Compare commits

..
38 Commits
Author SHA1 Message Date
github-actions[bot] 180ea21041 chore: release 1.9.3 2026-03-29 09:23:24 +00:00
Charles GTEandGitHub e1c0fc6917 fix: refactoring logs and adding pino logger for production and dev. (#236) 2026-03-29 11:22:28 +02:00
github-actions[bot] 057c192314 chore: release 1.9.2 2026-03-29 07:25:20 +00:00
Charles GTEandGitHub aff782aa28 fix: mariadb and mysql separation (#234) 2026-03-29 09:24:31 +02:00
github-actions[bot] 2e729cb79c chore: release 1.9.1 2026-03-28 18:01:28 +00:00
Charles GTEandGitHub d8677d74a7 fix: default notification (#233) 2026-03-28 19:00:50 +01:00
github-actions[bot] 2f77714994 chore: release 1.9.0 2026-03-28 15:37:58 +00:00
Charles GTEandGitHub bc8050c4df feat: add-healthcheck (#231)
* fix: refactoring

* fix: refactoring

* fix: refactoring
2026-03-28 16:37:03 +01:00
Charles GTEandGitHub a9c69b112d Merge pull request #232 from Portabase/revert-230-revert-229-feat/add-healthcheck
Revert "Revert "feat: add-healthcheck""
2026-03-28 16:35:18 +01:00
Charles GTEandGitHub 04785c323b Revert "Revert "feat: add-healthcheck"" 2026-03-28 16:35:02 +01:00
Charles GTEandGitHub 1d209ceb28 Merge pull request #230 from Portabase/revert-229-feat/add-healthcheck
Revert "feat: add-healthcheck"
2026-03-28 16:28:25 +01:00
Charles GTEandGitHub 941a9256f0 Revert "feat: add-healthcheck" 2026-03-28 16:27:57 +01:00
Charles GTEandGitHub 9098012426 feat: add-healthcheck (#229 )
feat: add-healthcheck
2026-03-28 16:23:11 +01:00
Charles GTE 8e12ff59a4 fix: refactoring 2026-03-28 16:22:04 +01:00
Charles GTE 0f8a267861 chore: update security.yml 2026-03-28 16:17:24 +01:00
Charles GTE da53a00fd8 chore: update security.yml 2026-03-28 16:13:36 +01:00
Charles GTE 47e3a93e42 fix: healthcheck system 2026-03-28 16:06:55 +01:00
Charles GTE 0262a1c176 fix: database health cron 2026-03-28 15:10:46 +01:00
Charles GTE 7aecd911c0 feat: database health logs 2026-03-27 23:06:19 +01:00
Charles GTE 595ccadfb3 feat: added heath_error_count and the cron for health check agent errors 2026-03-27 22:13:32 +01:00
charles-gauthereau bded273f94 fix: set default notification channel 2026-03-27 19:22:44 +01:00
charles-gauthereau c095d393cf fix: set default notification channel 2026-03-26 22:45:42 +01:00
charles-gauthereau c3ae82fde7 feat: set default notification channel 2026-03-26 20:17:23 +01:00
charles-gauthereau a8e11ebef7 feat: set default notification channel 2026-03-25 22:19:41 +01:00
charles-gauthereau 61b1a2e724 feat: set default notification channel 2026-03-25 22:07:20 +01:00
charles-gauthereau 7a4af4e1e0 feat: added cron job to remove healthcheck logs older than 12h 2026-03-25 21:11:09 +01:00
charles-gauthereau a9d6481193 fix: health chart labels and get logs for 12h instead of 24h 2026-03-25 20:45:20 +01:00
charles-gauthereau fb97963cc3 feat: front form for default notification settings page choice 2026-03-25 09:06:53 +01:00
charles-gauthereau 0cc0ba989c feat: add agent healthcheck 2026-03-24 20:06:43 +01:00
github-actions[bot] 26eff02943 chore: release 1.8.0 2026-03-18 18:48:30 +00:00
67246efafc feat: adding basic end-to-end testing and ci workflow (#225)
* feat: add end-to-end testing

* chore: fix bug in github action.

---------

Co-authored-by: charlesgauthereau <charles.gauthereau@soluce-technologies.com>
Co-authored-by: killianlarcher <killian.larcher@soluce-technologies.com>
2026-03-18 19:47:41 +01:00
github-actions[bot] 260b3ba1f9 chore: release 1.7.1 2026-03-17 14:03:35 +00:00
Charles GTEandGitHub 90f8885e73 fix: removed version number on auth layout (#223)
fix: removed version number on auth layout
2026-03-17 15:02:55 +01:00
charles-gauthereau 0bacd6a65c fix: removed version number on auth layout 2026-03-17 15:01:34 +01:00
github-actions[bot] 2846deb4d0 chore: release 1.7.0 2026-03-16 13:39:18 +00:00
ae6dbcab25 feat: add-valkey engine (#222)
* feat: add-valkey engine

* fix: README.md with Valkey content

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-03-16 14:38:43 +01:00
charles-gauthereau d823ec2e82 fix: README.md with Valkey content 2026-03-16 14:26:25 +01:00
charles-gauthereau b0ce5273ce feat: add-valkey engine 2026-03-16 12:16:50 +01:00
90 changed files with 26296 additions and 2125 deletions
-1
View File
@@ -6,7 +6,6 @@ DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
# Project
PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://localhost:8887
PROJECT_SECRET=
+77
View File
@@ -0,0 +1,77 @@
name: End-to-end testing
on:
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/dockerfile/Dockerfile
push: false
load: true
tags: portabase/portabase:test
- 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 firefox webkit
- name: Run Playwright tests
run: pnpm exec playwright test --project chromium
env:
PROJECT_URL: http://localhost:8887
- name: Upload report if failed
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Cleanup
if: always()
run: docker stop myapp && docker rm myapp || true
+1 -2
View File
@@ -9,14 +9,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@0.20.0
- uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: 'fs'
format: 'table'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
secrets-gitleaks:
if: github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
+7
View File
@@ -47,3 +47,10 @@ private/keys/*
seeds/pocket-id/*.db
certificates
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
+1 -1
View File
@@ -31,5 +31,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.6.1
version: 1.9.3
date-released: '2026-03-02'
+11 -10
View File
@@ -21,6 +21,7 @@
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![SQLite](https://img.shields.io/badge/-SQLite-blue?logo=sqlite&logoColor=white)](https://sqlite.org/)
[![Redis](https://img.shields.io/badge/Redis-DC382D?style=flat&logo=Redis&logoColor=white)](https://redis.io/)
[![Valkey](https://img.shields.io/badge/Valkey-6284fc?style=flat&logo=Valkey&logoColor=white)](https://valkey.io/)
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
@@ -61,16 +62,16 @@ You have 4 ways to install Portabase:
## Supported databases
| Engine | Support | Supported Versions | Restore |
|:-------------------|:------------|:------------------------------|:--------|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 et 18 | Yes |
| **MySQL** | ✅ Stable | 5.7, 8 et 9 | Yes |
| **MariaDB** | ✅ Stable | 10 et 11 | Yes |
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 et 8 | Yes |
| **SQLite** | ✅ Stable | 3.x | Yes |
| **Redis** | ✅ Stable | 2.8+ | No |
| **Valkey** | ❌ Ongoing | - | No |
| **MSSQL Server** | ❌ Backlog | - | Yes |
| Engine | Support | Supported Versions | Restore |
|:-------------------|:----------|:----------------------------------|:--------|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 et 18 | Yes |
| **MySQL** | ✅ Stable | 5.7, 8 et 9 | Yes |
| **MariaDB** | ✅ Stable | 10 et 11 | Yes |
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 et 8 | Yes |
| **SQLite** | ✅ Stable | 3.x | Yes |
| **Redis** | ✅ Stable | 2.8+ | No |
| **Valkey** | ✅ Stable | 7.2+ | No |
| **MSSQL Server** | ❌ Ongoing | - | Yes |
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
+1 -4
View File
@@ -1,10 +1,8 @@
import React, { Suspense } from "react";
import React from "react";
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/auth/current-user";
import { AuthLogoSection } from "@/components/wrappers/auth/auth-logo-section";
import { env } from "@/env.mjs";
import { Heart } from "lucide-react";
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
export default async function Layout({
children,
@@ -31,7 +29,6 @@ export default async function Layout({
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
by <span className="font-medium text-foreground">Portabase</span>
</p>
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
</footer>
</div>
);
-1
View File
@@ -8,7 +8,6 @@ import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { CardAuth } from "@/features/layout/card-auth";
import { env } from "@/env.mjs";
import { en } from "zod/v4/locales";
export const metadata: Metadata = {
title: "Login",
@@ -6,6 +6,7 @@ import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/setti
import {desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
export default async function RoutePage(props: PageParams<{}>) {
@@ -13,6 +14,8 @@ export default async function RoutePage(props: PageParams<{}>) {
where: (fields, {eq}) => eq(fields.name, "system"),
});
console.log(settings)
const storageChannels = await db.query.storageChannel.findMany({
with: {
organizations: true
@@ -21,7 +24,16 @@ export default async function RoutePage(props: PageParams<{}>) {
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
}) as StorageChannelWith[]
if (!settings || !storageChannels ) {
const notificationChannels = await db.query.notificationChannel.findMany({
with: {
organizations: true
},
where: isNull(drizzleDb.schemas.notificationChannel.organizationId),
orderBy: desc(drizzleDb.schemas.notificationChannel.createdAt)
}) as NotificationChannelWith[]
if (!settings || !storageChannels || !notificationChannels ) {
notFound()
}
@@ -33,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsTabs storageChannels={storageChannels} settings={settings} />
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels} settings={settings} />
</PageContent>
</Page>
);
@@ -8,6 +8,7 @@ import {getOrganizationProjectDatabases} from "@/lib/services";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
export default async function RoutePage(props: PageParams<{
projectId: string;
@@ -83,6 +84,8 @@ export default async function RoutePage(props: PageParams<{
notFound();
}
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({ id: dbItem.id }) : []
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
@@ -95,6 +98,7 @@ export default async function RoutePage(props: PageParams<{
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
+14 -12
View File
@@ -1,7 +1,10 @@
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import {NextResponse} from "next/server";
import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/backup/helpers"});
export function withAgentCheck(handler: Function) {
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
@@ -14,23 +17,22 @@ export function withAgentCheck(handler: Function) {
if (!agent) {
return NextResponse.json(
{ error: "Agent not found" },
{ status: 404 }
{error: "Agent not found"},
{status: 404}
);
}
return handler(request, { ...context, agent });
return handler(request, {...context, agent});
} catch (err) {
console.error("Error in agent middleware:", err);
log.error({error: err, name: "withAgentCheck"}, "Error in agent middleware");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
{error: "Internal server error"},
{status: 500}
);
}
};
}
export async function getDatabaseOrThrow(generatedId: string) {
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
@@ -43,8 +45,8 @@ export async function getDatabaseOrThrow(generatedId: string) {
if (!database) {
throw NextResponse.json(
{ error: "Database associated with generatedId not found" },
{ status: 404 }
{error: "Database associated with generatedId not found"},
{status: 404}
);
}
+6 -6
View File
@@ -8,6 +8,9 @@ import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/features/shared/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {EventKind} from "@/features/notifications/types";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/backup/route"});
export type BodyPost = {
method: "manual" | "automatic"
@@ -77,7 +80,6 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
}
}
eventEmitter.emit('modification', {update: true});
return NextResponse.json(
@@ -88,7 +90,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
{status: 200}
);
} catch (error) {
console.error("Error in POST for INIT backup:", error);
log.error({error: error}, "Error in POST for INIT backup");
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
@@ -102,7 +104,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
}) => {
try {
const body: BodyPatch = await request.json();
console.log("body", body);
log.debug({data: body}, "Body from PATH in backup route");
const status = body.status
const backupId = body.backupId
@@ -142,9 +144,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
{status: 200}
);
} catch (error) {
console.error("Error in PATCH backup:", error);
log.error({error: error}, "Error in PATCH backup")
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
@@ -5,6 +5,9 @@ import {db} from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {isUuidv4} from "@/utils/verify-uuid";
import {eventEmitter} from "@/features/shared/event";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/backup/upload/init"});
export type Body = {
generatedId: string
@@ -18,7 +21,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
try {
const body: Body = await request.json();
console.log("body", body);
log.debug({data: body}, "Body for backup upload init");
const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId;
@@ -66,7 +69,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
{status: 200}
);
} catch (error) {
console.error("Error in POST for INIT backup:", error);
log.error({error: error}, "Error in POST for INIT backup");
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
@@ -5,6 +5,9 @@ import {db as dbClient, db} from "@/db";
import {withUpdatedAt} from "@/db/utils";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
import {eventEmitter} from "@/features/shared/event";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/backup/upload/status"});
export type Body = {
generatedId: string
@@ -27,8 +30,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
const backupStorageId = body.backupStorageId;
const backupId = body.backupId;
console.log("body", body);
log.debug({data: body}, "Body for backup upload status");
const database = await getDatabaseOrThrow(generatedId);
@@ -85,7 +87,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
{status: 200}
);
} catch (error) {
console.error("Error in POST for INIT backup:", error);
log.error({error: error},"Error in POST for INIT backup");
return NextResponse.json(
{error: "Internal server error"},
{status: 500}
+4 -3
View File
@@ -4,7 +4,9 @@ import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {eventEmitter} from "@/features/shared/event";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/restore"});
export type BodyResultRestore = {
generatedId: string
@@ -70,10 +72,9 @@ export async function POST(
message: "Restoration successfully updated"
}
return Response.json(response, {status: 200})
} catch (error) {
console.error('Error in POST handler:', error);
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
+34 -7
View File
@@ -2,7 +2,7 @@ import {NextResponse} from "next/server";
import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
import {Agent} from "@/db/schema/08_agent";
import {Database, DatabaseWith} from "@/db/schema/07_database";
import {DatabaseWith} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
import {db, db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
@@ -11,6 +11,9 @@ import {withUpdatedAt} from "@/db/utils";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Setting} from "@/db/schema/01_setting";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/status/helpers"});
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
const databasesResponse = [];
@@ -56,7 +59,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
}
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
console.log(`Database type not available: ${db.dbms}`);
log.error({name: "handleDatabases"},`Database type not available: ${db.dbms}`);
continue;
}
@@ -67,12 +70,24 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
name: db.name,
dbms: db.dbms as EDbmsSchema,
agentDatabaseId: db.generatedId,
lastContact: lastContact,
lastContact: db.pingStatus ? lastContact : null,
healthErrorCount: null
})
.returning();
if (databaseCreated) {
await dbClient
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "database",
status: db.pingStatus ? "success" : "failed",
objectId: databaseCreated.id,
date: lastContact
})
const storages = await getDatabaseStorageChannels(databaseCreated.id)
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
@@ -84,11 +99,24 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.set(withUpdatedAt({
name: db.name,
agentId: agent.id,
lastContact: lastContact
dbms: db.dbms as EDbmsSchema,
lastContact: db.pingStatus ? lastContact : existingDatabase.lastContact,
healthErrorCount: db.pingStatus ? null : existingDatabase.healthErrorCount,
}))
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning();
await dbClient
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "database",
status: db.pingStatus ? "success" : "failed",
objectId: databaseUpdated.id,
date: lastContact
})
const activeBackup = await dbClient.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
@@ -159,11 +187,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
const errorMessage = "Failed to get backup URL";
console.error("Restoration failed: ", errorMessage);
log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed");
continue;
}
} catch (err) {
console.error("Restoration crashed unexpectedly:", err);
log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "failed"})
@@ -180,7 +208,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
}
}
return databasesResponse;
}
+19 -8
View File
@@ -6,13 +6,17 @@ import {EDbmsSchema} from "@/db/schema/types";
import {eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils";
import {eventEmitter} from "@/features/shared/event";
import {notFound} from "next/navigation";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/agent/status/route"});
export type databaseAgent = {
name: string,
dbms: EDbmsSchema,
generatedId: string
pingStatus: boolean
}
export type Body = {
@@ -27,14 +31,14 @@ export async function POST(
) {
try {
const agentId = (await params).agentId
console.log(agentId)
log.debug(`Agent ID: ${agentId}`)
const body: Body = await request.json();
const lastContact = new Date();
let message: string
if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid"
console.error(message)
log.error({error: message}, "An error occurred")
return NextResponse.json(
{error: "agentId is not a valid uuid"},
{status: 500}
@@ -55,17 +59,25 @@ export async function POST(
return NextResponse.json({error: "An error occured"}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
await db
.update(drizzleDb.schemas.agent)
.set(withUpdatedAt({
version: body.version,
lastContact: lastContact
lastContact: lastContact,
healthErrorCount: null
}))
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact
})
const response = {
agent: {
@@ -75,10 +87,9 @@ export async function POST(
databases: databasesResponse
}
return Response.json(response)
} catch (error) {
console.error('Error in POST handler:', error);
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
+6 -3
View File
@@ -2,6 +2,9 @@ import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {NextResponse} from "next/server";
import {eventEmitter} from "@/features/shared/event";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/events"});
export async function GET(request: Request) {
@@ -16,9 +19,9 @@ export async function GET(request: Request) {
return new Response(
new ReadableStream({
start(controller) {
console.log('Stream started');
log.info("Stream started");
const handleModification = (data: any) => {
console.log('Modification event triggered:', data);
log.info({data: data},"Modification event triggered");
controller.enqueue(`event: modification\n`);
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
};
@@ -26,7 +29,7 @@ export async function GET(request: Request) {
eventEmitter.on('modification', handleModification);
request.signal.addEventListener('abort', () => {
console.log('Client disconnected');
log.info("Client disconnected");
controller.close();
eventEmitter.off('modification', handleModification);
});
+4 -1
View File
@@ -3,6 +3,9 @@ import path from "path";
import type {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/files/backups"});
export async function GET(
request: Request,
@@ -30,7 +33,7 @@ export async function GET(
}
};
console.debug(input);
log.debug({input: input}, "Dispatch Storage");
const result = await dispatchStorage(input, undefined, storageId);
+5 -5
View File
@@ -1,12 +1,12 @@
import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/dispatch";
import {Readable} from "node:stream";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/files/images"});
export async function GET(
req: Request,
@@ -56,7 +56,7 @@ export async function GET(
const result = await dispatchStorage(input, undefined, storageId);
if (!result.file || !(result.file instanceof Readable)) {
console.error(`An error occurred while getting file :`, result);
log.error({error: result}, `An error occurred while getting file`);
return NextResponse.json(
{error: "Invalid file payload"},
{status: 500}
@@ -82,7 +82,7 @@ export async function GET(
});
} catch (err) {
console.error("Error streaming image:", err);
log.error({error: err}, `Error streaming image`);
return NextResponse.json({error: "Error fetching file"}, {status: 500});
}
}
+5 -5
View File
@@ -2,7 +2,9 @@ import {NextResponse} from "next/server";
import fs from "fs";
import path from "path";
import {env} from "@/env.mjs";
import {logger} from "@/lib/logger";
const log = logger.child({module: "api/tus/hooks"});
export async function POST(request: Request) {
try {
@@ -13,7 +15,7 @@ export async function POST(request: Request) {
const uploadOffset = headers["Upload-Offset"]?.[0];
const status = headers["X-Status"]?.[0];
console.log(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
log.info(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
if (status === "success") {
if (
@@ -29,9 +31,7 @@ export async function POST(request: Request) {
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
}
// const uploadDir = path.join(process.cwd(), "/private/uploads/");
const uploadDir = path.join(env.PRIVATE_PATH, "/uploads/");
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath);
@@ -74,7 +74,7 @@ export async function POST(request: Request) {
}
return NextResponse.json({});
} catch (error) {
console.error("Hook error:", error);
log.error({error: error},"TUS Hook error");
return NextResponse.json({error: "Internal server error"}, {status: 500});
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { author, geistMono, poppins } from "@/fonts/fonts";
import { cn } from "@/lib/utils";
import { Providers } from "./providers";
const title = process.env.PROJECT_NAME ?? "App Title";
const title = process.env.PROJECT_NAME ?? "Portabase";
export const metadata: Metadata = {
title: {
+1 -1
View File
@@ -4,7 +4,7 @@ services:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: portabase/portabase:1.4.0
image: portabase/portabase:1.7.1
ports:
- '8887:80'
environment:
+135
View File
@@ -0,0 +1,135 @@
import {test, expect, Page} from '@playwright/test';
type UserCredentials = {
username: string
email: string
password: string
}
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!'},
}
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"]')
}
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()
}
async function logout() {
//TODO
}
test.describe.serial('User signup and login flows', () => {
test('Redirect to login if not connected', async ({page}) => {
await page.goto('/dashboard/projects');
await expect(page).toHaveURL("login?redirect=%2Fdashboard%2Fprojects");
});
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')
})
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')
})
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')
})
})
// test('Successful logout', async ({page}) => {
// await logout()
// await expect(page).toHaveURL('/login')
// })
+3
View File
@@ -43,6 +43,9 @@ const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
},
logging: {
browserToTerminal: false,
},
experimental: {
serverActions: {
bodySizeLimit: "10gb",
+31 -28
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.6.1",
"version": "1.9.3",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -15,8 +15,8 @@
"release": "release-it"
},
"dependencies": {
"@better-auth/passkey": "^1.4.19",
"@better-auth/sso": "^1.4.19",
"@better-auth/passkey": "^1.5.6",
"@better-auth/sso": "^1.5.6",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -47,20 +47,20 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.21",
"@t3-oss/env-nextjs": "^0.13.11",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.23",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.1",
"bcrypt": "^6.0.0",
"better-auth": "1.4.18",
"better-auth": "1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dockerode": "^4.0.9",
"dockerode": "^4.0.10",
"dotenv": "^16.6.1",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
@@ -68,23 +68,25 @@
"googleapis": "^170.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
"minio": "^8.0.6",
"motion": "^12.34.3",
"next": "16.1.5",
"minio": "^8.0.7",
"motion": "^12.38.0",
"next": "16.2.1",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"node-forge": "^1.3.3",
"node-forge": "^1.4.0",
"nodemailer": "^7.0.13",
"npm-check-updates": "^18.3.1",
"pg": "^8.18.0",
"pg": "^8.20.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"prettier": "^3.8.1",
"react": "^19.2.4",
"react-day-picker": "9.7.0",
"react-dom": "^19.2.4",
"react-dropzone": "^14.4.1",
"react-email": "^4.3.2",
"react-hook-form": "^7.71.2",
"react-hook-form": "^7.72.0",
"react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.6",
"react-twc": "^1.5.1",
@@ -94,42 +96,43 @@
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"swiper": "^12.1.2",
"swiper": "^12.1.3",
"tailwind-merge": "^3.5.0",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"ws": "^8.19.0",
"ws": "^8.20.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@iconify/react": "^6.0.2",
"@playwright/test": "1.58.2",
"@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.4",
"@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.5",
"@tailwindcss/postcss": "^4.2.1",
"@release-it/conventional-changelog": "^10.0.6",
"@tailwindcss/postcss": "^4.2.2",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.11",
"@types/node": "^22.19.15",
"@types/node-forge": "^1.3.14",
"@types/pg": "^8.16.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.10.0",
"drizzle-kit": "^0.31.9",
"esbuild": "^0.27.3",
"eslint": "^9.39.3",
"eslint-config-next": "^16.1.6",
"baseline-browser-mapping": "^2.10.12",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.27.4",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.1",
"eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.34.3",
"postcss": "^8.5.6",
"framer-motion": "^12.38.0",
"postcss": "^8.5.8",
"release-it": "^19.2.4",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
"packageManager": "pnpm@10.33.0"
}
+79
View File
@@ -0,0 +1,79 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// 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: {
/* Base URL to use in actions like `await page.goto('')`. */
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 for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'docker compose up -d && make up',
// url: 'http://localhost:8887',
// reuseExistingServer: !process.env.CI,
// },
});
+1411 -1242
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
+17 -6
View File
@@ -209,14 +209,25 @@ type UseZodFormProps<Z extends ZodSchema> = Exclude<
};
// const useZodForm = <Z extends ZodSchema>({
// schema,
// ...formProps
// }: UseZodFormProps<Z>) =>
// useForm({
// ...formProps,
// // @ts-ignore
// resolver: zodResolver(schema),
// });
const useZodForm = <Z extends ZodSchema>({
schema,
...formProps
}: UseZodFormProps<Z>) =>
useForm({
...formProps,
schema,
...formProps
}: UseZodFormProps<Z>): UseFormReturn<TypeOf<Z>> =>
useForm<TypeOf<Z>>({
...formProps,
// @ts-ignore
resolver: zodResolver(schema),
resolver: zodResolver(schema),
});
export {
@@ -1,6 +1,5 @@
"use client";
import {Card, CardContent} from "@/components/ui/card";
import {
FormControl,
FormDescription,
@@ -0,0 +1,149 @@
"use client"
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {Info, Send} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useRouter} from "next/navigation";
import {Setting} from "@/db/schema/01_setting";
import {
Form,
FormField,
FormItem,
FormLabel,
useZodForm
} from "@/components/ui/form";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {useMutation} from "@tanstack/react-query";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {
DefaultNotificationSchema, DefaultNotificationType
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification.schema";
import {
updateNotificationSettingsAction
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification.action";
import {toast} from "sonner";
import {Badge} from "@/components/ui/badge";
export type SettingsNotificationSectionProps = {
settings: Setting;
notificationChannels: NotificationChannelWith[];
};
export const SettingsNotificationSection = ({settings, notificationChannels}: SettingsNotificationSectionProps) => {
const router = useRouter();
const form = useZodForm({
schema: DefaultNotificationSchema,
defaultValues: {
notificationChannelId: settings.defaultNotificationChannelId ?? "",
}
});
const mutation = useMutation({
mutationFn: async (values: DefaultNotificationType) => {
const result = await updateNotificationSettingsAction({name: "system", data: values})
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
}
});
return (
<div className="flex flex-col h-full">
<Alert className="mt-3 flex items-start gap-2">
<Info className="h-4 w-4 mt-1"/>
<div>
<AlertTitle>Informations</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-1">
The default notification channel will be used to send
<Badge>error_health_agent</Badge>
<Badge>error_health_database</Badge>
<Badge>error_backup</Badge>
<Badge>error_restore</Badge>
events. For more options like notify when success, please set policy at database level
</AlertDescription>
</div>
</Alert>
<div className="flex flex-col h-full py-4 gap-3">
<Form
className="space-y-4"
form={form}
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<div className="flex flex-wrap items-center gap-3">
<FormField
control={form.control}
name="notificationChannelId"
render={({ field }) => (
<FormItem className="flex-grow ">
<FormLabel>Default Notification Provider</FormLabel>
{notificationChannels.length === 0 ? (
<div className="text-sm text-muted-foreground">No channel available</div>
) : (
<Select
value={field.value ?? ""}
onValueChange={(value) => field.onChange(value)}
>
<SelectTrigger className="w-full h-full mb-0">
<SelectValue placeholder="Select a default channel" />
</SelectTrigger>
<SelectContent>
{notificationChannels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
<div className="flex items-center gap-2">
{getChannelIcon(channel.provider)}
<span className="font-medium">{channel.name}</span>
<span className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
{channel.provider}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormItem>
)}
/>
</div>
<div className="flex justify-between gap-4">
{notificationChannels.length > 0 && (
<ButtonWithLoading
type="submit"
>
Confirm
</ButtonWithLoading>
)}
<div className="flex justify-end">
{notificationChannels.length > 0 && form.getValues("notificationChannelId") ? (
<ButtonWithLoading
type="button"
variant="outline"
onClick={async () => {
form.setValue("notificationChannelId", "");
await mutation.mutateAsync({
notificationChannelId: null,
});
}}
className="flex-shrink-0 w-full sm:w-auto"
>
Reset
</ButtonWithLoading>
) : null}
</div>
</div>
</Form>
</div>
</div>
);
};
@@ -0,0 +1,51 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {ServerActionResult} from "@/types/action-type";
import {Setting} from "@/db/schema/01_setting";
import {z} from "zod";
import {
DefaultNotificationSchema
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification.schema";
export const updateNotificationSettingsAction = userAction
.schema(
z.object({
name: z.string(),
data: DefaultNotificationSchema,
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Setting>> => {
const {name, data} = parsedInput;
try {
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
defaultNotificationChannelId: data.notificationChannelId ?? null,
})
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
return {
success: true,
value: updatedSettings,
actionSuccess: {
message: "Settings successfully updated",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed update settings.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,7 @@
import {z} from "zod";
export const DefaultNotificationSchema = z.object({
notificationChannelId: z.string().optional().nullable()
});
export type DefaultNotificationType = z.infer<typeof DefaultNotificationSchema>;
@@ -7,14 +7,20 @@ import {Setting} from "@/db/schema/01_setting";
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {MailboxIcon, Save} from "lucide-react";
import {AlarmClock, MailboxIcon, Save} from "lucide-react";
import {
SettingsNotificationSection
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification-section";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
export type SettingsTabsProps = {
settings: Setting
storageChannels: StorageChannelWith[]
storageChannels: StorageChannelWith[],
notificationChannels: NotificationChannelWith[];
};
export const SettingsTabs = ({settings, storageChannels}: SettingsTabsProps) => {
export const SettingsTabs = ({settings, storageChannels, notificationChannels}: SettingsTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -47,6 +53,15 @@ export const SettingsTabs = ({settings, storageChannels}: SettingsTabsProps) =>
content: (
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
)
},
{
name: 'Notification',
value: 'notification',
icon: AlarmClock,
content: (
<SettingsNotificationSection notificationChannels={notificationChannels} settings={settings}/>
)
}
]
@@ -109,7 +109,7 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
control={form.control}
name="storageChannelId"
render={({field}) => (
<FormItem className="flex-grow min-w-[200px] sm:flex-grow-0 sm:w-64">
<FormItem className="flex-grow">
<FormLabel>Default Storage Provider</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-full h-full mb-0">
@@ -52,6 +52,11 @@ export const AgentCard = (props: agentCardProps) => {
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
v{agent.version}
</Badge>
{agent.healthErrorCount && (
<Badge variant="destructive" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
down
</Badge>
)}
{isUpdateAvailable && (
<Tooltip>
<TooltipTrigger asChild>
@@ -17,6 +17,8 @@ import {Separator} from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {AgentDatabaseCard} from "@/components/wrappers/dashboard/agent/agent-database-card";
import {HealthCheckGraph} from "@/components/wrappers/dashboard/health/heath-grid";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
type AgentContentPageProps = {
edgeKey: string;
@@ -32,7 +34,8 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
return result?.data;
},
initialData: {
data: initialAgent
data: initialAgent,
health: []
},
staleTime: 0,
gcTime: 0,
@@ -40,13 +43,15 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
});
const agent = data?.data ?? initialAgent;
const agentHealthLogs: HealthcheckLog[] = data?.health ?? [];
return (
<div className="space-y-10">
<div className="flex flex-col sm:flex-row sm:justify-between gap-6 ">
<Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
<Card className="w-full sm:w-auto flex-1 transition-all border-border/50 bg-card ">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Databases</CardTitle>
<CardTitle
className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
</CardHeader>
<CardContent>
@@ -55,26 +60,34 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1 border-none shadow-none bg-muted/30">
<Card className="w-full sm:w-auto flex-1 transition-all border-border/50 bg-card ">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Last contact</CardTitle>
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Last
contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground opacity-50"/>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tracking-tight">{formatDateLastContact(agent.lastContact)}</div>
<div
className="text-3xl font-bold tracking-tight">{formatDateLastContact(agent.lastContact)}</div>
<p className="text-xs text-muted-foreground mt-1">Status heartbeat</p>
</CardContent>
</Card>
</div>
{agent.lastContact && (
<HealthCheckGraph logs={agentHealthLogs}/>
)}
<div className="space-y-6">
<Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
<AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300">
<AccordionItem value="registration"
className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300">
<AccordionTrigger className="hover:no-underline py-4 group">
<div className="flex items-center gap-3">
<span className="text-xl font-bold tracking-tight">Registration & Setup</span>
{!agent.lastContact && (
<Badge variant="outline" className="bg-orange-500/10 text-orange-600 border-orange-500/20 animate-pulse">
<Badge variant="outline"
className="bg-orange-500/10 text-orange-600 border-orange-500/20 animate-pulse">
Action Required
</Badge>
)}
@@ -91,23 +104,23 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
</div>
{agent.databases.length > 0 && (
<div className="space-y-6">
<div className="flex items-center justify-between px-1">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Managed Databases</h2>
<p className="text-sm text-muted-foreground">
Resources currently connected to this agent.
</p>
<div className="space-y-6">
<div className="flex items-center justify-between px-1">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Managed Databases</h2>
<p className="text-sm text-muted-foreground">
Resources currently connected to this agent.
</p>
</div>
</div>
<Separator className="opacity-50"/>
<CardsWithPagination
cardsPerPage={4}
numberOfColumns={2}
data={agent.databases}
cardItem={AgentDatabaseCard}
/>
</div>
<Separator className="opacity-50" />
<CardsWithPagination
cardsPerPage={4}
numberOfColumns={2}
data={agent.databases}
cardItem={AgentDatabaseCard}
/>
</div>
)}
</div>
)
@@ -7,6 +7,7 @@ import * as drizzleDb from "@/db";
import {BackupWith, Restoration} 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";
export const getDatabaseDataAction = userAction
.schema(
@@ -50,7 +51,9 @@ export const getDatabaseDataAction = userAction
const successfulBackups = backups.filter(b => b.status === "success").length;
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
// @ts-ignore
let activeOrganizationChannels = [];
// @ts-ignore
let activeOrganizationStorageChannels = [];
if (database?.project?.organizationId) {
@@ -61,16 +64,20 @@ export const getDatabaseDataAction = userAction
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
}
return {
database,
backups,
restorations,
// @ts-ignore
activeOrganizationChannels,
// @ts-ignore
activeOrganizationStorageChannels,
stats: {
totalBackups,
availableBackups,
successRate
}
},
health: database ? await getHealthLast12hLogs({ id: database.id }) : []
};
});
@@ -37,7 +37,6 @@ import {backupOnly} from "@/components/wrappers/dashboard/projects/database/data
type ChannelPoliciesFormProps = {
onSuccess?: () => void;
channels: NotificationChannel[] | StorageChannel[];
organizationId: string;
database: DatabaseWith;
kind: ChannelKind
};
@@ -46,7 +45,6 @@ type ChannelPoliciesFormProps = {
export const ChannelPoliciesForm = ({
database,
channels,
organizationId,
onSuccess,
kind
}: ChannelPoliciesFormProps) => {
@@ -65,7 +65,6 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati
</DialogDescription>
<Separator className="mt-3 mb-3"/>
<ChannelPoliciesForm
organizationId={organizationId}
channels={channels}
database={database}
onSuccess={() => setOpen(false)}
@@ -3,7 +3,7 @@ import {z} from "zod";
export const PolicySchema = z.object({
channelId: z.string().min(1, "Please select channel"),
eventKinds: z.array(z.enum([
'error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report'
'error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report', 'error_health_database'
]))
.optional(),
enabled: z.boolean().default(true),
@@ -17,16 +17,16 @@ export const PoliciesSchema = z.object({
export type PoliciesType = z.infer<typeof PoliciesSchema>;
export type PolicyType = z.infer<typeof PolicySchema>;
export const EVENT_KIND_OPTIONS = [
{label: "Error Backup", value: "error_backup"},
{label: "Error Restore", value: "error_restore"},
{label: "Success Restore", value: "success_restore"},
{label: "Success Backup", value: "success_backup"},
// {label: "Weekly Report", value: "weekly_report"},
];
export const EVENT_KIND_BACKUP_ONLY_OPTIONS = [
{label: "Error Backup", value: "error_backup"},
{label: "Success Backup", value: "success_backup"},
];
{label: "Health Ping Fail", value: "error_health_database"},
];
export const EVENT_KIND_OPTIONS = [
...EVENT_KIND_BACKUP_ONLY_OPTIONS,
{label: "Error Restore", value: "error_restore"},
{label: "Success Restore", value: "success_restore"},
// {label: "Weekly Report", value: "weekly_report"},
];
@@ -0,0 +1,43 @@
"use client"
import {useState} from "react";
import {Button} from "@/components/ui/button";
import {DatabaseWith} from "@/db/schema/07_database";
import {HeartPulse} from "lucide-react";
import {
Sheet,
SheetContent,
SheetTitle,
SheetTrigger
} from "@/components/ui/sheet";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
import {HealthCheckGraph} from "@/components/wrappers/dashboard/health/heath-grid";
type HealthModalProps = {
database: DatabaseWith,
healthLogs: HealthcheckLog[]
}
export const HealthModal = ({database, healthLogs}: HealthModalProps) => {
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" onClick={() => setOpen(true)}>
<HeartPulse/>
</Button>
</SheetTrigger>
<SheetContent side="bottom">
<div className="flex pl-5 pt-4">
<SheetTitle>
Database Health Status
</SheetTitle>
</div>
<div className="px-4 pb-5">
<HealthCheckGraph logs={healthLogs} />
</div>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,204 @@
"use client"
import {useMemo} from "react"
import {Card} from "@/components/ui/card"
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log"
import {useIsMobile} from "@/hooks/use-mobile";
type HealthStatus = "healthy" | "degraded" | "down" | "unknown"
interface HealthCheckData {
timestamp: Date
status: HealthStatus
}
interface Props {
logs: HealthcheckLog[]
}
const INTERVAL_MINUTES = 10
const WINDOW_HOURS = 12
function roundDateToInterval(date: Date, intervalMinutes: number): Date {
const ms = intervalMinutes * 60 * 1000
return new Date(Math.floor(date.getTime() / ms) * ms)
}
function buildTimeSeries(logs: HealthcheckLog[]): HealthCheckData[] {
const intervalMs = INTERVAL_MINUTES * 60 * 1000
const now = new Date()
const roundedNow = roundDateToInterval(now, INTERVAL_MINUTES)
const buckets = (WINDOW_HOURS * 60) / INTERVAL_MINUTES
const data: HealthCheckData[] = []
const oldestLog = logs.length > 0 ? getOldestLog(logs) : null
for (let i = buckets - 1; i >= 0; i--) {
const start = new Date(roundedNow.getTime() - i * intervalMs)
const end = new Date(start.getTime() + intervalMs)
const bucketLogs = logs.filter(
(l) =>
l.date &&
new Date(l.date) >= start &&
new Date(l.date) < end
)
let status: HealthStatus = "unknown"
if (!oldestLog || new Date(oldestLog.date!) > start) {
status = "unknown"
} else if (new Date(oldestLog.date!) < start) {
status = "down"
}
if (bucketLogs.length > 0) {
const hasFailure = bucketLogs.some((l) => l.status === "failed")
const hasSuccess = bucketLogs.some((l) => l.status === "success")
if (hasFailure && hasSuccess) {
status = "degraded"
} else if (hasFailure) {
status = "down"
} else if (hasSuccess) {
status = "healthy"
}
}
data.push({timestamp: start, status})
}
return data
}
function getStatusColor(status: HealthStatus): string {
switch (status) {
case "healthy":
return "bg-emerald-500"
case "degraded":
return "bg-emerald-700"
case "down":
return "bg-red-500"
case "unknown":
return "bg-zinc-700"
}
}
function getOldestLog(logs: HealthcheckLog[]): HealthcheckLog {
console.log(logs)
const validLogs = logs.filter(l => l.date)
return validLogs.reduce((oldest, current) =>
new Date(current.date!) < new Date(oldest.date!) ? current : oldest
)
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
}
export const HealthCheckGraph = ({logs}: Props) => {
const data = useMemo(() => {
return buildTimeSeries(logs)
}, [logs])
const isMobile = useIsMobile()
const hourLabels = useMemo(() => {
if (data.length === 0) return []
return data
.map((item, index) => ({ item, index }))
.filter(({ item }) => {
const hours = item.timestamp.getHours()
const minutes = item.timestamp.getMinutes()
if (isMobile) {
return minutes === 0 && hours % 3 === 0
} else {
return minutes === 0
}
})
.map(({ item, index }) => ({
hour: formatTime(item.timestamp),
index,
}))
}, [data])
const healthyCount = data.filter((d) => d.status === "healthy").length
const uptimePercent =
data.length > 0
? ((healthyCount / data.length) * 100).toFixed(1)
: "0.0"
return (
<div className="flex items-center justify-center">
<div className="w-full">
<Card className="h-full flex flex-col p-4 border-border/50 bg-card gap-0">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">Health</h2>
<p className="text-zinc-500 text-sm">
Last 12 hours {INTERVAL_MINUTES} minute intervals
</p>
</div>
<div className="text-right">
<p className="text-emerald-400 text-2xl font-bold">
{uptimePercent}%
</p>
<p className="text-zinc-500 text-sm">Uptime</p>
</div>
</div>
<div className="relative mb-1 h-4 text-xs text-zinc-500">
{hourLabels.map((label, i) => {
const left = (label.index / (data.length - 1)) * 100
return (
<div
key={i}
className="absolute -translate-x-1/2 whitespace-nowrap"
style={{ left: `${left}%` }}
>
{label.hour}
</div>
)
})}
</div>
<div className="flex gap-0.5">
{data.map((item, index) => (
<div
key={index}
className={`flex-1 h-8 rounded-sm ${getStatusColor(item.status)} hover:ring-2 hover:ring-zinc-400 transition-all cursor-pointer`}
title={`${formatTime(item.timestamp)} - ${item.status}`}
/>
))}
</div>
<div className="flex items-center justify-end gap-4 mt-4 text-xs text-zinc-500">
<Legend color="bg-zinc-700" label="Unknown"/>
<Legend color="bg-red-500" label="Down"/>
<Legend color="bg-emerald-700" label="Degraded"/>
<Legend color="bg-emerald-500" label="Healthy"/>
</div>
</Card>
</div>
</div>
)
}
const Legend = ({color, label}: { color: string; label: string }) => (
<div className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded-sm ${color}`}/>
<span>{label}</span>
</div>
)
@@ -20,6 +20,8 @@ import { ChannelPoliciesModal } from "@/components/wrappers/dashboard/database/c
import { HardDrive, Megaphone } from "lucide-react";
import { ImportModal } from "@/components/wrappers/dashboard/database/import/import-modal";
import { BackupButton } from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {HealthModal} from "@/components/wrappers/dashboard/database/health/health-modal";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
export type DatabaseContentProps = {
settings: Setting;
@@ -34,6 +36,7 @@ export type DatabaseContentProps = {
organizationId: string;
activeOrganizationChannels: any[];
activeOrganizationStorageChannels: any[];
databaseHealthLogs: HealthcheckLog[]
};
export const DatabaseContent = (props: DatabaseContentProps) => {
@@ -64,6 +67,7 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
availableBackups: props.availableBackups,
successRate: props.successRate,
},
health: props.databaseHealthLogs
},
staleTime: 0,
gcTime: 0,
@@ -118,10 +122,13 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
organizationId={props.organizationId}
/>
<ImportModal database={database} />
<HealthModal database={database} healthLogs={data?.health ?? []}/>
</div>
<div className="flex items-center gap-2">
<BackupButton
disable={isAlreadyBackup}
disable={isAlreadyBackup || !database.lastContact}
databaseId={database.id}
/>
</div>
@@ -18,7 +18,7 @@ export type DatabaseTabsProps = {
activeMember: MemberWithUser
};
export const backupOnly = ["redis"];
export const backupOnly = ["redis", "valkey"];
export const DatabaseTabs = (props: DatabaseTabsProps) => {
const router = useRouter();
+9 -4
View File
@@ -15,6 +15,9 @@ import * as notificationLog from "./schema/11_notification-log";
import * as storageChannel from "./schema/12_storage-channel";
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";
const log = logger.child({module: "db"});
import {Pool} from "pg";
@@ -22,6 +25,7 @@ import {Pool} from "pg";
// Do not delete
import dotenv from "dotenv";
import {migrate} from "drizzle-orm/node-postgres/migrator";
import {logger} from "@/lib/logger";
dotenv.config({
path: ".env",
@@ -46,7 +50,8 @@ export const schemas = {
...notificationLog,
...storageChannel,
...storagePolicy,
...backupStorage
...backupStorage,
...healthcheckLog
};
export const db = drizzle({
@@ -64,12 +69,12 @@ export async function makeMigration() {
const database = drizzle({client: pool});
console.log("Running migrations...");
log.info("Running migrations...");
try {
await migrate(database, {migrationsFolder: "./src/db/migrations"});
console.log("Migrations applied successfully.");
log.info("Migrations applied successfully.");
} catch (error) {
console.error("Error applying migrations:", error);
log.error({error: error}, "Error applying migrations:");
}
}
}
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'valkey';
+12
View File
@@ -0,0 +1,12 @@
CREATE TYPE "public"."healthcheck_status" AS ENUM('success', 'failed');--> statement-breakpoint
CREATE TYPE "public"."healthcheck_kind" AS ENUM('database', 'agent');--> statement-breakpoint
CREATE TABLE "healthcheck_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"kind" "healthcheck_kind" NOT NULL,
"date" timestamp,
"status" "healthcheck_status",
"object_id" uuid,
"updated_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
@@ -0,0 +1,3 @@
ALTER TABLE "healthcheck_log" ALTER COLUMN "date" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "healthcheck_log" ALTER COLUMN "status" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "healthcheck_log" ALTER COLUMN "object_id" SET NOT NULL;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "settings" ADD COLUMN "default_notification_channel_id" uuid;--> statement-breakpoint
ALTER TABLE "settings" ADD CONSTRAINT "settings_default_notification_channel_id_notification_channel_id_fk" FOREIGN KEY ("default_notification_channel_id") REFERENCES "public"."notification_channel"("id") ON DELETE set null ON UPDATE no action;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "agents" ADD COLUMN "health_error_count" integer;
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "public"."event_kind" ADD VALUE 'error_health_agent';
@@ -0,0 +1 @@
ALTER TYPE "public"."event_kind" ADD VALUE 'error_health_database';
@@ -0,0 +1 @@
ALTER TABLE "databases" ADD COLUMN "health_error_count" integer;
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'mariadb' BEFORE 'mongodb';
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+63
View File
@@ -274,6 +274,69 @@
"when": 1773433992397,
"tag": "0038_misty_red_hulk",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1773659096498,
"tag": "0039_conscious_solo",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1774375027038,
"tag": "0040_quick_lester",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1774378412843,
"tag": "0041_spooky_radioactive_man",
"breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1774472168308,
"tag": "0042_breezy_namora",
"breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1774643615673,
"tag": "0043_peaceful_chat",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1774648872657,
"tag": "0044_steep_wiccan",
"breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1774696680244,
"tag": "0045_needy_martin_li",
"breakpoints": true
},
{
"idx": 46,
"version": "7",
"when": 1774706071024,
"tag": "0046_mysterious_menace",
"breakpoints": true
},
{
"idx": 47,
"version": "7",
"when": 1774768062658,
"tag": "0047_wet_carnage",
"breakpoints": true
}
]
}
+5 -2
View File
@@ -1,10 +1,10 @@
import {boolean, pgTable, timestamp, uuid, varchar} from "drizzle-orm/pg-core";
import {boolean, pgTable, uuid, varchar} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {timestamps} from "@/db/schema/00_common";
import {storageChannel} from "@/db/schema/12_storage-channel";
import {relations} from "drizzle-orm";
import {notificationChannel} from "@/db/schema/09_notification-channel";
export const setting = pgTable("settings", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -15,6 +15,8 @@ export const setting = pgTable("settings", {
smtpPort: varchar("smtp_port", {length: 255}),
smtpUser: varchar("smtp_user", {length: 255}),
smtpSecure: boolean("smtp_secure"),
defaultNotificationChannelId: uuid('default_notification_channel_id')
.references(() => notificationChannel.id, {onDelete: "set null"}),
defaultStorageChannelId: uuid('default_storage_channel_id')
.references(() => storageChannel.id, {onDelete: "set null"}),
encryption: boolean("encryption").default(false),
@@ -23,6 +25,7 @@ export const setting = pgTable("settings", {
export const settingRelations = relations(setting, ({one, many}) => ({
storageChannel: one(storageChannel, {fields: [setting.defaultStorageChannelId], references: [storageChannel.id]}),
notificationChannel: one(notificationChannel, {fields: [setting.defaultNotificationChannelId], references: [notificationChannel.id]}),
}));
+1
View File
@@ -19,6 +19,7 @@ export const database = pgTable("databases", {
backupPolicy: text("backup_policy"),
isWaitingForBackup: boolean("is_waiting_for_backup").default(false).notNull(),
backupToRestore: text("backup_to_restore"),
healthErrorCount: integer("health_error_count"),
agentId: uuid("agent_id")
.notNull()
.references(() => agent.id, {onDelete: "cascade"}),
+2 -1
View File
@@ -1,4 +1,4 @@
import {boolean, pgTable, text, timestamp, uuid} from "drizzle-orm/pg-core";
import {boolean, pgTable, text, timestamp, uuid, integer} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {Database, database} from "@/db/schema/07_database";
@@ -10,6 +10,7 @@ export const agent = pgTable("agents", {
slug: text("slug").notNull().unique(),
version: text("version"),
name: text("name").notNull().notNull(),
healthErrorCount: integer("health_error_count"),
description: text("description").notNull(),
isArchived: boolean("is_archived").default(false),
lastContact: timestamp("last_contact"),
+1 -1
View File
@@ -6,7 +6,7 @@ import {database} from "@/db/schema/07_database";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const eventKindEnum = pgEnum('event_kind', ['error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report']);
export const eventKindEnum = pgEnum('event_kind', ['error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report', 'error_health_agent', 'error_health_database']);
export const alertPolicy = pgTable('alert_policy', {
id: uuid('id').defaultRandom().primaryKey(),
+23
View File
@@ -0,0 +1,23 @@
import {pgTable, uuid, timestamp, pgEnum} from 'drizzle-orm/pg-core';
import {timestamps} from "@/db/schema/00_common";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const healthcheckKindEnum = pgEnum('healthcheck_kind', ['database', 'agent']);
export const healthCheckStatusEnum = pgEnum('healthcheck_status', ['success', 'failed']);
export const healthcheckLog = pgTable('healthcheck_log', {
id: uuid('id').defaultRandom().primaryKey(),
kind: healthcheckKindEnum('kind').notNull(),
date: timestamp("date").notNull(),
status: healthCheckStatusEnum("status").notNull(),
objectId: uuid('object_id').notNull(),
...timestamps
});
export const healthcheckLogSchema = createSelectSchema(healthcheckLog);
export type HealthcheckLog = z.infer<typeof healthcheckLogSchema>;
export type HealthcheckKind = (typeof healthcheckKindEnum.enumValues)[number];
export type HealthcheckStatus = (typeof healthCheckStatusEnum.enumValues)[number];
+1 -1
View File
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb", "sqlite", "redis"]);
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey"]);
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+185
View File
@@ -0,0 +1,185 @@
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {and, eq, gte, isNotNull, lt} from "drizzle-orm";
import {dispatchNotification} from "@/features/notifications/dispatch";
import {EventPayload} from "@/features/notifications/types";
import {logger} from "@/lib/logger";
const log = logger.child({module: "tasks/healthcheck"});
export async function getHealthLast12hLogs({id}: { id: string }) {
const now = new Date()
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000)
return db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
and(
eq(drizzleDb.schemas.healthcheckLog.objectId, id),
gte(drizzleDb.schemas.healthcheckLog.date, since)
)
)
}
export async function deleteHealthLogsOlderThan12h() {
const now = new Date()
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000)
const logsToDelete = await db
.select()
.from(drizzleDb.schemas.healthcheckLog)
.where(
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
log.info({name: "deleteHealthLogsOlderThan12h"},`Number of logs found to delete: ${logsToDelete.length}`)
await db
.delete(drizzleDb.schemas.healthcheckLog)
.where(
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
return logsToDelete.length
}
export async function checkAgentsHealthError() {
const agents = await db.query.agent.findMany({
where: isNotNull(drizzleDb.schemas.agent.lastContact),
});
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
throw new Error("System settings not found");
}
if (!settings.defaultNotificationChannelId) {
log.error({name: "checkAgentsHealthError"},`No default notification channel id found.`)
return
}
const now = new Date();
for (const agent of agents) {
if (!agent.lastContact) continue;
const lastContactDate = new Date(agent.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((agent.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1
await db.update(drizzleDb.schemas.agent)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.agent.id, agent.id));
const payload: EventPayload = {
title: "Agent down",
message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_agent",
data: {
agent: agent.name,
id: agent.id,
error: "Agent is down",
},
};
log.info({name: "checkAgentsHealthError", payload: payload},`Agent Healthcheck Notification`)
await dispatchNotification(
payload,
undefined,
settings.defaultNotificationChannelId,
undefined
);
}
}
}
}
export async function checkDatabasesHealthError() {
const databases = await db.query.database.findMany({
where: isNotNull(drizzleDb.schemas.database.lastContact),
with: {
agent: true,
alertPolicies: true
}
})
const now = new Date();
for (const database of databases) {
if (!database.lastContact) continue;
const lastContactDate = new Date(database.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((database.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1
await db.update(drizzleDb.schemas.database)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.database.id, database.id));
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: { notificationChannel: true },
});
const defaultPolicy = settings?.notificationChannel
? [{
id: null,
notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled,
eventKinds: ["error_health_database"]
}]
: [];
const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0)
? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes("error_health_database"))
: defaultPolicy;
if (!policiesToUse || policiesToUse.length === 0) {
continue
}
const promises = policiesToUse.map(alertPolicy => {
const payload: EventPayload = {
title: "Database down",
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_database",
data: {
agent: database.name,
id: database.id,
error: "Database is down",
},
};
log.info({name: "checkDatabasesHealthError", payload: payload},`Database Healthcheck Notification`)
return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined);
});
await Promise.all(promises);
}
}
}
}
+15
View File
@@ -39,6 +39,20 @@ export const env = createEnv({
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
),
CLEANING_HEALTHCHECK_LOGS_CRON: z
.string()
.default(
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
),
HEALTHCHECK_CRON: z
.string()
.default(
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
),
AUTH_OIDC_ID: z.string().optional().default("oidc"),
AUTH_OIDC_TITLE: z.string().optional(),
AUTH_OIDC_DESC: z.string().optional(),
@@ -95,6 +109,7 @@ export const env = createEnv({
SMTP_SECURE: process.env.SMTP_SECURE,
RETENTION_CRON: process.env.RETENTION_CRON,
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
+4
View File
@@ -6,6 +6,7 @@ import {eq, and, ne, count} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {slugify} from "@/utils/slugify";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
const verifySlugUniqueness = async (slug: string, agentId?: string) => {
const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug);
@@ -54,7 +55,10 @@ export const getAgentAction = userAction.schema(z.string()).action(async ({parse
databases: true
}
});
return {
data: agent,
health: agent ? await getHealthLast12hLogs({ id: agent.id }) : []
};
});
+27 -7
View File
@@ -1,17 +1,35 @@
import { DatabaseWith } from "@/db/schema/07_database";
import { EventKind, EventPayload } from "@/features/notifications/types";
import { dispatchNotification } from "@/features/notifications/dispatch";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
export async function sendNotificationsBackupRestore(database: DatabaseWith, event: EventKind) {
if (!database.alertPolicies || database.alertPolicies.length === 0) {
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: { notificationChannel: true },
});
const defaultPolicy = settings?.notificationChannel
? [{
id: null,
notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled,
eventKinds: ["error_backup" , "error_restore"]
}]
: [];
const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0)
? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes(event))
: defaultPolicy.filter(policy => policy.eventKinds.includes(event));
if (!policiesToUse || policiesToUse.length === 0) {
return [];
}
const activePolicies = database.alertPolicies.filter(policy =>
policy.enabled && policy.eventKinds.includes(event)
);
const promises = activePolicies.map(alertPolicy => {
const promises = policiesToUse.map(alertPolicy => {
const date = new Date();
let level: "info" | "critical" = "info";
let message = "";
@@ -41,6 +59,8 @@ export async function sendNotificationsBackupRestore(database: DatabaseWith, eve
success_backup: `Backup Notification`,
success_restore: `Restore Notification`,
weekly_report: `Weekly Report Notification`,
error_health_agent: "Health Agent Notification",
error_health_database: "Health Database Notification",
};
const payload: EventPayload = {
@@ -56,7 +76,7 @@ export async function sendNotificationsBackupRestore(database: DatabaseWith, eve
},
};
return dispatchNotification(payload, alertPolicy.id, undefined, undefined);
return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined);
});
+1 -1
View File
@@ -18,4 +18,4 @@ export interface EventPayload {
data?: Record<string, any>;
}
export type EventKind = ("error_backup" | "error_restore" | "success_restore" | "success_backup" | "weekly_report")
export type EventKind = ("error_backup" | "error_restore" | "success_restore" | "success_backup" | "weekly_report" | "error_health_agent" | "error_health_database")
+652 -665
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
import pino, { type Logger } from "pino";
const isProd = process.env.NODE_ENV === "production";
function getLocalTimestamp() {
const date = new Date();
const formatted = new Intl.DateTimeFormat("en-US", {
timeZone: process.env.TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
}).format(date).replace(" ", "T");
return `,"time":"${formatted}"`;
}
export const logger: Logger = pino({
level: isProd ? "info" : "debug",
base: null,
...(isProd
? {
timestamp: getLocalTimestamp,
formatters: {
level(label) {
return { level: label.toUpperCase() };
}
}
}
: {
timestamp: getLocalTimestamp,
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "yyyy-mm-dd HH:MM:ss",
ignore: "pid,hostname",
levelFirst: true
}
}
})
});
+5 -3
View File
@@ -2,6 +2,9 @@ import {db} from "@/db";
import {and, eq, isNotNull, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {withUpdatedAt} from "@/db/utils";
import {logger} from "@/lib/logger";
const log = logger.child({module: "tasks/cleaning"});
export const backupCleanTask = async () => {
try {
@@ -11,8 +14,7 @@ export const backupCleanTask = async () => {
eq(drizzleDb.schemas.backup.status, "ongoing")
)
});
console.log(`Backups to clean: ${backups.length}`);
log.info(`Backups to clean: ${backups.length}`);
for (const backup of backups) {
await db.update(drizzleDb.schemas.backup).set(withUpdatedAt({
@@ -22,7 +24,7 @@ export const backupCleanTask = async () => {
}
} catch (e: any) {
console.error("Backup cleanup failed:", e);
log.info({name: "backupCleanTask", error: e},`Backup cleanup failed`);
throw e;
}
};
+7 -3
View File
@@ -5,7 +5,9 @@ import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf";
import {retentionPolicy} from "@/db/schema/07_database";
import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {logger} from "@/lib/logger";
const log = logger.child({module: "tasks/database"});
export const retentionCleanTask = async () => {
try {
@@ -17,13 +19,13 @@ export const retentionCleanTask = async () => {
},
},
});
console.log(`Retention databases number: ${databases.length}`);
log.info(`Retention databases number: ${databases.length}`);
for (const db of databases) {
if (!db.retentionPolicy) continue;
await enforceRetention(db.id, db.retentionPolicy);
}
} catch (e: any) {
console.error("Retention cleanup failed:", e);
log.error({error: e},"Retention cleanup failed");
throw e;
}
};
@@ -32,7 +34,9 @@ export async function enforceRetention(
databaseId: string,
policy: typeof retentionPolicy.$inferSelect
) {
console.log(`Retention started for ${databaseId}`);
;
log.info({name: "enforceRetention"},`Retention started for ${databaseId}`);
switch (policy.type) {
case "count":
await enforceRetentionCount(databaseId, policy.count ?? 7);
+7 -5
View File
@@ -2,10 +2,12 @@ import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, desc, eq, isNull} from "drizzle-orm";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
import {toast} from "sonner";
import {logger} from "@/lib/logger";
const log = logger.child({module: "tasks/database/retention-count"});
export async function enforceRetentionCount(databaseId: string, count: number) {
console.log(`[Retention Count] - ${databaseId} : started`);
log.info({ name: "enforceRetentionCount"}, `Retention count started for databaseId: ${databaseId}`);
const backups = await db.query.backup.findMany({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)),
orderBy: desc(drizzleDb.schemas.backup.createdAt),
@@ -19,7 +21,7 @@ export async function enforceRetentionCount(databaseId: string, count: number) {
});
const toDelete = backups.slice(count);
console.log(`[Retention Count] - ${databaseId} : ${toDelete.length} backups to delete`);
log.info({ name: "enforceRetentionCount"}, `Found ${toDelete.length} backups to delete for databaseId: ${databaseId}`);
for (const b of toDelete) {
const result = await deleteBackupCronAction({
@@ -29,9 +31,9 @@ export async function enforceRetentionCount(databaseId: string, count: number) {
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention Count] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
log.info({ name: "enforceRetentionCount"}, `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
} else {
console.log(`[Retention Count] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
log.info({ name: "enforceRetentionCount"}, `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
}
}
}
+7 -3
View File
@@ -2,9 +2,13 @@ import {db} from "@/db";
import {eq, lt, and, desc, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
import {logger} from "@/lib/logger";
const log = logger.child({module: "tasks/database/retention-days"});
export async function enforceRetentionDays(databaseId: string, days: number) {
console.log(`Enforce Retention Days starting for ${databaseId}`);
log.info({ name: "enforceRetentionDays"}, `Enforce Retention Days starting for ${databaseId}`);
const cutoff = new Date(Date.now() - days * 86400000);
const expiredBackups = await db.query.backup.findMany({
@@ -31,9 +35,9 @@ export async function enforceRetentionDays(databaseId: string, days: number) {
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention Days] - (databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`);
log.info({ name: "enforceRetentionDays"}, `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`);
} else {
console.log(`[Retention Days] - (databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : an error occurred - ${inner?.actionError?.message}`);
log.info({ name: "enforceRetentionDays"}, `(databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : an error occurred - ${inner?.actionError?.message}`);
}
}
+6 -3
View File
@@ -3,6 +3,9 @@ import {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, start
import {eq, desc, isNull, and} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
import {logger} from "@/lib/logger";
const log = logger.child({module: "tasks/database/retention-gsf"});
export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
daily: number;
@@ -10,7 +13,7 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
monthly: number;
yearly: number;
}) {
console.log(`Enforce Retention GFS starting for ${databaseId}`);
log.info({ name: "enforceRetentionGFS"}, `Retention GFS started for databaseId: ${databaseId}`);
const backups = await db.query.backup.findMany({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)),
@@ -69,9 +72,9 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention GFS] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
log.info({ name: "enforceRetentionGFS"}, `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
} else {
console.log(`[Retention GFS] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
log.info({ name: "enforceRetentionGFS"}, `(databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
}
}
}
+31 -4
View File
@@ -2,22 +2,49 @@ import cron from "node-cron";
import {retentionCleanTask} from "@/lib/tasks/database";
import {env} from "@/env.mjs";
import {backupCleanTask} from "@/lib/tasks/cleaning";
import {
checkAgentsHealthError,
checkDatabasesHealthError,
deleteHealthLogsOlderThan12h
} from "@/db/services/healthcheck";
import {logger} from "@/lib/logger";
const log = logger.child({module: "tasks"});
export const retentionJob = cron.schedule(env.RETENTION_CRON, async () => {
try {
console.log("Retention Job : Starting task");
log.info({ job: "cron", action: "start", name: "retentionJob" }, "Retention Job started");
await retentionCleanTask();
} catch (err) {
console.error(`[CRON] Error:`, err);
log.error({ job: "cron", name: "retentionJob", error: err }, "Retention Job Error");
}
});
export const cleaningJob = cron.schedule("* * * * *", async () => {
try {
console.log("Cleaning Job : Starting task");
log.info({ job: "cron", action: "start", name: "cleaningJob" }, "Cleaning Job started");
await backupCleanTask();
} catch (err) {
console.error(`[CRON] Error:`, err);
log.error({ job: "cron", name: "cleaningJob", error: err }, "Cleaning Job Error");
}
});
export const cleaningHealthcheckLogsJob = cron.schedule(env.CLEANING_HEALTHCHECK_LOGS_CRON, async () => {
try {
log.info({ job: "cron", action: "start", name: "cleaningHealthcheckLogsJob" }, "Cleaning Health Logs Job started");
await deleteHealthLogsOlderThan12h();
} catch (err) {
log.error({ job: "cron", name: "cleaningHealthcheckLogsJob", error: err }, "Cleaning Health Logs Job Error");
}
});
export const healthcheckAgentAndDatabaseJob = cron.schedule(env.HEALTHCHECK_CRON, async () => {
try {
log.info({ job: "cron", action: "start", name: "healthcheckAgentAndDatabaseJob" }, "Healthcheck Jobs started");
await checkAgentsHealthError();
await checkDatabasesHealthError()
} catch (err) {
log.error({ job: "cron", name: "healthcheckAgentAndDatabaseJob", error: err }, "Healthcheck Jobs Error");
}
});
+20 -14
View File
@@ -2,42 +2,48 @@ import { env } from "@/env.mjs";
import { db, makeMigration } from "@/db";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { cleaningJob, retentionJob } from "@/lib/tasks";
import {cleaningHealthcheckLogsJob, cleaningJob, healthcheckAgentAndDatabaseJob, retentionJob} from "@/lib/tasks";
import { generateRSAKeys, getOrCreateMasterKey } from "@/utils/rsa-keys";
import { StorageProviderKind } from "@/features/storages/types";
import {logger} from "@/lib/logger";
const log = logger.child({module: "init"});
export async function init() {
consoleAscii();
console.log("====Init Functions====");
log.info("====Init Functions====");
await getOrCreateMasterKey();
await generateRSAKeys();
await makeMigration();
await createDefaultOrganization();
await createSettingsIfNotExist();
console.log("====Initialization completed====");
log.info("====Initialization completed====");
await setupCronJobs();
await setupCleaningJobs();
if (
(env.AUTH_GOOGLE_ID && env.AUTH_GOOGLE_SECRET) ||
(env.AUTH_GITHUB_ID && env.AUTH_GITHUB_SECRET)
) {
console.warn(
"[Deprecation Warning] You have set up OAuth credentials in your environment variables, but the format is now different. Please update your environment variables to use the new format. For example, if you were using AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET, you should now use AUTH_SOCIAL_GOOGLE_CLIENT and AUTH_SOCIAL_GOOGLE_SECRET. Please refer to the documentation for more details. (https://portabase.io/docs/dashboard/auth/oauth2/setup#dynamic-providers)",
log.warn(
{
deprecated: true,
provider: "oauth_env",
message: "You have set up OAuth credentials in your environment variables, but the format is now different. Please update your environment variables to use the new format. For example, if you were using AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET, you should now use AUTH_SOCIAL_GOOGLE_CLIENT and AUTH_SOCIAL_GOOGLE_SECRET. Please refer to the documentation for more details. (https://portabase.io/docs/dashboard/auth/oauth2/setup#dynamic-providers)"
},
"Deprecated OAuth environment variables detected",
);
}
}
async function setupCronJobs() {
console.log("==== Setting up Cron Jobs ====");
retentionJob.start();
console.log("==== Cron job started ====");
}
async function setupCleaningJobs() {
console.log("==== Setting up Cleaning Jobs ====");
log.info("==== Setting up Cron Jobs ====");
retentionJob.start();
cleaningJob.start();
console.log("==== Cleaning job started ====");
cleaningHealthcheckLogsJob.start();
healthcheckAgentAndDatabaseJob.start();
log.info("==== Cron jobs started ====");
}
async function createSettingsIfNotExist() {
@@ -116,7 +122,7 @@ async function createDefaultOrganization() {
.limit(1);
if (!existing) {
console.log("==== Creating default Organization... ====");
log.info("==== Creating default Organization... ====");
await db
.insert(drizzleDb.schemas.organization)
.values(defaultOrganizationConf);
+12 -7
View File
@@ -4,6 +4,11 @@ import {generateKeyPair} from 'crypto';
import {promisify} from 'util';
import {randomBytes} from 'crypto';
import {env} from "@/env.mjs";
import {logger} from "@/lib/logger";
const log = logger.child({module: "rsa-keys"});
const generateKeyPairAsync = promisify(generateKeyPair);
@@ -14,17 +19,16 @@ const generateKeyPairAsync = promisify(generateKeyPair);
* @param {string} [dir] path to directory
* @returns {Promise<{privateKeyPath:string, publicKeyPath:string}>}
*/
export async function generateRSAKeys(dir = path.join(env.PRIVATE_PATH, '/keys')) {
export async function generateRSAKeys(dir = path.join(env.PRIVATE_PATH!, '/keys')) {
await fs.mkdir(dir, {recursive: true});
const privateKeyPath = path.join(dir, 'server_private.pem');
const publicKeyPath = path.join(dir, 'server_public.pem');
try {
await fs.access(privateKeyPath);
await fs.access(publicKeyPath);
console.log('RSA keys already exist. Skipping generation.');
log.info('RSA keys already exist. Skipping generation.');
return {privateKeyPath, publicKeyPath};
} catch {
}
@@ -49,13 +53,13 @@ export async function generateRSAKeys(dir = path.join(env.PRIVATE_PATH, '/keys')
* @param {string} [filePath] Path to store the key
* @returns {Promise<Buffer>} The master key
*/
export async function getOrCreateMasterKey(filePath = path.join(env.PRIVATE_PATH, '/keys', 'master_key.bin')) {
export async function getOrCreateMasterKey(filePath = path.join(env.PRIVATE_PATH!, '/keys', 'master_key.bin')) {
await fs.mkdir(path.dirname(filePath), {recursive: true});
try {
const existing = await fs.readFile(filePath);
console.log('Master key already exists. Skipping generation.');
log.info('Master key already exists. Skipping generation.');
return existing;
} catch {
// File does not exist, generate
@@ -64,7 +68,8 @@ export async function getOrCreateMasterKey(filePath = path.join(env.PRIVATE_PATH
const key = randomBytes(32); // 256-bit key
await fs.writeFile(filePath, key, {mode: 0o600});
console.log(`Master key generated at ${filePath}`);
log.info("Master key already exists. Skipping generation.");
return key;
}
}