mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b65d5648f | ||
|
|
eb4a31e5f4 | ||
|
|
0d2f2957b3 | ||
|
|
51bf9cf679 | ||
|
|
87f8a50326 | ||
|
|
b70a8c8a16 | ||
|
|
ebdef2d302 | ||
|
|
a6bb50c5e2 | ||
|
|
50eb48fcf9 | ||
|
|
6622565b14 | ||
|
|
f001de1e97 | ||
|
|
6711fab413 | ||
|
|
e2f28a84bc | ||
|
|
d1941c2f3d | ||
|
|
f35e6abb05 | ||
|
|
0598f78a34 | ||
|
|
f30b55ba47 | ||
|
|
751f29e340 | ||
|
|
2e629f8423 | ||
|
|
ae431f5b85 | ||
|
|
f5f697ceb0 | ||
|
|
02a8f0baf9 | ||
|
|
2932149b92 | ||
|
|
e1e4d67114 | ||
|
|
1e3f112d9c | ||
|
|
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
|
||||
|
||||
+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 }}
|
||||
|
||||
@@ -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.4
|
||||
version: 1.18.4
|
||||
date-released: '2026-03-02'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -127,12 +131,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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.17.4",
|
||||
"version": "1.18.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -139,5 +139,5 @@
|
||||
"typescript": "^5.9.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
"packageManager": "pnpm@11.5.2+sha512.71c631e382066efc25625d5cf029075de07b61b37f6e27350fbd84b1bda5864c8c1967adc280776b45c30a715c0359a3be08fef42d5bb09e2b99029979692916"
|
||||
}
|
||||
|
||||
Generated
+1794
-1343
File diff suppressed because it is too large
Load Diff
+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;
|
||||
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,34 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type LevelType = "info" | "debug" | "error" | "warn";
|
||||
export type EntryType = "log" | "command";
|
||||
|
||||
export interface JobLogEntry {
|
||||
timestamp: string
|
||||
type: EntryType
|
||||
level: LevelType
|
||||
message: string
|
||||
command?: string
|
||||
output?: string
|
||||
exit_code?: number
|
||||
duration_ms?: number
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
||||
|
||||
<Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20">
|
||||
<AvatarImage className="object-cover" src={user.image || undefined}/>
|
||||
<AvatarFallback className="text-3xl">{user.name.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
<AvatarFallback className="text-3xl">{(user.name?.charAt(0) ?? user.email?.charAt(0) ?? "?").toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div
|
||||
|
||||
@@ -2,7 +2,7 @@ import z from "zod";
|
||||
import {zString} from "@/lib/zod";
|
||||
|
||||
export const ProfileSchema = z.object({
|
||||
name: zString().nonempty(),
|
||||
name: zString().trim().nonempty(),
|
||||
role: zString().nonempty(),
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {ApiKey} from "@better-auth/api-key";
|
||||
|
||||
const UpdateProfileSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
name: z.string().trim().nonempty().optional(),
|
||||
});
|
||||
|
||||
export const updateProfileSettingsAction = userAction.schema(UpdateProfileSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {formatDistanceToNow} from "date-fns";
|
||||
import {format} from "date-fns";
|
||||
|
||||
/**
|
||||
* Get user's locale and timezone from the browser
|
||||
|
||||
@@ -25,4 +25,45 @@ export function formatBytes(bytes: number | null, decimals = 2): string {
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms == null || Number.isNaN(ms)) return "0 ms";
|
||||
|
||||
const totalMs = Math.max(0, Math.floor(ms));
|
||||
|
||||
if (totalMs < 1000) {
|
||||
return `${totalMs} ms`;
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(totalMs / 1000);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds} s`;
|
||||
}
|
||||
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (totalMinutes < 60) {
|
||||
return seconds > 0
|
||||
? `${totalMinutes} min ${seconds} s`
|
||||
: `${totalMinutes} min`;
|
||||
}
|
||||
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const hours = totalHours % 24;
|
||||
|
||||
if (totalHours < 24) {
|
||||
return minutes > 0
|
||||
? `${totalHours} h ${minutes} min`
|
||||
: `${totalHours} h`;
|
||||
}
|
||||
|
||||
const days = Math.floor(totalHours / 24);
|
||||
|
||||
return hours > 0
|
||||
? `${days} d ${hours} h`
|
||||
: `${days} d`;
|
||||
}
|
||||
+2
-1
@@ -33,7 +33,8 @@
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
".next/dev/types/**/*.ts",
|
||||
".next/dev/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
Reference in New Issue
Block a user