mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40d3228db6 | ||
|
|
383564f988 | ||
|
|
fb2f7791fd | ||
|
|
80a3f8642a | ||
|
|
18bdbafa75 | ||
|
|
e31245b492 | ||
|
|
c7d10efefe | ||
|
|
408d6d76ef | ||
|
|
bf0ed69b60 | ||
|
|
afd9a8eff2 | ||
|
|
baab060628 | ||
|
|
3018515abf | ||
|
|
402d3f3d9d | ||
|
|
e193a300b1 | ||
|
|
0ed3be16f5 | ||
|
|
c34d8c5532 | ||
|
|
aaa0c1d9a3 | ||
|
|
1dfd0c2e56 | ||
|
|
63a94143e2 | ||
|
|
243d724fb2 | ||
|
|
d6235adccd | ||
|
|
ae0f9a770d | ||
|
|
305d55d972 | ||
|
|
aa14367f65 | ||
|
|
8f797df033 | ||
|
|
d680eeb40e | ||
|
|
6ede27667f | ||
|
|
187d100230 | ||
|
|
4f8df0cef6 | ||
|
|
76a7c2ce43 | ||
|
|
4911e49c6b | ||
|
|
f2f5f767b4 | ||
|
|
f209989f11 | ||
|
|
d651568921 | ||
|
|
27348a3341 | ||
|
|
ceba848bdd | ||
|
|
1a36b8b6ef | ||
|
|
61bc4a286d | ||
|
|
f5b3ad7381 | ||
|
|
a96c739d90 | ||
|
|
aaeaeabc81 | ||
|
|
0cf7c45488 | ||
|
|
db6eb221d8 | ||
|
|
130c23dd5b | ||
|
|
6075872881 | ||
|
|
7b65d5648f | ||
|
|
eb4a31e5f4 | ||
|
|
0d2f2957b3 | ||
|
|
51bf9cf679 | ||
|
|
777a8bba3c | ||
|
|
09eba5c0e8 | ||
|
|
b9924f511f | ||
|
|
32da8b5fd0 | ||
|
|
87f8a50326 | ||
|
|
b70a8c8a16 | ||
|
|
ebdef2d302 | ||
|
|
a6bb50c5e2 | ||
|
|
50eb48fcf9 | ||
|
|
6622565b14 | ||
|
|
f001de1e97 | ||
|
|
6711fab413 | ||
|
|
e2f28a84bc | ||
|
|
d1941c2f3d | ||
|
|
f35e6abb05 | ||
|
|
0598f78a34 | ||
|
|
f30b55ba47 | ||
|
|
751f29e340 | ||
|
|
2e629f8423 | ||
|
|
ae431f5b85 | ||
|
|
f5f697ceb0 | ||
|
|
02a8f0baf9 | ||
|
|
2932149b92 | ||
|
|
e1e4d67114 | ||
|
|
1e3f112d9c | ||
|
|
e3600949b0 | ||
|
|
856c80cc20 | ||
|
|
8c77796f91 | ||
|
|
f9986e1b7f | ||
|
|
48f8d00d2e | ||
|
|
8db995010f | ||
|
|
41d8b2c375 | ||
|
|
3b8fb93fb2 | ||
|
|
55be9374e1 | ||
|
|
ce1bade773 | ||
|
|
9fe046617c | ||
|
|
01763a6b36 | ||
|
|
b28bb44f97 | ||
|
|
411d0b4565 | ||
|
|
16697c442c | ||
|
|
a3b7af3ba5 | ||
|
|
33492b8f3a | ||
|
|
150ab83f73 | ||
|
|
35db7a4416 | ||
|
|
1ce93af144 | ||
|
|
f410a8fdd5 | ||
|
|
88914169bc | ||
|
|
8d51bcb74d | ||
|
|
5a87529934 | ||
|
|
0d7a41996e | ||
|
|
dc97de03d0 | ||
|
|
5d4a1248e4 | ||
|
|
f876d8eb09 | ||
|
|
1d1d92a5cf |
@@ -78,3 +78,6 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:
|
||||
#OPENAPI_ENABLED=true
|
||||
#API_ENABLED=true
|
||||
#MCP_ENABLED=true
|
||||
|
||||
# Default to false
|
||||
#TUSD_BEHIND_PROXY=true
|
||||
|
||||
+11
-2
@@ -14,8 +14,9 @@ Please take a moment to review this guide. It will help you understand how to co
|
||||
2. [Reporting Issues](#reporting-issues)
|
||||
3. [Submitting Changes](#submitting-changes)
|
||||
4. [Code Style Guidelines](#code-style-guidelines)
|
||||
5. [Pull Request Process](#pull-request-process)
|
||||
6. [Community Guidelines](#community-guidelines)
|
||||
5. [E2E Tests](#e2e-tests)
|
||||
6. [Pull Request Process](#pull-request-process)
|
||||
7. [Community Guidelines](#community-guidelines)
|
||||
|
||||
---
|
||||
|
||||
@@ -88,6 +89,14 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
||||
|
||||
---
|
||||
|
||||
## E2E Tests
|
||||
|
||||
E2E tests now live in a dedicated repository: [Portabase/e2e-tests](https://github.com/Portabase/e2e-tests).
|
||||
|
||||
If your contribution needs corresponding end-to-end test coverage, add it there instead of this repo.
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Ensure your code passes all tests and linters.
|
||||
|
||||
+66
-97
@@ -4,114 +4,83 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
outputs:
|
||||
image: ${{ steps.image.outputs.ref }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Compute image reference
|
||||
id: image
|
||||
run: echo "ref=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/portabase:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Docker image
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/dockerfile/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: portabase/portabase:test
|
||||
push: true
|
||||
tags: ${{ steps.image.outputs.ref }}
|
||||
target: prod
|
||||
|
||||
- name: Run app container
|
||||
run: |
|
||||
docker run -d --name myapp \
|
||||
-p 8887:80 \
|
||||
-e NODE_ENV=production \
|
||||
-e PROJECT_URL=http://localhost:8887 \
|
||||
-e PROJECT_SECRET=80af5e4c875dfded3a6ba511c6ed8b0160cad723f848ee0851403ed6141f6ba1 \
|
||||
portabase/portabase:test
|
||||
|
||||
- name: Wait for app port to be listening
|
||||
run: |
|
||||
for i in {1..40}; do
|
||||
|
||||
if curl -fsS http://localhost:8887/ >/dev/null; then
|
||||
echo "App is responding on port 8887"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for app readiness... ($i/40)"
|
||||
sleep 2
|
||||
done
|
||||
echo "Timeout: app never became ready"
|
||||
docker logs myapp
|
||||
exit 1
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm exec playwright test
|
||||
env:
|
||||
PROJECT_URL: http://localhost:8887
|
||||
# E2E Notification
|
||||
E2E_NOTIFICATION_SMTP_HOST: ${{ secrets.E2E_NOTIFICATION_SMTP_HOST }}
|
||||
E2E_NOTIFICATION_SMTP_PORT: ${{ secrets.E2E_NOTIFICATION_SMTP_PORT }}
|
||||
E2E_NOTIFICATION_SMTP_USER: ${{ secrets.E2E_NOTIFICATION_SMTP_USER }}
|
||||
E2E_NOTIFICATION_SMTP_PASSWORD: ${{ secrets.E2E_NOTIFICATION_SMTP_PASSWORD }}
|
||||
E2E_NOTIFICATION_SMTP_FROM: ${{ secrets.E2E_NOTIFICATION_SMTP_FROM }}
|
||||
E2E_NOTIFICATION_SMTP_TO: ${{ secrets.E2E_NOTIFICATION_SMTP_TO }}
|
||||
E2E_NOTIFICATION_SLACK_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_SLACK_WEBHOOK }}
|
||||
E2E_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_DISCORD_WEBHOOK }}
|
||||
E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN }}
|
||||
E2E_NOTIFICATION_TELEGRAM_CHAT_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_CHAT_ID }}
|
||||
E2E_NOTIFICATION_TELEGRAM_TOPIC_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_TOPIC_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
|
||||
run-e2e:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
needs: build-image
|
||||
uses: portabase/e2e-tests/.github/workflows/run-e2e.yml@main
|
||||
with:
|
||||
server_image: ${{ needs.build-image.outputs.image }}
|
||||
secrets:
|
||||
E2E_NOTIFICATION_SMTP_HOST: ${{ secrets.E2E_NOTIFICATION_SMTP_HOST }}
|
||||
E2E_NOTIFICATION_SMTP_PORT: ${{ secrets.E2E_NOTIFICATION_SMTP_PORT }}
|
||||
E2E_NOTIFICATION_SMTP_USER: ${{ secrets.E2E_NOTIFICATION_SMTP_USER }}
|
||||
E2E_NOTIFICATION_SMTP_PASSWORD: ${{ secrets.E2E_NOTIFICATION_SMTP_PASSWORD }}
|
||||
E2E_NOTIFICATION_SMTP_FROM: ${{ secrets.E2E_NOTIFICATION_SMTP_FROM }}
|
||||
E2E_NOTIFICATION_SMTP_TO: ${{ secrets.E2E_NOTIFICATION_SMTP_TO }}
|
||||
E2E_NOTIFICATION_SLACK_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_SLACK_WEBHOOK }}
|
||||
E2E_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_DISCORD_WEBHOOK }}
|
||||
E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN }}
|
||||
E2E_NOTIFICATION_TELEGRAM_CHAT_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_CHAT_ID }}
|
||||
E2E_NOTIFICATION_TELEGRAM_TOPIC_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_TOPIC_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_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 }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
create-release:
|
||||
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
|
||||
outputs:
|
||||
draft_tag: ${{ steps.release_step.outputs.draft_tag }}
|
||||
|
||||
@@ -59,3 +59,4 @@ certificates
|
||||
.claude
|
||||
.codex
|
||||
/docs
|
||||
.worktrees
|
||||
|
||||
+1
-1
@@ -33,5 +33,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.17.3
|
||||
version: 1.20.2
|
||||
date-released: '2026-03-02'
|
||||
|
||||
@@ -47,16 +47,10 @@ export-keycloak:
|
||||
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
|
||||
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
|
||||
|
||||
end-to-end:
|
||||
@echo "Starting E2E testing..."
|
||||
@docker compose -f docker-compose.e2e.yml up -d
|
||||
@CI=true PROJECT_URL=http://localhost:8887 pnpm playwright test --project=chromium || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
|
||||
@docker compose -f docker-compose.e2e.yml down --volumes
|
||||
@echo "Finished E2E testing successfully."
|
||||
seed-blob:
|
||||
@chmod +x seeds/azurite/azurite-seed.sh
|
||||
@bash seeds/azurite/azurite-seed.sh
|
||||
|
||||
e2e-manual:
|
||||
@echo "Starting E2E testing..."
|
||||
@docker compose -f docker-compose.e2e.yml up -d
|
||||
@pnpm playwright test --ui || (docker compose -f docker-compose.e2e.yml down --volumes && exit 1)
|
||||
@docker compose -f docker-compose.e2e.yml down --volumes
|
||||
@echo "Finished E2E testing successfully."
|
||||
list-blob:
|
||||
@chmod +x seeds/azurite/azurite-list.sh
|
||||
@bash seeds/azurite/azurite-list.sh
|
||||
@@ -54,12 +54,13 @@
|
||||
|
||||
## 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)
|
||||
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||
- 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)
|
||||
|
||||
**Ensure Docker is installed on your machine before getting started.**
|
||||
|
||||
+27
-20
@@ -9,6 +9,7 @@ import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {BackupModalProvider} from "@/features/database/backup-modal-context";
|
||||
import {DatabaseContent} from "@/features/database/database-content";
|
||||
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||
import {LogsModalProvider} from "@/features/logs/logs-modal-context";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string;
|
||||
@@ -34,7 +35,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
project: true,
|
||||
retentionPolicy: true,
|
||||
alertPolicies: true,
|
||||
storagePolicies: true
|
||||
storagePolicies: true,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,13 +51,17 @@ export default async function RoutePage(props: PageParams<{
|
||||
with: {
|
||||
storageChannel: true
|
||||
}
|
||||
}
|
||||
},
|
||||
logs: true
|
||||
},
|
||||
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
||||
});
|
||||
|
||||
const restorations = await db.query.restoration.findMany({
|
||||
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
|
||||
with: {
|
||||
logs: true
|
||||
},
|
||||
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
||||
});
|
||||
|
||||
@@ -84,7 +89,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
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;
|
||||
@@ -93,23 +98,25 @@ export default async function RoutePage(props: PageParams<{
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<BackupModalProvider>
|
||||
<DatabaseContent
|
||||
activeMember={activeMember}
|
||||
settings={settings}
|
||||
database={dbItem}
|
||||
databaseHealthLogs={databaseHealthLogs}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
restorations={restorations}
|
||||
backups={backups}
|
||||
totalBackups={totalBackups}
|
||||
availableBackups={availableBackups}
|
||||
successRate={successRate}
|
||||
organizationId={organization.id}
|
||||
activeOrganizationChannels={[]}
|
||||
activeOrganizationStorageChannels={[]}
|
||||
/>
|
||||
</BackupModalProvider>
|
||||
<LogsModalProvider>
|
||||
<BackupModalProvider>
|
||||
<DatabaseContent
|
||||
activeMember={activeMember}
|
||||
settings={settings}
|
||||
database={dbItem}
|
||||
databaseHealthLogs={databaseHealthLogs}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
restorations={restorations}
|
||||
backups={backups}
|
||||
totalBackups={totalBackups}
|
||||
availableBackups={availableBackups}
|
||||
successRate={successRate}
|
||||
organizationId={organization.id}
|
||||
activeOrganizationChannels={[]}
|
||||
activeOrganizationStorageChannels={[]}
|
||||
/>
|
||||
</BackupModalProvider>
|
||||
</LogsModalProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,9 @@ export default async function RoutePage(props: PageParams<{
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
{proj.databases.length > 0 ? (
|
||||
<CardsWithPagination
|
||||
data={proj.databases}
|
||||
data={[...proj.databases].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)}
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {eventEmitter} from "@/lib/event";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
|
||||
import {EventKind} from "@/features/notifications/notifications.types";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {JobLogEntry} from "@/features/logs/types";
|
||||
|
||||
const log = logger.child({module: "api/agent/backup/route"});
|
||||
|
||||
@@ -22,6 +23,8 @@ export type BodyPatch = {
|
||||
status: "success" | "failed"
|
||||
size: number
|
||||
generatedId: string
|
||||
logs: JobLogEntry[]
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
@@ -30,6 +33,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
}) => {
|
||||
try {
|
||||
const body: BodyPost = await request.json();
|
||||
|
||||
const method = body.method
|
||||
const database = await getDatabaseOrThrow(body.generatedId);
|
||||
|
||||
@@ -104,7 +108,6 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
}) => {
|
||||
try {
|
||||
const body: BodyPatch = await request.json();
|
||||
log.info({data: body}, "Body from PATH in backup route");
|
||||
|
||||
const status = body.status
|
||||
const backupId = body.backupId
|
||||
@@ -127,12 +130,37 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({
|
||||
status: status,
|
||||
fileSize: backupSize
|
||||
fileSize: backupSize,
|
||||
durationMs: body.durationMs
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id))
|
||||
.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});
|
||||
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {db as dbClient, db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {JobLogEntry} from "@/features/logs/types";
|
||||
|
||||
const log = logger.child({module: "api/agent/restore"});
|
||||
|
||||
export type BodyResultRestore = {
|
||||
generatedId: string
|
||||
status: string
|
||||
logs: JobLogEntry[]
|
||||
durationMs: number
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
|
||||
await db
|
||||
const [restorationUpdated] = await db
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set(withUpdatedAt({status: body.status as RestorationStatus}))
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
.set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs}))
|
||||
.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");
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const log = logger.child({module: "api/agent/status/helpers"});
|
||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
|
||||
const databasesResponse = [];
|
||||
|
||||
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({
|
||||
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null, backupSize: number | null) => ({
|
||||
generatedId: database.agentDatabaseId,
|
||||
dbms: database.dbms,
|
||||
storages: storages,
|
||||
@@ -31,7 +31,8 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
restore: {
|
||||
action: restoreAction,
|
||||
file: UrlBackup,
|
||||
metaFile: urlMeta
|
||||
metaFile: urlMeta,
|
||||
size: backupSize
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -49,6 +50,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
let restoreAction: boolean = false
|
||||
let urlBackup: string | null = null;
|
||||
let urlMeta: string | null = null
|
||||
let backupSize: number | null = null
|
||||
|
||||
if (!existingDatabase) {
|
||||
if (!isUuidv4(db.generatedId)) {
|
||||
@@ -90,7 +92,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
|
||||
const storages = await getDatabaseStorageChannels(databaseCreated.id)
|
||||
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null, null));
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -180,6 +182,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
if (result.success) {
|
||||
urlBackup = result.url ?? null;
|
||||
urlMeta = resultMeta.url ?? null
|
||||
backupSize = restoration.backupStorage.size
|
||||
} else {
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
@@ -205,7 +208,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
}
|
||||
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
|
||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
|
||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta, backupSize));
|
||||
}
|
||||
}
|
||||
return databasesResponse;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
TZ: "Europe/Paris"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- portabase-e2e-data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:18-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-e2e-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=devdb
|
||||
- POSTGRES_USER=devuser
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
portabase-e2e-data:
|
||||
postgres-e2e-data:
|
||||
@@ -41,5 +41,25 @@ services:
|
||||
- "8025:8025"
|
||||
- "1025:1025"
|
||||
|
||||
azurite:
|
||||
image: mcr.microsoft.com/azure-storage/azurite
|
||||
command: azurite --blobHost 0.0.0.0 --skipApiVersionCheck
|
||||
ports:
|
||||
- "10000:10000"
|
||||
volumes:
|
||||
- azurite-data:/data
|
||||
extra_hosts:
|
||||
- "localhost:host-gateway"
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
# DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
azurite-data:
|
||||
|
||||
networks:
|
||||
portabase:
|
||||
name: portabase_network
|
||||
external: true
|
||||
|
||||
@@ -92,6 +92,7 @@ ENV PORT=80
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV PGDATA=/data/postgres
|
||||
ENV PRIVATE_PATH=/data/private
|
||||
ENV TUSD_BEHIND_PROXY=false
|
||||
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
|
||||
@@ -72,7 +72,18 @@ fi
|
||||
|
||||
mkdir -p /data/private/uploads/tmp
|
||||
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..."
|
||||
PORT=3000 node server.js &
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
events {}
|
||||
|
||||
http {
|
||||
map $http_x_forwarded_proto $forwarded_proto {
|
||||
default $scheme;
|
||||
"~*^\s*(https?)\s*(?:,|$)" $1;
|
||||
}
|
||||
|
||||
client_max_body_size 20G;
|
||||
ignore_invalid_headers off;
|
||||
|
||||
@@ -18,7 +23,7 @@ http {
|
||||
|
||||
proxy_set_header 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 Upgrade $http_upgrade;
|
||||
@@ -34,7 +39,7 @@ http {
|
||||
|
||||
proxy_set_header 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {users} from "./helpers/auth";
|
||||
import {changeUserRole, create, switchToDefault} from "./helpers/access-management";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const firstOrganization = "Organization A";
|
||||
const secondOrganization = "Organization B";
|
||||
|
||||
test.describe.serial(() => {
|
||||
test("Create an organization from organizations page", async ({page}) => {
|
||||
await page.goto("/dashboard/admin/organizations");
|
||||
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
|
||||
|
||||
await create(page, "button", firstOrganization);
|
||||
|
||||
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
|
||||
await expect(page).toHaveURL("/dashboard/admin/organizations");
|
||||
await expect(page.getByRole("button", {name: firstOrganization, exact: true})).toBeVisible();
|
||||
|
||||
await switchToDefault(page);
|
||||
});
|
||||
|
||||
test("Create an organization from sidebar button", async ({page}) => {
|
||||
await page.goto("/dashboard/home");
|
||||
await expect(page.getByRole("heading", {name: "Dashboard"})).toBeVisible();
|
||||
|
||||
await create(page, "sidebar", secondOrganization);
|
||||
|
||||
await expect(page.getByText(/Organization has been successfully created\./i)).toBeVisible();
|
||||
await expect(page).toHaveURL("/dashboard/home");
|
||||
await expect(page.getByRole("button", {name: secondOrganization, exact: true})).toBeVisible();
|
||||
|
||||
await switchToDefault(page);
|
||||
});
|
||||
|
||||
test("Change John Doe's role from pending to user", async ({page}) => {
|
||||
await page.goto("/dashboard/admin/users");
|
||||
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
|
||||
|
||||
const userRow = page.locator("tr").filter({hasText: users.normal.email}).first();
|
||||
await expect(userRow).toBeVisible();
|
||||
await userRow.locator("button").last().click();
|
||||
await page.getByRole('menuitem', {name: 'Role'}).click();
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Change the user's role"})).toBeVisible();
|
||||
|
||||
await changeUserRole(page, "user")
|
||||
|
||||
await expect(page.getByText("User role changed successfully.")).toBeVisible();
|
||||
await expect(page.locator("tr").filter({hasText: users.normal.email}).getByText("user", {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test("Add John Doe to Organization A", async ({page}) => {
|
||||
await page.goto("/dashboard/admin/organizations");
|
||||
await expect(page.getByRole("heading", {name: "Active organizations"})).toBeVisible();
|
||||
|
||||
const organizationRow = page.locator("tr").filter({hasText: firstOrganization}).first();
|
||||
await expect(organizationRow).toBeVisible();
|
||||
await organizationRow.locator('a[href*="/dashboard/admin/organizations/"], a[href*="organizations/"]').click();
|
||||
|
||||
await expect(page.getByText(firstOrganization, {exact: true})).toBeVisible();
|
||||
await page.getByRole("button", {name: /Add member/i}).click();
|
||||
await expect(page.getByRole("heading", {name: "Add member to your organization"})).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Enter a user email").fill(users.normal.email);
|
||||
await page.getByRole("option", {name: new RegExp(users.normal.email, "i")}).click();
|
||||
await page.getByRole("button", {name: "Confirm"}).click();
|
||||
|
||||
await expect(page.getByText("Member successfully added!")).toBeVisible();
|
||||
await expect(page.getByText(users.normal.email, {exact: true})).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {execSync} from "child_process";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import {createAgentWithDockerDatabases} from "./helpers/agent-cli";
|
||||
import {create, edit, get, remove} from "./helpers/agent";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
const agent = {
|
||||
aName: "Agent A",
|
||||
aUpdatedName: "Agent A Updated",
|
||||
bName: "Agent B",
|
||||
description: "Agent created by Playwright E2E",
|
||||
updatedDescription: "Agent updated by Playwright E2E",
|
||||
};
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
test.describe.serial(() => {
|
||||
let agentWorkspace: string | null = null;
|
||||
|
||||
test("Create agent A from empty state", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(page.getByText("Create new Agent", {exact: true})).toBeVisible();
|
||||
await create(page, "emptyState", agent.aName, agent.description);
|
||||
|
||||
await expect(page.getByText("Success creating agent")).toBeVisible();
|
||||
await expect(get(page, agent.aName)).toBeVisible();
|
||||
await expect(page.getByText("Create new Agent", {exact: true})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Edit agent A", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(get(page, agent.aName)).toBeVisible();
|
||||
|
||||
await edit(page, agent.aName, agent.aUpdatedName, agent.updatedDescription);
|
||||
|
||||
await expect(page.getByText("Success updating agent")).toBeVisible();
|
||||
await expect(page.getByText(agent.aUpdatedName, {exact: true})).toBeVisible();
|
||||
await expect(page.getByText(agent.updatedDescription, {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test("Create agent B from classic button", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(page.getByRole("button", {name: /Create Agent/i})).toBeVisible();
|
||||
await create(page, "button", agent.bName, agent.description);
|
||||
|
||||
await expect(page.getByText("Success creating agent")).toBeVisible();
|
||||
await expect(get(page, agent.bName)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Delete Agent B", async ({page}) => {
|
||||
await page.goto("/dashboard/agents");
|
||||
await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
await expect(get(page, agent.bName)).toBeVisible();
|
||||
await remove(page, agent.bName);
|
||||
|
||||
await expect(page.getByText("Agent has been successfully deleted.")).toBeVisible();
|
||||
await expect(page).toHaveURL("/dashboard/agents");
|
||||
await expect(page.getByText(agent.bName)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// test("Launch the updated agent", async ({page}) => {
|
||||
// await page.goto("/dashboard/agents");
|
||||
// await expect(page.getByRole("heading", {name: "Agents"})).toBeVisible();
|
||||
// await get(page, agent.aName).click();
|
||||
//
|
||||
// await expect(page).toHaveURL(/\/dashboard\/agents\/.+/);
|
||||
// await expect(page.getByText(agent.aName, {exact: true})).toBeVisible();
|
||||
// await expect(page.getByText("Registration & Setup")).toBeVisible();
|
||||
//
|
||||
// const commandInput = page.locator("input[readonly]").first();
|
||||
// await page.locator("input[readonly]").first().locator("xpath=following-sibling::button[1]").click();
|
||||
// const command = await commandInput.inputValue();
|
||||
//
|
||||
// agentWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "portabase-agent-"));
|
||||
// await createAgentWithDockerDatabases(command, agentWorkspace);
|
||||
// execSync(`portabase start "${agent.aName}"`, {
|
||||
// cwd: agentWorkspace,
|
||||
// stdio: "pipe",
|
||||
// timeout: 120_000,
|
||||
// });
|
||||
//
|
||||
// await expect(page.getByText("Never connected.")).toHaveCount(0, {timeout: 120_000});
|
||||
// await expect(page.getByText("Action Required")).toHaveCount(0);
|
||||
// });
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (agentWorkspace) {
|
||||
try {
|
||||
execSync(`portabase stop "${agent.aName}"`, {
|
||||
cwd: agentWorkspace,
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`portabase uninstall --force "${agent.aName}"`, {
|
||||
cwd: agentWorkspace,
|
||||
stdio: "pipe",
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
fs.rmSync(agentWorkspace, {recursive: true, force: true});
|
||||
agentWorkspace = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, register, users} from "./helpers/auth";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
const TIMEOUT = undefined
|
||||
// const TIMEOUT = 5000
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH})
|
||||
|
||||
test.describe.serial( () => {
|
||||
|
||||
test('Redirect to login if not connected', async ({page}) => {
|
||||
await page.goto('/dashboard/projects');
|
||||
await expect(page).toHaveURL("login?redirect=%2Fdashboard%2Fprojects", {timeout: TIMEOUT});
|
||||
});
|
||||
|
||||
test('Password too short', async ({page}) => {
|
||||
await page.goto('')
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
let password = '123456'
|
||||
await register(page, users["admin"].username, users["admin"].email, password, password)
|
||||
|
||||
const toast = page.locator('text=Must have at least 8 character')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Password too simple', async ({page}) => {
|
||||
await page.goto('')
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
let password = '12345678'
|
||||
await register(page, users["admin"].username, users["admin"].email, password, password)
|
||||
|
||||
const toast = page.locator('text=Your password must contain at least one uppercase letter, one lowercase letter, one number, and one special character.')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Password and confirm password mismatch', async ({page}) => {
|
||||
await page.goto('/')
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
let password = 'testPASS123456!'
|
||||
let confirmPassword = 'testPASS123456!!'
|
||||
await register(page, users["admin"].username, users["admin"].email, password, confirmPassword)
|
||||
|
||||
const toast = page.locator('text=The passwords did not match')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful register for admin', async ({page}) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
|
||||
|
||||
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
|
||||
})
|
||||
|
||||
test('User already exists.', async ({page}) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
await register(page, users["admin"].username, users["admin"].email, users["admin"].password, users["admin"].password)
|
||||
|
||||
const toast = page.locator('text=User already exists. Use another email.')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful register for normal', async ({page}) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.click('text=Sign up')
|
||||
await expect(page).toHaveURL('/register')
|
||||
|
||||
await register(page, users["normal"].username, users["normal"].email, users["normal"].password, users["normal"].password)
|
||||
|
||||
await expect(page).toHaveURL('/login', {timeout: TIMEOUT})
|
||||
})
|
||||
|
||||
test('Failed login because account not active', async ({page}) => {
|
||||
await page.goto('/login')
|
||||
await login(page, users["normal"].email, users["normal"].password)
|
||||
|
||||
const toast = page.locator('text=Your account is not active.')
|
||||
await expect(toast).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful login', async ({page}) => {
|
||||
await page.goto('/login')
|
||||
await login(page, users["admin"].email, users["admin"].password)
|
||||
|
||||
await expect(page).toHaveURL('/dashboard/home', {timeout: TIMEOUT})
|
||||
await expect(page.getByRole('link', {name: 'Logo Portabase'})).toBeVisible()
|
||||
await page.context().storageState({path: LOCAL_STORAGE_PATH})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import {logout} from "./helpers/auth";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
test.describe( () => {
|
||||
test.afterAll(async () => {
|
||||
if (fs.existsSync(LOCAL_STORAGE_PATH)) {
|
||||
fs.unlinkSync(LOCAL_STORAGE_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
test("Remove shared storage state", async ({page}) => {
|
||||
await test.step("Logout shared authenticated session", async () => {
|
||||
if (!fs.existsSync(LOCAL_STORAGE_PATH)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(LOCAL_STORAGE_PATH, "utf-8").trim();
|
||||
if (!content || content === "{}") {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto('/dashboard/home');
|
||||
await logout(page);
|
||||
await expect(page).toHaveURL(/\/login(?:\?.*)?$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
export function getUserRow(page: Page, email: string) {
|
||||
return page.locator("tr").filter({hasText: email}).first();
|
||||
}
|
||||
|
||||
export function getOrganizationRow(page: Page, organizationName: string) {
|
||||
return page.locator("tr").filter({hasText: organizationName}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an organization from the selected entrypoint.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the organizations page create button.
|
||||
* - `sidebar`: the sidebar organization switcher.
|
||||
*
|
||||
* Executes from:
|
||||
* - `/dashboard/admin/organizations` for "button" entrypoint
|
||||
* - any `/dashboard/**` page for "sidebar" entrypoint
|
||||
*/
|
||||
export async function create(page: Page, entrypoint: "button" | "sidebar", name: string) {
|
||||
if (entrypoint === "sidebar") {
|
||||
await page.getByRole("button", {name: "Default Organization"}).click();
|
||||
await page.getByText("Create organization", {exact: true}).click();
|
||||
} else {
|
||||
await page.getByRole("button", {name: /Create a new organization/i}).click();
|
||||
}
|
||||
|
||||
await page.getByLabel("Name").fill(name);
|
||||
await page.getByRole("button", {name: "Create"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active organization from the sidebar switcher.
|
||||
*
|
||||
* Executes from: any `/dashboard/**` if the sidebar is visible.
|
||||
*/
|
||||
export async function switchTo(page: Page, name: string) {
|
||||
await page.getByTestId('organization-dropdown').click();
|
||||
await page.getByRole('menuitem', {name: name}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch back to the default organization.
|
||||
*
|
||||
* Executes from: any `/dashboard/**` if the sidebar is visible.
|
||||
*/
|
||||
export async function switchToDefault(page: Page) {
|
||||
await switchTo(page, "Default Organization");
|
||||
}
|
||||
|
||||
const ROLE_LABELS = {
|
||||
pending: "Pending",
|
||||
user: "User",
|
||||
admin: "Admin",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Change the global role of a user from the admin users role modal.
|
||||
*
|
||||
* Executes from: the "Change the user's role" dialog opened from `/dashboard/admin/users`.
|
||||
*/
|
||||
export async function changeUserRole(page: Page, role: keyof typeof ROLE_LABELS) {
|
||||
const roleOption = ROLE_LABELS[role];
|
||||
|
||||
await page.getByRole("combobox").click();
|
||||
await page.getByRole("option", {name: roleOption}).click();
|
||||
await page.getByRole("button", {name: "Validate"}).click();
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import * as pty from "node-pty";
|
||||
|
||||
type InteractiveStep = {
|
||||
match: RegExp;
|
||||
reply: string;
|
||||
};
|
||||
|
||||
function normalizeOutput(output: string) {
|
||||
return output.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an interactive CLI command and answer prompts in sequence.
|
||||
* Entry point:
|
||||
* - `command` is the full CLI command executed in the PTY.
|
||||
* - `cwd` is the local workspace where the command is launched.
|
||||
* - `steps` defines which prompt text is matched and which reply is sent.
|
||||
* - `timeoutMs` controls when the command is aborted if it stalls.
|
||||
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
|
||||
*/
|
||||
export async function runInteractiveCommand(
|
||||
command: string,
|
||||
cwd: string,
|
||||
steps: InteractiveStep[],
|
||||
timeoutMs: number = 120_000,
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = pty.spawn("sh", ["-lc", command], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
},
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
|
||||
let currentStep = 0;
|
||||
let output = "";
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`Interactive command timed out.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
|
||||
}, timeoutMs);
|
||||
|
||||
const handleChunk = (chunk: string) => {
|
||||
output += chunk;
|
||||
const normalizedOutput = normalizeOutput(output);
|
||||
|
||||
while (currentStep < steps.length && steps[currentStep].match.test(normalizedOutput)) {
|
||||
child.write(steps[currentStep].reply);
|
||||
currentStep += 1;
|
||||
}
|
||||
};
|
||||
|
||||
child.onData(handleChunk);
|
||||
child.onExit(({exitCode}) => {
|
||||
clearTimeout(timer);
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`Interactive command failed with exit code ${exitCode}.\n\nCommand: ${command}\n\nOutput:\n${normalizeOutput(output)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure a Portabase agent with two Docker-backed databases.
|
||||
* Entry point:
|
||||
* - `command` is the CLI setup command copied from the agent details page.
|
||||
* - `cwd` is the local workspace where the agent is installed.
|
||||
* - this helper answers the wizard for PostgreSQL first, then MariaDB.
|
||||
* Executes from: not page-bound; runs from a local test workspace on the Node.js side.
|
||||
*/
|
||||
export async function createAgentWithDockerDatabases(command: string, cwd: string) {
|
||||
await runInteractiveCommand(command, cwd, [
|
||||
{
|
||||
match: /configure.*database|add.*database/i,
|
||||
reply: "y\n",
|
||||
},
|
||||
{
|
||||
match: /docker.*new local container|manual.*external|external.*existing/i,
|
||||
reply: "\r",
|
||||
},
|
||||
{
|
||||
match: /postgresql|mariadb/i,
|
||||
reply: "\r",
|
||||
},
|
||||
{
|
||||
match: /another.*database|add.*another|configure.*another/i,
|
||||
reply: "y\n",
|
||||
},
|
||||
{
|
||||
match: /docker.*new local container|manual.*external|external.*existing/i,
|
||||
reply: "\r",
|
||||
},
|
||||
{
|
||||
match: /postgresql|mariadb/i,
|
||||
reply: "\u001B[B\r",
|
||||
},
|
||||
{
|
||||
match: /another.*database|add.*another|configure.*another/i,
|
||||
reply: "n\n",
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate an agent card in the list.
|
||||
|
||||
* Executes from: `/dashboard/agents`.
|
||||
*/
|
||||
export function get(page: Page, name: string) {
|
||||
return page.locator('a[href^="/dashboard/agents"]').filter({hasText: name}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent from the selected entrypoint.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the classic create button.
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `auto`: uses the classic create button and falls back to the empty-state CTA.
|
||||
*
|
||||
* Executes from: `/dashboard/agents`.
|
||||
*/
|
||||
export async function create(page: Page, entrypoint: "auto" | "emptyState" | "button" = "auto", agentName: string, description: string) {
|
||||
if (entrypoint === "auto") {
|
||||
const createButton = page.getByRole("button", {name: /Create Agent/i});
|
||||
if (await createButton.isVisible()) await page.getByRole("button", {name: /Create Agent/i}).click();
|
||||
await page.getByText("Create new Agent", {exact: true}).click();
|
||||
} else if (entrypoint === "button") {
|
||||
await page.getByRole("button", {name: /Create Agent/i}).click();
|
||||
} else if (entrypoint === "emptyState") {
|
||||
await page.getByText("Create new Agent", {exact: true}).click();
|
||||
}
|
||||
|
||||
await page.getByLabel("Name").fill(agentName);
|
||||
await page.getByLabel("Description").fill(description);
|
||||
await page.getByRole("button", {name: "Create"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing agent (name and description) from its details page.
|
||||
*
|
||||
* Executes from: `/dashboard/agents/[agentId]`.
|
||||
*/
|
||||
export async function edit(page: Page, currentName: string, updatedName: string, updatedDescription: string) {
|
||||
await get(page, currentName).click();
|
||||
|
||||
await page
|
||||
.getByRole("button", {name: /Delete Agent/i})
|
||||
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
|
||||
.click();
|
||||
|
||||
await page.getByLabel("Name").fill(updatedName);
|
||||
await page.getByLabel("Description").fill(updatedDescription);
|
||||
await page.getByRole("button", {name: "Update"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an agent from its details page.
|
||||
|
||||
* Executes from: `/dashboard/agents/[agentId]`.
|
||||
*/
|
||||
export async function remove(page: Page, name: string) {
|
||||
await get(page, name).click();
|
||||
await page.getByRole("button", {name: /Delete Agent/i}).click();
|
||||
await page.getByRole("button", {name: "Delete", exact: true}).click();
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
export type UserCredentials = {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export const users: Record<string, UserCredentials> = {
|
||||
admin: {username: "Admin", email: "admin@example.com", password: "testPASS123456!"},
|
||||
normal: {username: "John Doe", email: "john.doe@example.com", password: "testPASS123456!"},
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill and submit the registration form.
|
||||
*
|
||||
* Executes from: `/register`.
|
||||
*/
|
||||
export async function register(page: Page, name: string, email: string, password: string, confirmPassword: string) {
|
||||
await page.locator('input[name="name"]').fill(name)
|
||||
await page.locator('input[name="email"]').fill(email)
|
||||
await page.locator('input[name="password"]').fill(password)
|
||||
await page.locator('input[name="confirmPassword"]').fill(confirmPassword)
|
||||
|
||||
await page.click('button[type="submit"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill and submit the login form.
|
||||
*
|
||||
* Executes from: `/login`.
|
||||
*/
|
||||
export async function login(page: Page, email: string, password: string) {
|
||||
await page.locator('input[name="email"]').fill(email)
|
||||
await page.locator('input[name="password"]').fill(password)
|
||||
|
||||
await page.locator('button:has-text("Login")').click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out the current authenticated user.
|
||||
*
|
||||
* Executes from: any authenticated `/dashboard/**` page.
|
||||
*/
|
||||
export async function logout(page: Page) {
|
||||
const profileButton = page.getByTestId('profile-dropdown')
|
||||
await profileButton.first().click();
|
||||
|
||||
await page.getByRole("menuitem").filter({hasText: /Logout/i}).click();
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
const REQUIRED_E2E_ENV_VARS = [
|
||||
"E2E_NOTIFICATION_SMTP_HOST",
|
||||
"E2E_NOTIFICATION_SMTP_PORT",
|
||||
"E2E_NOTIFICATION_SMTP_USER",
|
||||
"E2E_NOTIFICATION_SMTP_PASSWORD",
|
||||
"E2E_NOTIFICATION_SMTP_FROM",
|
||||
"E2E_NOTIFICATION_SMTP_TO",
|
||||
"E2E_NOTIFICATION_SLACK_WEBHOOK",
|
||||
"E2E_NOTIFICATION_DISCORD_WEBHOOK",
|
||||
"E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN",
|
||||
"E2E_NOTIFICATION_TELEGRAM_CHAT_ID",
|
||||
"E2E_NOTIFICATION_TELEGRAM_TOPIC_ID",
|
||||
"E2E_NOTIFICATION_GOTIFY_SERVER_URL",
|
||||
"E2E_NOTIFICATION_GOTIFY_APP_TOKEN",
|
||||
"E2E_NOTIFICATION_NTFY_TOPIC",
|
||||
"E2E_NOTIFICATION_NTFY_SERVER_URL",
|
||||
"E2E_NOTIFICATION_NTFY_TOKEN",
|
||||
"E2E_NOTIFICATION_NTFY_USERNAME",
|
||||
"E2E_NOTIFICATION_NTFY_PASSWORD",
|
||||
"E2E_NOTIFICATION_WEBHOOK_URL",
|
||||
"E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER",
|
||||
"E2E_NOTIFICATION_WEBHOOK_SECRET",
|
||||
"E2E_STORAGE_AWS_S3_ENDPOINT_URL",
|
||||
"E2E_STORAGE_AWS_S3_REGION",
|
||||
"E2E_STORAGE_AWS_S3_ACCESS_KEY",
|
||||
"E2E_STORAGE_AWS_S3_SECRET_KEY",
|
||||
"E2E_STORAGE_AWS_S3_BUCKET_NAME",
|
||||
"E2E_STORAGE_AWS_S3_PORT",
|
||||
"E2E_STORAGE_R2_ENDPOINT_URL",
|
||||
"E2E_STORAGE_R2_REGION",
|
||||
"E2E_STORAGE_R2_ACCESS_KEY",
|
||||
"E2E_STORAGE_R2_SECRET_KEY",
|
||||
"E2E_STORAGE_R2_BUCKET_NAME",
|
||||
"E2E_STORAGE_R2_PORT",
|
||||
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID",
|
||||
"E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET",
|
||||
"E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID",
|
||||
] as const;
|
||||
|
||||
function assertRequiredEnvVars() {
|
||||
const missingVars = REQUIRED_E2E_ENV_VARS.filter((name) => {
|
||||
const value = process.env[name]?.trim();
|
||||
return !value;
|
||||
});
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables:\n${missingVars.map((name) => `- ${name}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertRequiredEnvVars();
|
||||
|
||||
export function getEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate a notification channel card in the list.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export function get(page: Page, channelName: string) {
|
||||
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
|
||||
has: page.locator('h3', {hasText: channelName}),
|
||||
}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for an existing notification channel.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function edit(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(1).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing notification channel.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function remove(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(2).click();
|
||||
await page.getByRole("button", {name: "Delete"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the notification channel creation form.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the classic add button.
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `auto` use the classic add button and falls back to the empty-state CTA.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function create(
|
||||
page: Page,
|
||||
provider: "Discord" | "Gotify" | "ntfy.sh" | "Slack" | "Email" | "Telegram" | "Webhook",
|
||||
channelName: string,
|
||||
fillConfig: (page: Page) => Promise<void>,
|
||||
entrypoint: "auto" | "emptyState" | "button" = "auto",
|
||||
) {
|
||||
if (entrypoint === "auto") {
|
||||
const addButton = page.getByRole("button", {name: /Add notification channel/i});
|
||||
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add notification channel/i}).click();
|
||||
else await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
|
||||
} else if (entrypoint === "button") {
|
||||
await page.getByRole("button", {name: /Add notification channel/i}).click();
|
||||
} else if (entrypoint === "emptyState") {
|
||||
await page.getByRole("button", {name: /No notification channels configured yet/i}).click();
|
||||
}
|
||||
|
||||
await page.getByText(provider, {exact: true}).click();
|
||||
await page.getByLabel(/Channel Name/).fill(channelName);
|
||||
await fillConfig(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the notification channel creation form.
|
||||
*
|
||||
* Executes from: the add notification channel dialog opened from `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function submit(page: Page) {
|
||||
await page.getByRole("button", {name: "Add Channel"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for a notification channel and trigger the test action.
|
||||
*
|
||||
* Executes from: `/dashboard/notifications/channels`.
|
||||
*/
|
||||
export async function testFromEdit(page: Page, channelName: string) {
|
||||
await edit(page, channelName);
|
||||
await page.getByRole("button", {name: /Test Channel/i}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current notification channel dialog without saving.
|
||||
*
|
||||
* Executes from: the add or edit notification channel dialog.
|
||||
*/
|
||||
export async function cancel(page: Page) {
|
||||
await page.getByRole("button", {name: "Cancel"}).click();
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate a project card in the list.
|
||||
*
|
||||
* Executes from: `/dashboard/projects`.
|
||||
*/
|
||||
export function get(page: Page, projectName: string) {
|
||||
return page.locator('a[href^="/dashboard/projects/"]').filter({hasText: projectName}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a project from the selected entrypoint.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `button`: the classic create button.
|
||||
*
|
||||
* * Executes from: `/dashboard/projects`.
|
||||
*/
|
||||
export async function create(page: Page, entrypoint: "emptyState" | "button", projectName: string) {
|
||||
if (entrypoint === "emptyState") {
|
||||
await page.getByText("Create new Project", {exact: true}).click();
|
||||
} else {
|
||||
await page.getByRole("button", {name: /Create Project/i}).click();
|
||||
}
|
||||
|
||||
await page.getByLabel("Name").fill(projectName);
|
||||
await page.getByRole("button", {name: "Create"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing project from its details page.
|
||||
*
|
||||
* Executes from: `/dashboard/projects/[projectId]`.
|
||||
*/
|
||||
export async function edit(page: Page, currentName: string, updatedName: string) {
|
||||
await get(page, currentName).click();
|
||||
|
||||
await page
|
||||
.getByRole("button", {name: /Delete Project/i})
|
||||
.locator("xpath=ancestor::div[1]/preceding-sibling::div[1]/*[1]")
|
||||
.click();
|
||||
|
||||
await page.getByLabel("Name").fill(updatedName);
|
||||
await page.getByRole("button", {name: "Update"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing project from its details page.
|
||||
*
|
||||
* Executes from: `/dashboard/projects/[projectId]`.
|
||||
* */
|
||||
export async function remove(page: Page, projectName: string) {
|
||||
await get(page, projectName).click();
|
||||
await page.getByRole("button", {name: /Delete Project/i}).click();
|
||||
await page.getByRole("button", {name: "Delete", exact: true}).click();
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const LOCAL_STORAGE_PATH = "./e2e/local-storage.json";
|
||||
@@ -1,103 +0,0 @@
|
||||
import {Page} from "@playwright/test";
|
||||
|
||||
|
||||
/**
|
||||
* Locate a storage channel card in the channels list.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export function get(page: Page, channelName: string) {
|
||||
return page.locator('div.block.transition-all.duration-200.rounded-xl', {
|
||||
has: page.locator('h3', {hasText: channelName}),
|
||||
}).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the storage channel creation form.
|
||||
*
|
||||
* Available entrypoints:
|
||||
* - `button`: the classic add button.
|
||||
* - `emptyState`: the empty-state CTA.
|
||||
* - `auto`: uses the classic add button and falls back to the empty-state CTA.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function create(
|
||||
page: Page,
|
||||
provider: "S3" | "Google Drive",
|
||||
channelName: string,
|
||||
fillConfig: (page: Page) => Promise<void>,
|
||||
entrypoint: "auto" | "emptyState" | "button" = "auto",
|
||||
) {
|
||||
if (entrypoint === "auto") {
|
||||
const addButton = page.getByRole("button", {name: /Add storage channel/i});
|
||||
if (await addButton.isVisible()) await page.getByRole("button", {name: /Add storage channel/i}).click();
|
||||
else await page.getByText("No storage channels configured yet", {exact: true}).click();
|
||||
} else if (entrypoint === "button") {
|
||||
await page.getByRole("button", {name: /Add storage channel/i}).click();
|
||||
} else if (entrypoint === "emptyState") {
|
||||
await page.getByText("No storage channels configured yet", {exact: true}).click();
|
||||
}
|
||||
|
||||
await page.getByText(provider, {exact: true}).click();
|
||||
await page.getByLabel(/Channel Name/).fill(channelName);
|
||||
await fillConfig(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for an existing storage channel.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function edit(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(1).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing storage channel.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function remove(page: Page, channelName: string) {
|
||||
const card = get(page, channelName);
|
||||
await card.locator("button").nth(2).click();
|
||||
await page.getByRole("button", {name: "Delete"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the storage connection test in the current dialog.
|
||||
*
|
||||
* Executes from: the add or edit storage channel dialog opened from `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function testConnection(page: Page) {
|
||||
await page.getByRole("button", {name: /Test Storage/i}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the storage channel creation form.
|
||||
*
|
||||
* Executes from: the add storage channel dialog opened from `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function submit(page: Page) {
|
||||
await page.getByRole("button", {name: "Add Channel"}).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit dialog for a storage channel and trigger the test action.
|
||||
*
|
||||
* Executes from: `/dashboard/storages/channels`.
|
||||
*/
|
||||
export async function testFromEdit(page: Page, channelName: string) {
|
||||
await edit(page, channelName);
|
||||
await testConnection(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current storage channel dialog without saving.
|
||||
*
|
||||
* Executes from: the add or edit storage channel dialog.
|
||||
*/
|
||||
export async function cancel(page: Page) {
|
||||
await page.getByRole("button", {name: "Cancel"}).click();
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const validChannelName = "Discord E2E Required";
|
||||
const invalidChannelName = "Discord E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid Discord channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Discord", validChannelName, async (page) => {
|
||||
// await page.getByLabel(/Discord Webhook URL/).fill(getEnv("E2E_NOTIFICATION_DISCORD_WEBHOOK"));
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Sent to Discord")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
|
||||
// });
|
||||
//
|
||||
// test("Edit and test a valid Discord channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Sent to Discord")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Discord channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Discord", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Discord Webhook URL/).fill("https://discord.com/api/webhooks/123456789012345678/wrong-discord-webhook-token");
|
||||
});
|
||||
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toBeVisible();
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
await expect(page.getByRole("heading", {name: "Edit Notification Channel"})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Delete invalid Discord channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const validChannelName = "Gotify E2E Required";
|
||||
const invalidChannelName = "Gotify E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid Gotify channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Gotify", validChannelName, async (page) => {
|
||||
// await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
|
||||
// await page.getByLabel(/Application Token/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_APP_TOKEN"));
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Edit and test a valid Gotify channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText("Sent to Gotify")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Gotify channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Gotify", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Gotify Server URL/).fill(getEnv("E2E_NOTIFICATION_GOTIFY_SERVER_URL"));
|
||||
await page.getByLabel(/Application Token/).fill("wrong-gotify-app-token");
|
||||
});
|
||||
await expect(page.getByRole("heading", {name: "Add Notification Channel"})).toBeVisible();
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Gotify channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const requiredChannelName = "Ntfy E2E Required";
|
||||
const optionalChannelName = "Ntfy E2E Optional";
|
||||
const invalidChannelName = "Ntfy E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channels", () => {
|
||||
// test("Create and test a valid Ntfy channel with only required fields", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "ntfy.sh", requiredChannelName, async (page) => {
|
||||
// await page.getByLabel(/Topic Name/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOPIC"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, requiredChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, requiredChannelName);
|
||||
// await expect(page.getByText("Sent to ntfy")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Create and test a valid Ntfy channel with optional fields", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "ntfy.sh", optionalChannelName, async (page) => {
|
||||
// await page.getByLabel(/Topic Name/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOPIC"));
|
||||
// await page.getByLabel(/^Server URL$/).fill(getEnv("E2E_NOTIFICATION_NTFY_SERVER_URL"));
|
||||
// await page.getByLabel(/^Access Token$/).fill(getEnv("E2E_NOTIFICATION_NTFY_TOKEN"));
|
||||
// await page.getByLabel(/^Basic Auth Username$/).fill(getEnv("E2E_NOTIFICATION_NTFY_USERNAME"));
|
||||
// await page.getByLabel(/^Basic Auth Password$/).fill(getEnv("E2E_NOTIFICATION_NTFY_PASSWORD"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, optionalChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, optionalChannelName);
|
||||
// await expect(page.getByText("Sent to ntfy")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Ntfy channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "ntfy.sh", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Topic Name/).fill("wrong-ntfy-topic");
|
||||
await page.getByLabel(/^Server URL$/).fill(getEnv("E2E_NOTIFICATION_NTFY_SERVER_URL"));
|
||||
await page.getByLabel(/^Access Token$/).fill("wrong-ntfy-token");
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Ntfy channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const validChannelName = "Slack E2E Required";
|
||||
const invalidChannelName = "Slack E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid Slack channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Slack", validChannelName, async (page) => {
|
||||
// await page.getByLabel(/Slack Webhook URL/).fill(getEnv("E2E_NOTIFICATION_SLACK_WEBHOOK"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText("Sent to Slack")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Edit and test a valid Slack E2E channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText("Sent to Slack")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Slack channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Slack", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Slack Webhook URL/).fill("https://WRONG_SLACK_WEBHOOK");
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Slack channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const validChannelName = "SMTP E2E Required";
|
||||
const invalidChannelName = "SMTP E2E Invalid";
|
||||
const successMessage = `Email sent: ${getEnv("E2E_NOTIFICATION_SMTP_TO")}`;
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid SMTP channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Email", validChannelName, async (page) => {
|
||||
// await page.getByLabel(/SMTP Host/).fill(getEnv("E2E_NOTIFICATION_SMTP_HOST"));
|
||||
// await page.getByLabel(/SMTP Port/).fill(getEnv("E2E_NOTIFICATION_SMTP_PORT"));
|
||||
// await page.getByLabel(/Username/).fill(getEnv("E2E_NOTIFICATION_SMTP_USER"));
|
||||
// await page.getByLabel(/Password/).fill(getEnv("E2E_NOTIFICATION_SMTP_PASSWORD"));
|
||||
// await page.getByLabel(/From Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_FROM"));
|
||||
// await page.getByLabel(/To Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_TO"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText(successMessage)).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Edit and test a valid SMTP channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await expect(get(page, validChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, validChannelName);
|
||||
// await expect(page.getByText(successMessage)).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid SMTP E2E channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Email", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/SMTP Host/).fill("smtp.invalid");
|
||||
await page.getByLabel(/SMTP Port/).fill(getEnv("E2E_NOTIFICATION_SMTP_PORT"));
|
||||
await page.getByLabel(/Username/).fill(getEnv("E2E_NOTIFICATION_SMTP_USER"));
|
||||
await page.getByLabel(/Password/).fill("wrong-password");
|
||||
await page.getByLabel(/From Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_FROM"));
|
||||
await page.getByLabel(/To Email/).fill(getEnv("E2E_NOTIFICATION_SMTP_TO"));
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid SMTP channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const requiredChannelName = "Telegram E2E Required";
|
||||
const optionalChannelName = "Telegram E2E Optional";
|
||||
const invalidChannelName = "Telegram E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channels", () => {
|
||||
// test("Create and test a valid Telegram channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Telegram", requiredChannelName, async (page) => {
|
||||
// await page.getByLabel(/Telegram Bot Token/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN"));
|
||||
// await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, requiredChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, requiredChannelName);
|
||||
// await expect(page.getByText("Sent to Telegram")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Create and test a valid Telegram channel with Topic ID", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Telegram", optionalChannelName, async (page) => {
|
||||
// await page.getByLabel(/Telegram Bot Token/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN"));
|
||||
// await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
|
||||
// await page.getByLabel(/Telegram Topic ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_TOPIC_ID"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, optionalChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, optionalChannelName);
|
||||
// await expect(page.getByText("Sent to Telegram")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Telegram channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Telegram", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Telegram Bot Token/).fill("wrong-telegram-bot-token");
|
||||
await page.getByLabel(/Telegram Chat ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_CHAT_ID"));
|
||||
await page.getByLabel(/Telegram Topic ID/).fill(getEnv("E2E_NOTIFICATION_TELEGRAM_TOPIC_ID"));
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Telegram channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testFromEdit,
|
||||
} from "../helpers/notification";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const requiredChannelName = "Webhook E2E Required";
|
||||
const optionalChannelName = "Webhook E2E Optional";
|
||||
const invalidChannelName = "Webhook E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channels", () => {
|
||||
// test("Create and test a valid Webhook channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Webhook", requiredChannelName, async (page) => {
|
||||
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, requiredChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, requiredChannelName);
|
||||
// await expect(page.getByText("Sent to Webhook")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test("Create and test a valid Webhook channel with optional header", async ({page}) => {
|
||||
// await page.goto("/dashboard/notifications/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Webhook", optionalChannelName, async (page) => {
|
||||
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
|
||||
// await page.getByRole("button", { name: "Add Header" }).click();
|
||||
// await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
|
||||
// await page.getByLabel(/^Header Value$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, optionalChannelName)).toBeVisible();
|
||||
// await testFromEdit(page, optionalChannelName);
|
||||
// await expect(page.getByText("Sent to Webhook")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Webhook channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Webhook", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Webhook URL/).fill("https://webhook.example.com/api/wrong-webhook");
|
||||
await page.getByRole("button", { name: "Add Header" }).click();
|
||||
await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
|
||||
await page.getByLabel(/^Header Value$/).fill("wrong-webhook-secret");
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await testFromEdit(page, invalidChannelName);
|
||||
await expect(page.getByText("An error occurred while testing the notification channel, check your configuration")).toBeVisible();
|
||||
await cancel(page);
|
||||
});
|
||||
|
||||
test("Delete invalid Webhook E2E channel", async ({page}) => {
|
||||
await page.goto("/dashboard/notifications/channels");
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Notification channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {create, edit, get, remove} from "./helpers/project";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const project = {
|
||||
emptyStateName: "Project A",
|
||||
buttonName: "Project B",
|
||||
updatedButtonName: "Project B Updated",
|
||||
};
|
||||
|
||||
test.describe.serial(() => {
|
||||
test("Create a project from empty state", async ({page}) => {
|
||||
await page.goto("/dashboard/projects");
|
||||
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
|
||||
await expect(page.getByText("Create new Project", {exact: true})).toBeVisible();
|
||||
|
||||
await create(page, "emptyState", project.emptyStateName);
|
||||
|
||||
await expect(page.getByText("Project has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, project.emptyStateName)).toBeVisible();
|
||||
await expect(page.getByText("Create new Project", {exact: true})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Create a project from classic button", async ({page}) => {
|
||||
await page.goto("/dashboard/projects");
|
||||
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
|
||||
await expect(get(page, project.emptyStateName)).toBeVisible();
|
||||
|
||||
await create(page, "button", project.buttonName);
|
||||
|
||||
await expect(page.getByText("Project has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, project.buttonName)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Edit the second created project", async ({page}) => {
|
||||
await page.goto("/dashboard/projects");
|
||||
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
|
||||
await expect(get(page, project.buttonName)).toBeVisible();
|
||||
|
||||
await edit(page, project.buttonName, project.updatedButtonName);
|
||||
|
||||
await expect(page.getByText("Project has been successfully updated.")).toBeVisible();
|
||||
await expect(page.getByText(project.updatedButtonName, {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test("Delete the second created project", async ({page}) => {
|
||||
await page.goto("/dashboard/projects");
|
||||
await expect(page.getByRole("heading", {name: "Projects"})).toBeVisible();
|
||||
await expect(get(page, project.updatedButtonName)).toBeVisible();
|
||||
|
||||
await remove(page, project.updatedButtonName);
|
||||
|
||||
await expect(page).toHaveURL("/dashboard/projects");
|
||||
await expect(page.getByText("Projects has been successfully archived.")).toBeVisible();
|
||||
await expect(page.getByText(project.updatedButtonName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import {test} from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import {LOCAL_STORAGE_PATH} from "./helpers/session";
|
||||
|
||||
test.describe(() => {
|
||||
test.beforeAll(async () => {
|
||||
if (fs.existsSync(LOCAL_STORAGE_PATH)) fs.unlinkSync(LOCAL_STORAGE_PATH);
|
||||
fs.writeFileSync(LOCAL_STORAGE_PATH, JSON.stringify({}));
|
||||
});
|
||||
|
||||
test("Prepare shared storage state", async () => {
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testConnection, testFromEdit,
|
||||
} from "../helpers/storage";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const requiredChannelName = "Google Drive E2E Required";
|
||||
const invalidChannelName = "Google Drive E2E Invalid";
|
||||
|
||||
// test.describe.serial("Valid channel", () => {
|
||||
// test("Create and test a valid Google Drive channel", async ({page}) => {
|
||||
// await page.goto("/dashboard/storages/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
// await create(page, "Google Drive", requiredChannelName, async (page) => {
|
||||
// await page.getByLabel(/Client ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID"));
|
||||
// await page.getByLabel(/Client Secret/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET"));
|
||||
// await page.getByLabel(/Folder ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID"));
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
|
||||
// await testConnection(page);
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, requiredChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, requiredChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test("Create and test invalid Google Drive channel", async ({page}) => {
|
||||
await page.goto("/dashboard/storages/channels");
|
||||
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
await create(page, "Google Drive", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Client ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID"));
|
||||
await page.getByLabel(/Client Secret/).fill("wrong-google-drive-client-secret");
|
||||
await page.getByLabel(/Folder ID/).fill(getEnv("E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID"));
|
||||
});
|
||||
await testConnection(page);
|
||||
await expect(page.getByText("An error occurred while testing the storage channel")).toBeVisible();
|
||||
await submit(page);
|
||||
await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Delete invalid Google Drive E2E channel", async ({page}) => {
|
||||
await page.goto("/dashboard/storages/channels");
|
||||
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
await expect(get(page, invalidChannelName)).toBeVisible();
|
||||
await remove(page, invalidChannelName);
|
||||
await expect(page.getByText("Storage channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {getEnv} from "../helpers/env";
|
||||
import {LOCAL_STORAGE_PATH} from "../helpers/session";
|
||||
import {
|
||||
cancel, create, get, remove, submit, testConnection, testFromEdit,
|
||||
} from "../helpers/storage";
|
||||
|
||||
test.use({storageState: LOCAL_STORAGE_PATH});
|
||||
|
||||
const providers = [
|
||||
{
|
||||
title: "AWS S3",
|
||||
requiredChannelName: "AWS S3 E2E Required",
|
||||
optionalChannelName: "AWS S3 E2E Optional",
|
||||
invalidChannelName: "AWS S3 E2E Invalid",
|
||||
endpointUrl: getEnv("E2E_STORAGE_AWS_S3_ENDPOINT_URL"),
|
||||
region: getEnv("E2E_STORAGE_AWS_S3_REGION"),
|
||||
accessKey: getEnv("E2E_STORAGE_AWS_S3_ACCESS_KEY"),
|
||||
secretKey: getEnv("E2E_STORAGE_AWS_S3_SECRET_KEY"),
|
||||
bucketName: getEnv("E2E_STORAGE_AWS_S3_BUCKET_NAME"),
|
||||
port: getEnv("E2E_STORAGE_AWS_S3_PORT"),
|
||||
invalidAccessKey: "wrong-aws-access-key",
|
||||
invalidSecretKey: "wrong-aws-secret-key",
|
||||
},
|
||||
{
|
||||
title: "Cloudflare R2",
|
||||
requiredChannelName: "Cloudflare R2 E2E Required",
|
||||
optionalChannelName: "Cloudflare R2 E2E Optional",
|
||||
invalidChannelName: "Cloudflare R2 E2E Invalid",
|
||||
endpointUrl: getEnv("E2E_STORAGE_R2_ENDPOINT_URL"),
|
||||
region: getEnv("E2E_STORAGE_R2_REGION"),
|
||||
accessKey: getEnv("E2E_STORAGE_R2_ACCESS_KEY"),
|
||||
secretKey: getEnv("E2E_STORAGE_R2_SECRET_KEY"),
|
||||
bucketName: getEnv("E2E_STORAGE_R2_BUCKET_NAME"),
|
||||
port: getEnv("E2E_STORAGE_R2_PORT"),
|
||||
invalidAccessKey: "wrong-r2-access-key",
|
||||
invalidSecretKey: "wrong-r2-secret-key",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const provider of providers) {
|
||||
test.describe(provider.title, () => {
|
||||
// test.describe.serial("Valid channels", () => {
|
||||
// test(`Create and test a valid ${provider.title} channel`, async ({page}) => {
|
||||
// await page.goto("/dashboard/storages/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
// await create(page, "S3", provider.requiredChannelName, async (page) => {
|
||||
// await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl);
|
||||
// await page.getByLabel(/Access Key/).fill(provider.accessKey);
|
||||
// await page.getByLabel(/Secret Key/).fill(provider.secretKey);
|
||||
// await page.getByLabel(/Bucket name/).fill(provider.bucketName);
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
|
||||
// await testConnection(page);
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, provider.requiredChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, provider.requiredChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
//
|
||||
// test(`Create and test a valid ${provider.title} channel with optional region and port`, async ({page}) => {
|
||||
// await page.goto("/dashboard/storages/channels");
|
||||
// await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
// await create(page, "S3", provider.optionalChannelName, async (page) => {
|
||||
// await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl);
|
||||
// await page.getByLabel(/^Region$/).fill(provider.region);
|
||||
// await page.getByLabel(/Access Key/).fill(provider.accessKey);
|
||||
// await page.getByLabel(/Secret Key/).fill(provider.secretKey);
|
||||
// await page.getByLabel(/Bucket name/).fill(provider.bucketName);
|
||||
// await page.getByLabel(/^Port$/).fill(provider.port);
|
||||
// });
|
||||
// await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
|
||||
// await testConnection(page);
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
|
||||
// await expect(get(page, provider.optionalChannelName)).toBeVisible();
|
||||
//
|
||||
// await testFromEdit(page, provider.optionalChannelName);
|
||||
// await expect(page.getByRole("heading", {name: "Edit Storage Channel"})).toBeVisible();
|
||||
// await expect(page.getByText("Successfully connected to storage channel")).toBeVisible();
|
||||
// await cancel(page);
|
||||
// });
|
||||
// });
|
||||
|
||||
test.describe.serial("Invalid channel", () => {
|
||||
test(`Create and test invalid ${provider.title} channel`, async ({page}) => {
|
||||
await page.goto("/dashboard/storages/channels");
|
||||
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
await create(page, "S3", provider.invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Endpoint URL/).fill(provider.endpointUrl);
|
||||
await page.getByLabel(/^Region$/).fill(provider.region);
|
||||
await page.getByLabel(/Access Key/).fill(provider.invalidAccessKey);
|
||||
await page.getByLabel(/Secret Key/).fill(provider.invalidSecretKey);
|
||||
await page.getByLabel(/Bucket name/).fill(provider.bucketName);
|
||||
await page.getByLabel(/^Port$/).fill(provider.port);
|
||||
});
|
||||
await expect(page.getByRole("heading", {name: "Add Storage Channel"})).toBeVisible();
|
||||
await testConnection(page);
|
||||
await expect(page.getByText("An error occurred while testing the storage channel")).toBeVisible();
|
||||
await submit(page);
|
||||
await expect(page.getByText("Storage channel has been successfully created.")).toBeVisible();
|
||||
await expect(get(page, provider.invalidChannelName)).toBeVisible();
|
||||
});
|
||||
|
||||
test(`Delete invalid ${provider.title} channel`, async ({page}) => {
|
||||
await page.goto("/dashboard/storages/channels");
|
||||
await expect(page.getByRole("heading", {name: "Storage channels"})).toBeVisible();
|
||||
await expect(get(page, provider.invalidChannelName)).toBeVisible();
|
||||
await remove(page, provider.invalidChannelName);
|
||||
await expect(page.getByText("Storage channel has been successfully removed.")).toBeVisible();
|
||||
await expect(page.getByText(provider.invalidChannelName)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,8 @@ spec:
|
||||
value: {{ .Values.project.url }}
|
||||
- name: PROJECT_SECRET
|
||||
value: {{ .Values.project.secret }}
|
||||
- name: TUSD_BEHIND_PROXY
|
||||
value: {{ .Values.project.behindProxy | quote }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
|
||||
@@ -23,6 +23,7 @@ timezone: Europe/Paris
|
||||
project:
|
||||
url: http://localhost:8887
|
||||
secret: "change_me_generate_secure_secret"
|
||||
behindProxy: false
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.17.3",
|
||||
"version": "1.20.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "^8.5.0",
|
||||
"@azure/storage-blob": "^12.32.0",
|
||||
"@better-auth/api-key": "1.6.11",
|
||||
"@better-auth/core": "1.6.11",
|
||||
"@better-auth/passkey": "1.6.11",
|
||||
@@ -109,7 +110,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@react-email/preview-server": "4.3.2",
|
||||
"@react-email/render": "^2.0.8",
|
||||
"@release-it/bumper": "^7.0.5",
|
||||
@@ -139,5 +139,5 @@
|
||||
"typescript": "^5.9.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
baseURL: process.env.PROJECT_URL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects and dependency order */
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: '**/setup.spec.ts',
|
||||
},
|
||||
{
|
||||
name: 'auth',
|
||||
testMatch: '**/auth.spec.ts',
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'access-management',
|
||||
testMatch: '**/access-management.spec.ts',
|
||||
dependencies: ['auth'],
|
||||
},
|
||||
{
|
||||
name: 'notification',
|
||||
testMatch: '**/notification/**/*.spec.ts',
|
||||
dependencies: ['access-management'],
|
||||
},
|
||||
{
|
||||
name: 'storage',
|
||||
testMatch: '**/storage/**/*.spec.ts',
|
||||
dependencies: ['access-management'],
|
||||
},
|
||||
{
|
||||
name: 'agent',
|
||||
testMatch: '**/agent.spec.ts',
|
||||
dependencies: ['access-management'],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'project',
|
||||
testMatch: '**/project.spec.ts',
|
||||
dependencies: ['access-management'],
|
||||
},
|
||||
{
|
||||
name: 'cleanup',
|
||||
testMatch: '**/cleanup.spec.ts',
|
||||
dependencies: ['agent', 'storage', 'notification', 'project'],
|
||||
},
|
||||
]
|
||||
|
||||
});
|
||||
Generated
+1984
-1350
File diff suppressed because it is too large
Load Diff
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# azurite-list.sh — enumerate every container and blob in a local Azurite emulator.
|
||||
#
|
||||
# Usage:
|
||||
# ./azurite-list.sh # list containers + blobs
|
||||
# ./azurite-list.sh -v # also print blob size, last-modified, content-type
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;"
|
||||
|
||||
VERBOSE=0
|
||||
[[ "${1:-}" == "-v" || "${1:-}" == "--verbose" ]] && VERBOSE=1
|
||||
|
||||
command -v az >/dev/null 2>&1 || { echo "ERROR: 'az' CLI not found in PATH." >&2; exit 1; }
|
||||
command -v jq >/dev/null 2>&1 || { echo "ERROR: 'jq' not found in PATH." >&2; exit 1; }
|
||||
|
||||
if ! az storage container list --num-results 1 >/dev/null 2>&1; then
|
||||
echo "ERROR: Cannot reach Azurite at 127.0.0.1:10000. Is the container running?" >&2
|
||||
echo " Start it with: docker start azurite" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
containers=$(az storage container list --query "[].name" -o tsv)
|
||||
|
||||
if [[ -z "$containers" ]]; then
|
||||
echo "(no containers found in this emulator)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r container; do
|
||||
echo "📦 container: $container"
|
||||
|
||||
if [[ "$VERBOSE" -eq 1 ]]; then
|
||||
az storage blob list --container-name "$container" \
|
||||
--query "[].{name:name, bytes:properties.contentLength, modified:properties.lastModified, type:properties.contentSettings.contentType}" \
|
||||
-o json \
|
||||
| jq -r '.[] | " • \(.name) [\(.bytes) bytes] \(.type // "?") \(.modified)"'
|
||||
else
|
||||
az storage blob list --container-name "$container" \
|
||||
--query "[].{name:name, bytes:properties.contentLength}" \
|
||||
-o json \
|
||||
| jq -r '.[] | " • \(.name) [\(.bytes) bytes]"'
|
||||
fi
|
||||
|
||||
count=$(az storage blob list --container-name "$container" --query "length(@)" -o tsv)
|
||||
[[ "$count" -eq 0 ]] && echo " (empty)"
|
||||
echo
|
||||
done <<< "$containers"
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# azurite-seed.sh — seed a local Azurite emulator with a container + sample blob.
|
||||
#
|
||||
# Usage:
|
||||
# ./azurite-seed.sh # create container "portabase" + sample blob
|
||||
# ./azurite-seed.sh my-container # override container name
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;"
|
||||
|
||||
CONTAINER="${1:-portabase}"
|
||||
BLOB_NAME="hello.txt"
|
||||
|
||||
command -v az >/dev/null 2>&1 || { echo "ERROR: 'az' CLI not found in PATH." >&2; exit 1; }
|
||||
|
||||
# Reachability check — surface the real az error instead of guessing "container down".
|
||||
if ! err=$(az storage container list --num-results 1 2>&1 >/dev/null); then
|
||||
echo "ERROR: Azurite query failed:" >&2
|
||||
printf '%s\n' "$err" | sed 's/^/ /' >&2
|
||||
echo " Is Azurite up? Start it with: docker compose up -d azurite" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create container (idempotent — az returns created:false if it already exists).
|
||||
az storage container create --name "$CONTAINER" -o none
|
||||
echo "📦 container ready: $CONTAINER"
|
||||
|
||||
# Upload a sample blob from a temp file.
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
printf 'hello from azurite seed — %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp"
|
||||
|
||||
az storage blob upload \
|
||||
--container-name "$CONTAINER" \
|
||||
--name "$BLOB_NAME" \
|
||||
--file "$tmp" \
|
||||
--overwrite true \
|
||||
--no-progress \
|
||||
-o none
|
||||
echo " • uploaded blob: $BLOB_NAME"
|
||||
|
||||
echo
|
||||
echo "Seed complete. Verify with: make list-blob"
|
||||
+3
-1
@@ -17,6 +17,7 @@ import * as storagePolicy from "@/db/schema/13_storage-policy";
|
||||
import * as backupStorage from "@/db/schema/14_storage-backup";
|
||||
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
|
||||
import * as apiKey from "@/db/schema/16_apikey";
|
||||
import * as jobLog from "@/db/schema/17_job-log";
|
||||
|
||||
const log = logger.child({module: "db"});
|
||||
|
||||
@@ -53,7 +54,8 @@ export const schemas = {
|
||||
...storagePolicy,
|
||||
...backupStorage,
|
||||
...healthcheckLog,
|
||||
...apiKey
|
||||
...apiKey,
|
||||
...jobLog
|
||||
};
|
||||
|
||||
export const db = drizzle({
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "backups" ADD COLUMN "duration_ms" bigint;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "restorations" ADD COLUMN "duration_ms" bigint;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."provider_kind" ADD VALUE 'teams';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."provider_kind" ADD VALUE 'pushover';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."provider_storage_kind" ADD VALUE 'blob';
|
||||
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
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
@@ -407,6 +407,55 @@
|
||||
"when": 1779698258492,
|
||||
"tag": "0057_cooing_nocturne",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 62,
|
||||
"version": "7",
|
||||
"when": 1780934519843,
|
||||
"tag": "0062_futuristic_dragon_lord",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 63,
|
||||
"version": "7",
|
||||
"when": 1781854323074,
|
||||
"tag": "0063_open_proudstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 64,
|
||||
"version": "7",
|
||||
"when": 1781960184847,
|
||||
"tag": "0064_orange_richard_fisk",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {timestamps} from "@/db/schema/00_common";
|
||||
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
|
||||
import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy";
|
||||
import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup";
|
||||
import {JobLog, jobLog} from "@/db/schema/17_job-log";
|
||||
|
||||
export const database = pgTable("databases", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -38,6 +39,7 @@ export const backup = pgTable(
|
||||
status: statusEnum("status").default("waiting").notNull(),
|
||||
file: text("file"),
|
||||
fileSize: bigint("file_size", { mode: "number" }),
|
||||
durationMs: bigint("duration_ms", { mode: "number" }),
|
||||
databaseId: uuid("database_id")
|
||||
.notNull()
|
||||
.references(() => database.id, {onDelete: "cascade"}),
|
||||
@@ -66,7 +68,7 @@ export const retentionPolicy = pgTable("retention_policies", {
|
||||
export const restoration = pgTable("restorations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
status: statusEnum("status").default("waiting").notNull(),
|
||||
|
||||
durationMs: bigint("duration_ms", { mode: "number" }),
|
||||
backupStorageId: uuid("backup_storage_id")
|
||||
.references(() => backupStorage.id, {onDelete: "cascade"}),
|
||||
backupId: uuid("backup_id")
|
||||
@@ -94,12 +96,15 @@ export const backupRelations = relations(backup, ({one, many}) => ({
|
||||
database: one(database, {fields: [backup.databaseId], references: [database.id]}),
|
||||
restorations: many(restoration),
|
||||
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]}),
|
||||
database: one(database, {fields: [restoration.databaseId], references: [database.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 & {
|
||||
restorations?: Restoration[] | null;
|
||||
storages?: BackupStorage[] | null;
|
||||
logs?: JobLog[] | null;
|
||||
};
|
||||
|
||||
export type RestorationWith = Restoration & {
|
||||
logs?: JobLog[] | null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {z} from "zod";
|
||||
import {OrganizationInvitation} from "@/db/schema/05_invitation";
|
||||
|
||||
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud']);
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud', 'teams', 'pushover']);
|
||||
|
||||
export const notificationChannel = pgTable('notification_channel', {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
@@ -5,7 +5,7 @@ import {relations} from "drizzle-orm";
|
||||
import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
|
||||
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive']);
|
||||
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive', 'blob']);
|
||||
|
||||
export const storageChannel = pgTable('storage_channel', {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
@@ -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
@@ -6,6 +6,7 @@ import packageJson from "../package.json" with {type: "json"};
|
||||
const {version} = packageJson;
|
||||
|
||||
export const env = createEnv({
|
||||
emptyStringAsUndefined: true,
|
||||
server: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
|
||||
@@ -30,7 +31,7 @@ export const env = createEnv({
|
||||
SMTP_USER: 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(),
|
||||
|
||||
|
||||
@@ -199,3 +200,4 @@ export const env = createEnv({
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -117,7 +117,9 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
||||
<CardsWithPagination
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={2}
|
||||
data={agent.databases}
|
||||
data={[...agent.databases].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)}
|
||||
cardItem={AgentDatabaseCard}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
|
||||
import {AgentSchema} from "@/features/agents/agents.schema";
|
||||
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 * as drizzleDb from "@/db";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
|
||||
@@ -7,9 +7,12 @@ import {GotifyChannelConfigSchema} from "@/features/channel/notifications/gotify
|
||||
import {NtfyChannelConfigSchema} from "@/features/channel/notifications/ntfy.schema";
|
||||
import {WebhookChannelConfigSchema} from "@/features/channel/notifications/webhook.schema";
|
||||
import {NextcloudChannelConfigSchema} from "@/features/channel/notifications/nextcloud.schema";
|
||||
import {PushoverChannelConfigSchema} from "@/features/channel/notifications/pushover.schema";
|
||||
import {S3ChannelConfigSchema} from "@/features/channel/storages/s3.schema";
|
||||
import {GoogleDriveChannelConfigSchema} from "@/features/channel/storages/google-drive/google-drive.schema";
|
||||
import {LocalChannelConfigSchema} from "@/features/channel/storages/local.schema";
|
||||
import {TeamsChannelConfigSchema} from "@/features/channel/notifications/teams.schema";
|
||||
import {BlobChannelConfigSchema} from "@/features/channel/storages/az-blob.schema";
|
||||
|
||||
|
||||
const BaseChannelFormSchema = z.object({
|
||||
@@ -53,6 +56,14 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
provider: z.literal("nextcloud"),
|
||||
config: NextcloudChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("teams"),
|
||||
config: TeamsChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("pushover"),
|
||||
config: PushoverChannelConfigSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
@@ -64,6 +75,10 @@ export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
provider: z.literal("google-drive"),
|
||||
config: GoogleDriveChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("blob"),
|
||||
config: BlobChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("local"),
|
||||
config: LocalChannelConfigSchema
|
||||
|
||||
@@ -23,9 +23,15 @@ import {
|
||||
import {
|
||||
NotifierNextcloudForm
|
||||
} from "@/features/channel/notifications/nextcloud.form";
|
||||
import {
|
||||
NotifierPushoverForm
|
||||
} from "@/features/channel/notifications/pushover.form";
|
||||
import {
|
||||
notificationProviders,
|
||||
} from "@/features/channel/channels-notification-helper";
|
||||
import {
|
||||
NotifierTeamsForm
|
||||
} from "@/features/channel/notifications/teams.form";
|
||||
import {storageProviders} from "@/features/channel/channels-storage-helper";
|
||||
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
|
||||
import {LucideProps} from "lucide-react";
|
||||
@@ -35,6 +41,9 @@ import {
|
||||
import {
|
||||
StorageGoogleDriveForm
|
||||
} from "@/features/channel/storages/google-drive/google-drive.form";
|
||||
import {
|
||||
StorageBlobForm
|
||||
} from "@/features/channel/storages/az-blob.form";
|
||||
|
||||
export type ChannelKind = "notification" | "storage";
|
||||
|
||||
@@ -92,10 +101,16 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
|
||||
return <NotifierWebhookForm form={form}/>;
|
||||
case "nextcloud":
|
||||
return <NotifierNextcloudForm form={form}/>;
|
||||
case "teams":
|
||||
return <NotifierTeamsForm form={form}/>;
|
||||
case "pushover":
|
||||
return <NotifierPushoverForm form={form}/>;
|
||||
case "s3":
|
||||
return <StorageS3Form form={form}/>
|
||||
case "google-drive":
|
||||
return <StorageGoogleDriveForm form={form}/>
|
||||
case "blob":
|
||||
return <StorageBlobForm form={form}/>
|
||||
case "local":
|
||||
return <></>
|
||||
default:
|
||||
|
||||
@@ -14,7 +14,8 @@ export const notificationProviders: ProviderIconTypes[] = [
|
||||
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
|
||||
{value: "webhook", label: "Webhook", icon: WebhookIcon},
|
||||
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
|
||||
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
|
||||
{value: "teams", label: "Microsoft Teams", icon: MSTeamsIcon},
|
||||
{value: "pushover", label: "Pushover", icon: PushoverIcon},
|
||||
]
|
||||
|
||||
|
||||
@@ -555,4 +556,21 @@ export function NextcloudIcon(props: SVGProps<SVGSVGElement>) {
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PushoverIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
{...props}
|
||||
>
|
||||
<rect width="100" height="100" rx="22" fill="#249DF1"/>
|
||||
<path
|
||||
d="M28 72V28h16.5c5.5 0 9.8 1.3 12.8 3.8 3 2.5 4.5 6 4.5 10.4 0 4.5-1.5 8-4.5 10.5-3 2.5-7.3 3.8-12.8 3.8H38V72H28zm10-23.8h6c2.8 0 4.9-.6 6.3-1.9 1.4-1.3 2.1-3.1 2.1-5.4 0-2.3-.7-4-2.1-5.3-1.4-1.3-3.5-1.9-6.3-1.9H38v14.5z"
|
||||
fill="white"
|
||||
/>
|
||||
<circle cx="72" cy="68" r="8" fill="white" opacity="0.9"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const storageProviders: ProviderIconTypes[] = [
|
||||
{value: "local", label: "Local", icon: Server},
|
||||
{value: "s3", label: "S3", icon: S3Icon},
|
||||
{value: "google-drive", label: "Google Drive", icon: GoogleDriveIcon},
|
||||
{value: "blob", label: "Azure Blob Storage", icon: BlobIcon, preview: true},
|
||||
{value: "blob", label: "Azure Blob Storage", icon: BlobIcon},
|
||||
{value: "gcs", label: "Google Cloud Storage", icon: GCSIcon, preview: true},
|
||||
]
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {sendGotify} from "@/features/channel/notifications/gotify";
|
||||
import {sendNtfy} from "@/features/channel/notifications/ntfy";
|
||||
import {sendWebhook} from "@/features/channel/notifications/webhook";
|
||||
import {sendNextcloud} from "@/features/channel/notifications/nextcloud";
|
||||
import {sendPushover} from "@/features/channel/notifications/pushover";
|
||||
import {sendTeams} from "@/features/channel/notifications/teams"
|
||||
|
||||
const handlers: Record<
|
||||
ProviderKind,
|
||||
@@ -21,6 +23,8 @@ const handlers: Record<
|
||||
ntfy: sendNtfy,
|
||||
webhook: sendWebhook,
|
||||
nextcloud: sendNextcloud,
|
||||
teams: sendTeams,
|
||||
pushover: sendPushover,
|
||||
};
|
||||
|
||||
export async function dispatchViaProvider(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
import {PUSHOVER_PRIORITIES} from "@/features/channel/notifications/pushover.schema";
|
||||
|
||||
type NotifierPushoverFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierPushoverForm = ({form}: NotifierPushoverFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverUserKey"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Key *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="Your 30-character Pushover user key"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Found at the top of your Pushover dashboard at pushover.net.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverApiToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>App API Token *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="Your 30-character application API token"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create an application at pushover.net/apps to get an API token.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverPriority"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Message Priority</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value ?? "0"}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select priority"/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{PUSHOVER_PRIORITIES.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Emergency priority repeats every 60 seconds until acknowledged (max 1 hour).
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverDevice"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Device Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} placeholder="e.g. iphone (leave empty for all devices)"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Target a specific registered device. Leave empty to send to all devices.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const PUSHOVER_PRIORITIES = [
|
||||
{value: "-2", label: "Lowest - no sound, no vibration, no alert"},
|
||||
{value: "-1", label: "Low - quiet notification, no sound"},
|
||||
{value: "0", label: "Normal - default sound and alert"},
|
||||
{value: "1", label: "High - bypass quiet hours"},
|
||||
{value: "2", label: "Emergency - repeat until acknowledged"},
|
||||
] as const;
|
||||
|
||||
export const PushoverChannelConfigSchema = z.object({
|
||||
pushoverUserKey: z.string().min(30, "User key must be 30 characters").max(30, "User key must be 30 characters"),
|
||||
pushoverApiToken: z.string().min(30, "API token must be 30 characters").max(30, "API token must be 30 characters"),
|
||||
pushoverPriority: z.enum(["-2", "-1", "0", "1", "2"]).default("0"),
|
||||
pushoverDevice: z.string().max(25, "Device name must be at most 25 characters").optional().or(z.literal("")),
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
|
||||
|
||||
export async function sendPushover(
|
||||
config: {
|
||||
pushoverUserKey: string;
|
||||
pushoverApiToken: string;
|
||||
pushoverPriority: string;
|
||||
pushoverDevice?: string;
|
||||
},
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {pushoverUserKey, pushoverApiToken, pushoverDevice} = config;
|
||||
const priority = parseInt(config.pushoverPriority ?? "0", 10);
|
||||
|
||||
const rawTitle = `[${payload.level.toUpperCase()}] ${payload.title}`;
|
||||
const rawMessage = payload.data
|
||||
? `${payload.message}\n\nData:\n${JSON.stringify(payload.data)}`
|
||||
: payload.message;
|
||||
|
||||
const body: Record<string, string | number> = {
|
||||
token: pushoverApiToken,
|
||||
user: pushoverUserKey,
|
||||
title: rawTitle.length > 250 ? rawTitle.slice(0, 249) + "…" : rawTitle,
|
||||
message: rawMessage.length > 1024 ? rawMessage.slice(0, 1023) + "…" : rawMessage,
|
||||
priority,
|
||||
};
|
||||
|
||||
if (pushoverDevice) {
|
||||
body.device = pushoverDevice;
|
||||
}
|
||||
|
||||
// Emergency priority requires retry + expire
|
||||
if (priority === 2) {
|
||||
body.retry = 60;
|
||||
body.expire = 3600;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch("https://api.pushover.net/1/messages.json", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const json = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
const errors = json?.errors?.join(", ") ?? res.statusText;
|
||||
throw new Error(`Pushover error ${res.status}: ${errors}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "pushover",
|
||||
message: "Sent via Pushover",
|
||||
response: json,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierTeamsFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierTeamsForm = ({ form }: NotifierTeamsFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.teamsWebhook"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Teams Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder="e.g. https://xxxxx.logic.azure.com:443/xxxxx"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const TeamsChannelConfigSchema = z.object({
|
||||
teamsWebhook: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { EventPayload, DispatchResult } from '@/features/notifications/notifications.types';
|
||||
|
||||
|
||||
|
||||
export async function sendTeams(
|
||||
config: { teamsWebhook: string },
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const { teamsWebhook: webhookUrl } = config;
|
||||
const levelColor: Record<string, string> = {
|
||||
critical: 'attention',
|
||||
warning: 'warning',
|
||||
info: 'good',
|
||||
};
|
||||
const accentColor = levelColor[payload.level] ?? 'default';
|
||||
const bodyItems: object[] = [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: `**[${payload.level.toUpperCase()}] ${payload.title}**`,
|
||||
wrap: true,
|
||||
color: accentColor,
|
||||
size: 'Medium',
|
||||
weight: 'Bolder',
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: payload.message,
|
||||
wrap: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (payload.data) {
|
||||
bodyItems.push(
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: '**Data:**',
|
||||
wrap: true,
|
||||
weight: 'Bolder',
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: JSON.stringify(payload.data, null, 2).substring(0, 1000),
|
||||
wrap: true,
|
||||
fontType: 'Monospace',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const adaptiveCard = {
|
||||
type: 'AdaptiveCard',
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
version: '1.2',
|
||||
body: bodyItems,
|
||||
};
|
||||
|
||||
const body = {
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
contentUrl: null,
|
||||
content: adaptiveCard,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Teams error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'teams',
|
||||
message: 'Sent to Microsoft Teams',
|
||||
response: res.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
type StorageBlobFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const StorageBlobForm = ({ form }: StorageBlobFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accountName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. mystorageaccount" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accountKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Key</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. base64-encoded-key" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.connectionString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connection String</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. DefaultEndpointsProtocol=https;..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.containerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Container Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. backups-prod" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.endpointUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://myaccount.blob.core.windows.net" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const BlobChannelConfigSchema = z.object({
|
||||
accountName: z.string().min(1, "Account name is required"),
|
||||
accountKey: z.string().optional(),
|
||||
connectionString: z.string().optional(),
|
||||
containerName: z.string().min(1, "Container name is required"),
|
||||
endpointUrl: z.preprocess(
|
||||
(v) => (v === "" ? undefined : v),
|
||||
z.string().url("Endpoint URL must be a valid URL").optional(),
|
||||
),
|
||||
}).refine(
|
||||
(data) => data.accountKey || data.connectionString,
|
||||
{message: "Either account key or connection string is required"}
|
||||
);
|
||||
@@ -0,0 +1,185 @@
|
||||
import {BlobSASPermissions, BlobServiceClient, StorageSharedKeyCredential} from "@azure/storage-blob";
|
||||
import {
|
||||
StorageCopyInput,
|
||||
StorageDeleteInput,
|
||||
StorageGetInput,
|
||||
StorageMetaData,
|
||||
StorageResult,
|
||||
StorageUploadInput
|
||||
} from '@/features/storages/storages.types';
|
||||
import {Readable} from "node:stream";
|
||||
|
||||
type BlobConfig = {
|
||||
accountName: string;
|
||||
accountKey?: string;
|
||||
connectionString?: string;
|
||||
containerName: string;
|
||||
endpointUrl?: string;
|
||||
};
|
||||
|
||||
async function getBlobClient(config: BlobConfig) {
|
||||
if (config.connectionString) {
|
||||
return BlobServiceClient.fromConnectionString(config.connectionString);
|
||||
}
|
||||
|
||||
const url = config.endpointUrl ?? `https://${config.accountName}.blob.core.windows.net`;
|
||||
const credential = new StorageSharedKeyCredential(config.accountName, config.accountKey!);
|
||||
return new BlobServiceClient(url, credential);
|
||||
}
|
||||
|
||||
const BASE_DIR = "";
|
||||
|
||||
|
||||
async function ensureContainer(config: BlobConfig) {
|
||||
const client = await getBlobClient(config);
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
await containerClient.createIfNotExists();
|
||||
}
|
||||
|
||||
export async function uploadBlob(
|
||||
config: BlobConfig,
|
||||
input: { data: StorageUploadInput, metadata?: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const client = await getBlobClient(config);
|
||||
await ensureContainer(config);
|
||||
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
const file = input.data.file;
|
||||
|
||||
let uploadStream: Readable;
|
||||
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
|
||||
uploadStream = Readable.from(file);
|
||||
} else if ((file as any).pipe) {
|
||||
uploadStream = file;
|
||||
} else {
|
||||
return {success: false, provider: "blob", error: "Unsupported file type for streaming upload"};
|
||||
}
|
||||
|
||||
try {
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
const blockBlobClient = containerClient.getBlockBlobClient(key);
|
||||
await blockBlobClient.uploadStream(uploadStream);
|
||||
} catch (err: any) {
|
||||
return {success: false, provider: "blob", error: err.message};
|
||||
}
|
||||
|
||||
return {success: true, provider: "blob"};
|
||||
}
|
||||
|
||||
|
||||
export async function getBlob(
|
||||
config: BlobConfig,
|
||||
input: { data: StorageGetInput, metadata: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const client = await getBlobClient(config);
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
const blockBlobClient = containerClient.getBlockBlobClient(key);
|
||||
|
||||
if (!(await blockBlobClient.exists())) {
|
||||
return {success: false, provider: "blob", error: "File not found"};
|
||||
}
|
||||
|
||||
const downloadResponse = await blockBlobClient.download();
|
||||
const fileStream = downloadResponse.readableStreamBody as unknown as Readable;
|
||||
|
||||
let presignedUrl: string | undefined;
|
||||
if (input.data.signedUrl) {
|
||||
presignedUrl = await blockBlobClient.generateSasUrl({
|
||||
expiresOn: new Date(Date.now() + (input.data.expiresInSeconds ?? 60) * 1000),
|
||||
permissions: BlobSASPermissions.parse("r"),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "blob",
|
||||
file: fileStream,
|
||||
url: presignedUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export async function deleteBlob(config: BlobConfig, input: {
|
||||
data: StorageDeleteInput,
|
||||
metadata?: StorageMetaData
|
||||
}): Promise<StorageResult> {
|
||||
const client = await getBlobClient(config);
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
|
||||
try {
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
const blockBlobClient = containerClient.getBlockBlobClient(key);
|
||||
await blockBlobClient.delete();
|
||||
return {success: true, provider: "blob"};
|
||||
} catch (err: any) {
|
||||
return {success: false, provider: "blob", error: err.message};
|
||||
}
|
||||
}
|
||||
|
||||
export async function pingBlob(config: BlobConfig): Promise<StorageResult> {
|
||||
try {
|
||||
const client = await getBlobClient(config);
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
const exists = await containerClient.exists();
|
||||
if (!exists) return {
|
||||
success: false,
|
||||
provider: "blob",
|
||||
response: "Container does not exist"
|
||||
};
|
||||
const key = `${BASE_DIR}ping-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`;
|
||||
const blockBlobClient = containerClient.getBlockBlobClient(key);
|
||||
try {
|
||||
await blockBlobClient.upload(Buffer.from("ping"), 4);
|
||||
await blockBlobClient.download();
|
||||
} finally {
|
||||
await blockBlobClient.delete().catch(() => undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "blob",
|
||||
response: "Blob storage OK"
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "blob",
|
||||
response: err.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function copyBlob(
|
||||
config: BlobConfig,
|
||||
input: {
|
||||
data: StorageCopyInput,
|
||||
},
|
||||
): Promise<StorageResult> {
|
||||
const client = await getBlobClient(config);
|
||||
await ensureContainer(config);
|
||||
|
||||
const sourceKey = `${BASE_DIR}${input.data.from}`;
|
||||
const destinationKey = `${BASE_DIR}${input.data.to}`;
|
||||
|
||||
try {
|
||||
const containerClient = client.getContainerClient(config.containerName);
|
||||
const sourceBlob = containerClient.getBlockBlobClient(sourceKey);
|
||||
const destBlob = containerClient.getBlockBlobClient(destinationKey);
|
||||
const poller = await destBlob.beginCopyFromURL(sourceBlob.url);
|
||||
await poller.pollUntilDone();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "blob",
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "blob",
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
|
||||
import {uploadLocal, getLocal, deleteLocal, pingLocal, copyLocal} from './local';
|
||||
import {copyS3, deleteS3, getS3, pingS3, uploadS3} from "@/features/channel/storages/s3";
|
||||
import {copyBlob, deleteBlob, getBlob, pingBlob, uploadBlob} from "@/features/channel/storages/az-blob";
|
||||
import {
|
||||
copyGoogleDrive,
|
||||
deleteGoogleDrive,
|
||||
@@ -43,6 +44,13 @@ const handlers: Record<StorageProviderKind, ProviderHandler> = {
|
||||
delete: deleteGoogleDrive,
|
||||
ping: pingGoogleDrive,
|
||||
copy: copyGoogleDrive,
|
||||
},
|
||||
blob: {
|
||||
upload: uploadBlob,
|
||||
get: getBlob,
|
||||
delete: deleteBlob,
|
||||
ping: pingBlob,
|
||||
copy: copyBlob,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
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 {cn} from "@/lib/utils";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||
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 { Badge as BadgeC } from "@/components/ui/badge";
|
||||
import {backupOnly} from "@/features/database/database-tabs";
|
||||
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
|
||||
|
||||
export function backupColumns(
|
||||
isAlreadyRestore: boolean,
|
||||
settings: Setting,
|
||||
database: DatabaseWith,
|
||||
activeMember: MemberWithUser
|
||||
): ColumnDef<Backup>[] {
|
||||
): ColumnDef<BackupWith>[] {
|
||||
|
||||
const isBackupOnly = backupOnly.some((type) => database.dbms === type)
|
||||
|
||||
@@ -85,6 +86,14 @@ export function backupColumns(
|
||||
return formatBytes(row.getValue("fileSize"))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "durationMs",
|
||||
header: "Duration",
|
||||
cell: ({row}) => {
|
||||
const durationMs = row.getValue("durationMs");
|
||||
return durationMs ? formatDuration(row.getValue("durationMs")) : "-"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
@@ -99,6 +108,14 @@ export function backupColumns(
|
||||
return <StatusBadge status={row.getValue("status")}/>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "logs",
|
||||
header: "Logs",
|
||||
cell: ({row}) => {
|
||||
const logs = row.original.logs ?? [];
|
||||
return <LogsModalTrigger logs={logs}/>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
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 {eq} from "drizzle-orm";
|
||||
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 {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||
@@ -36,15 +36,19 @@ export const getDatabaseDataAction = userAction
|
||||
with: {
|
||||
storageChannel: true
|
||||
}
|
||||
}
|
||||
},
|
||||
logs: true
|
||||
},
|
||||
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
||||
}) as BackupWith[];
|
||||
|
||||
const restorations = await db.query.restoration.findMany({
|
||||
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
|
||||
with: {
|
||||
logs: true
|
||||
},
|
||||
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
||||
}) as Restoration[];
|
||||
}) as RestorationWith[];
|
||||
|
||||
const totalBackups = backups.length;
|
||||
const availableBackups = backups.filter(b => !b.deletedAt).length;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {DatabaseBackupActionsModal} from "@/features/database/backup-actions-modal";
|
||||
import {DatabaseTabs} from "@/features/database/database-tabs";
|
||||
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 {useBackupModal} from "@/features/database/backup-modal-context";
|
||||
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 {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {LogsModal} from "@/features/logs/logs-modal";
|
||||
|
||||
export type DatabaseContentProps = {
|
||||
settings: Setting;
|
||||
backups: BackupWith[];
|
||||
restorations: Restoration[];
|
||||
restorations: RestorationWith[];
|
||||
isAlreadyRestore: boolean;
|
||||
database: DatabaseWith;
|
||||
activeMember: MemberWithUser;
|
||||
@@ -156,6 +157,7 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
||||
availableBackups={stats.availableBackups}
|
||||
totalBackups={stats.totalBackups}
|
||||
/>
|
||||
<LogsModal/>
|
||||
<DatabaseBackupActionsModal/>
|
||||
<DatabaseTabs
|
||||
activeMember={props.activeMember}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Check, MoreHorizontal, Trash2, X } from "lucide-react";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -24,11 +24,13 @@ import { toast } from "sonner";
|
||||
import { TooltipCustom } from "@/components/common/tooltip-custom";
|
||||
import { MemberWithUser } from "@/db/schema/03_organization";
|
||||
import { ButtonWithConfirm } from "@/components/common/button-with-confirm";
|
||||
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
|
||||
import {formatDuration} from "@/utils/text";
|
||||
|
||||
export function restoreColumns(
|
||||
isAlreadyRestore: boolean,
|
||||
activeMember: MemberWithUser,
|
||||
): ColumnDef<Restoration>[] {
|
||||
): ColumnDef<RestorationWith>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "id",
|
||||
@@ -48,6 +50,22 @@ export function restoreColumns(
|
||||
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",
|
||||
cell: ({ row }) => {
|
||||
|
||||
@@ -21,6 +21,9 @@ export const deleteRestoreAction = userAction
|
||||
.where(and(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId)))
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.delete(drizzleDb.schemas.jobLog)
|
||||
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -50,14 +53,16 @@ export const rerunRestorationAction = userAction
|
||||
)
|
||||
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
|
||||
try {
|
||||
const updateResult = await db
|
||||
const [updatedRestoration] = await db
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "waiting"})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId))
|
||||
.returning()
|
||||
.execute();
|
||||
|
||||
const updatedRestoration = updateResult[0];
|
||||
await db
|
||||
.delete(drizzleDb.schemas.jobLog)
|
||||
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
|
||||
|
||||
if (!updatedRestoration) {
|
||||
return {
|
||||
|
||||
@@ -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">
|
||||
<div className="flex items-center gap-2">
|
||||
<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} />}
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user