Compare commits

..
47 Commits
Author SHA1 Message Date
github-actions[bot] f30b55ba47 chore: release 1.18.0 2026-06-07 15:42:38 +00:00
Charles GTEandGitHub 751f29e340 Merge pull request #309 from Portabase/feat/backup-restore-logs
feat: backup-restore-logs
2026-06-07 17:41:43 +02:00
charles-gauthereau 2e629f8423 fix: refactoring and ordering data on agent and project page 2026-06-07 17:33:47 +02:00
Charles GTE ae431f5b85 fix: pnpm 2026-06-07 11:22:22 +02:00
Charles GTE f5f697ceb0 fix: refactoring 2026-06-07 11:18:45 +02:00
Charles GTE 02a8f0baf9 fix: also add for restoration 2026-06-07 11:15:21 +02:00
Charles GTE 2932149b92 feat: backup and restore logs modal view 2026-06-06 22:52:21 +02:00
Charles GTE e1e4d67114 feat: backup and restore logs modal view 2026-06-06 22:46:36 +02:00
Charles GTE 1e3f112d9c Merge branch 'main' into dev 2026-06-05 21:32:28 +02:00
github-actions[bot] e3600949b0 chore: release 1.17.4 2026-06-04 07:09:53 +00:00
Charles GTEandGitHub 856c80cc20 fix: release.yml (#307) 2026-06-04 09:09:06 +02:00
Charles GTE 8c77796f91 Merge branch 'main' into dev 2026-06-04 08:58:07 +02:00
github-actions[bot] dbeaaa4bac chore: release 1.17.3 2026-06-04 06:44:52 +00:00
charles-gauthereau f9986e1b7f Merge branch 'main' into dev 2026-06-02 21:21:06 +02:00
github-actions[bot] 895008ba1d chore: release 1.17.2 2026-06-02 16:37:29 +00:00
Charles GTEandGitHub 09791ae86e fix: demo-banner (#305) 2026-06-02 18:36:35 +02:00
github-actions[bot] 564dee512f chore: release 1.17.1 2026-06-02 07:18:07 +00:00
Charles GTEandGitHub 56a9922057 fix: refactoring the api keys section and add documentation link. (#304) 2026-06-02 09:17:21 +02:00
github-actions[bot] 44f0112620 chore: release 1.17.0 2026-06-01 21:07:33 +00:00
Charles GTEandGitHub 2d7a5ce264 feat: add mcp server (#303)
* feat: added the mcp server

* feat: added the mcp server

* fix: api for database not linked to any projects

* fix: added variable to disable MCP

* chore: refactoring
2026-06-01 23:06:21 +02:00
charles-gauthereau 48f8d00d2e Merge branch 'main' into dev 2026-05-29 18:29:03 +02:00
github-actions[bot] 7afcf0f76b chore: release 1.16.2 2026-05-29 06:23:23 +00:00
a7b28ce84d fix: Discord Notification (#302)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-05-29 08:22:41 +02:00
Charles GTE 8db995010f Merge branch 'main' into dev 2026-05-24 17:13:16 +02:00
Charles GTE 41d8b2c375 Merge branch 'main' into dev 2026-05-23 18:42:07 +02:00
Charles GTE 3b8fb93fb2 Merge branch 'main' into dev 2026-05-21 19:28:57 +02:00
killian-larcher 55be9374e1 Revert "docs: add OIDC E2E design spec"
This reverts commit b28bb44f97.
2026-05-20 21:45:46 +02:00
killian-larcher ce1bade773 Merge remote-tracking branch 'origin/dev' into dev 2026-05-17 10:46:53 +02:00
Charles GTE 9fe046617c fix: logger.ts 2026-05-17 10:34:54 +02:00
killian-larcher 01763a6b36 chore: ignore local worktrees 2026-05-17 10:31:15 +02:00
killian-larcher b28bb44f97 docs: add OIDC E2E design spec 2026-05-17 10:23:41 +02:00
Charles GTE 411d0b4565 Merge branch 'main' into dev 2026-05-17 10:04:10 +02:00
Charles GTE 16697c442c Merge branch 'main' into dev 2026-05-15 23:03:57 +02:00
Charles GTE a3b7af3ba5 Merge branch 'main' into dev 2026-04-29 22:41:02 +02:00
Charles GTE 33492b8f3a Merge branch 'main' into dev 2026-04-07 18:40:51 +02:00
Charles GTE 150ab83f73 Merge branch 'main' into dev 2026-03-30 17:51:28 +02:00
Charles GTE 35db7a4416 fix: trusted url error due to better auth update. 2026-03-29 14:08:20 +02:00
Charles GTE 1ce93af144 fix: refactoring logs and adding pino logger for production and dev. 2026-03-29 11:41:39 +02:00
Charles GTE f410a8fdd5 Merge branch 'main' into dev 2026-03-29 11:23:38 +02:00
Charles GTE 88914169bc Merge branch 'main' into dev 2026-03-29 09:25:00 +02:00
Charles GTE 8d51bcb74d Merge branch 'main' into dev 2026-03-28 19:01:31 +01:00
Charles GTE 5a87529934 fix: refactoring 2026-03-28 16:55:31 +01:00
Charles GTE 0d7a41996e chore: package.json 2026-03-21 08:39:29 +01:00
killianlarcher dc97de03d0 Merge branch 'main' into dev 2026-03-18 19:34:44 +01:00
charlesgauthereau 5d4a1248e4 fix: S3ChannelConfigSchema type port mismatch 2026-03-16 08:54:13 +01:00
charlesgauthereau f876d8eb09 Merge branch 'main' into dev 2026-03-16 07:59:39 +01:00
charlesgauthereau 1d1d92a5cf fix: release.yml 2026-03-08 21:57:13 +01:00
51 changed files with 15402 additions and 1863 deletions
+2 -1
View File
@@ -76,4 +76,5 @@ RETENTION_CRON="* * * * *"
TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056"
#OPENAPI_ENABLED=true
#API_ENABLED=true
#API_ENABLED=true
#MCP_ENABLED=true
+55 -19
View File
@@ -24,50 +24,86 @@ on:
jobs:
notify-discord:
runs-on: ubuntu-latest
steps:
- name: Send Discord Notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag }}
DISCORD_TITLE: ${{ inputs.discord_title }}
DISCORD_COLOR: ${{ inputs.discord_color }}
DISCORD_FOOTER: ${{ inputs.discord_footer }}
run: |
RELEASE_INFO=$(gh release view "${{ inputs.release_tag }}" -R ${{ github.repository }} --json name,url,body,author)
set -euo pipefail
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ inputs.release_tag }}"; fi
RELEASE_INFO=$(gh release view "$RELEASE_TAG" -R "${{ github.repository }}" --json name,url,body,author)
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r '.name // empty')
if [ -z "$RELEASE_TITLE" ]; then
RELEASE_TITLE="$RELEASE_TAG"
fi
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r '.url // empty')
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r '.body // ""')
AUTHOR_NAME="Portabase"
AUTHOR_ICON="https://github.com/Portabase.png"
PAYLOAD=$(jq -n \
jq -n \
--arg title "$RELEASE_TITLE" \
--arg description "$RELEASE_BODY" \
--arg url "$RELEASE_URL" \
--arg author "$AUTHOR_NAME" \
--arg icon "$AUTHOR_ICON" \
--arg discord_title "${{ inputs.discord_title }}" \
--arg discord_footer "${{ inputs.discord_footer }}" \
--argjson discord_color ${{ inputs.discord_color }} \
--arg discord_title "$DISCORD_TITLE" \
--arg discord_footer "$DISCORD_FOOTER" \
--argjson discord_color "$DISCORD_COLOR" \
'{
content: $discord_title,
content: (
if ($discord_title | length) > 1900
then ($discord_title[0:1900] + "...")
else $discord_title
end
),
embeds: [{
title: $title,
title: (
if ($title | length) > 256
then ($title[0:253] + "...")
else $title
end
),
url: $url,
description: $description,
description: (
if ($description | length) > 3800
then ($description[0:3800] + "\n\n...")
else $description
end
),
color: $discord_color,
author: {
name: $author,
name: (
if ($author | length) > 256
then ($author[0:253] + "...")
else $author
end
),
icon_url: $icon
},
footer: {
text: $discord_footer
text: (
if ($discord_footer | length) > 512
then ($discord_footer[0:509] + "...")
else $discord_footer
end
)
}
}]
}'
)
}' > payload.json
curl -H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$DISCORD_WEBHOOK"
cat payload.json | jq .
curl --fail-with-body -sS \
-H "Content-Type: application/json" \
-d @payload.json \
"$DISCORD_WEBHOOK?wait=true"
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
create-release:
needs: check-skip
if: ${{ needs.check-skip.outputs.skip == 'false' }}
if: ${{ needs.check-skip.outputs.skip == 'false' && github.event.pull_request.merged == true }}
runs-on: ubuntu-latest
outputs:
draft_tag: ${{ steps.release_step.outputs.draft_tag }}
+1
View File
@@ -59,3 +59,4 @@ certificates
.claude
.codex
/docs
.worktrees
+1 -1
View File
@@ -33,5 +33,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.16.1
version: 1.18.0
date-released: '2026-03-02'
@@ -9,6 +9,7 @@ import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {BackupModalProvider} from "@/features/database/backup-modal-context";
import {DatabaseContent} from "@/features/database/database-content";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
import {LogsModalProvider} from "@/features/logs/logs-modal-context";
export default async function RoutePage(props: PageParams<{
projectId: string;
@@ -34,7 +35,7 @@ export default async function RoutePage(props: PageParams<{
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
storagePolicies: true,
}
});
@@ -50,13 +51,17 @@ export default async function RoutePage(props: PageParams<{
with: {
storageChannel: true
}
}
},
logs: true
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
});
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)],
});
@@ -84,7 +89,7 @@ export default async function RoutePage(props: PageParams<{
notFound();
}
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({ id: dbItem.id }) : []
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : []
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
@@ -93,23 +98,25 @@ export default async function RoutePage(props: PageParams<{
return (
<Page>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
<LogsModalProvider>
<BackupModalProvider>
<DatabaseContent
activeMember={activeMember}
settings={settings}
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
organizationId={organization.id}
activeOrganizationChannels={[]}
activeOrganizationStorageChannels={[]}
/>
</BackupModalProvider>
</LogsModalProvider>
</Page>
);
}
@@ -85,7 +85,9 @@ export default async function RoutePage(props: PageParams<{
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
data={proj.databases}
data={[...proj.databases].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard}
+30 -1
View File
@@ -9,6 +9,7 @@ import {eventEmitter} from "@/lib/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {EventKind} from "@/features/notifications/notifications.types";
import {logger} from "@/lib/logger";
import {JobLogEntry} from "@/features/logs/types";
const log = logger.child({module: "api/agent/backup/route"});
@@ -22,6 +23,8 @@ export type BodyPatch = {
status: "success" | "failed"
size: number
generatedId: string
logs: JobLogEntry[]
durationMs: number
}
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
@@ -30,6 +33,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
}) => {
try {
const body: BodyPost = await request.json();
const method = body.method
const database = await getDatabaseOrThrow(body.generatedId);
@@ -127,12 +131,37 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({
status: status,
fileSize: backupSize
fileSize: backupSize,
durationMs: body.durationMs
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id))
.returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: backup.id,
restorationId: null,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
+31 -5
View File
@@ -1,17 +1,20 @@
import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {db as dbClient, db} from "@/db";
import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils";
import {JobLogEntry} from "@/features/logs/types";
const log = logger.child({module: "api/agent/restore"});
export type BodyResultRestore = {
generatedId: string
status: string
logs: JobLogEntry[]
durationMs: number
}
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
@@ -60,11 +63,34 @@ export async function POST(
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
}
await db
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: body.status as RestorationStatus}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
.set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
+31
View File
@@ -0,0 +1,31 @@
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { NextResponse } from "next/server";
import { withApiKey } from "@/lib/api-v1/middleware";
import { createPortabaseMcpServer } from "@/lib/mcp/server";
import type { ApiKeyContext } from "@/lib/api-v1/types";
import { env } from "@/env.mjs";
/**
* MCP endpoint — Streamable HTTP transport, stateless mode.
*
* Auth is handled by withApiKey before MCP is ever touched.
* The validated key is forwarded by MCP tools to downstream /api/v1 REST calls.
*/
const apiEnabled = env.API_ENABLED;
const mcpEnabled = env.MCP_ENABLED;
export const POST = apiEnabled && mcpEnabled
? withApiKey(async (req: Request, ctx: ApiKeyContext) => {
const apiKey = req.headers.get("x-api-key")!;
const server = createPortabaseMcpServer(ctx, apiKey);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
return transport.handleRequest(req);
})
: () => NextResponse.json({ error: "Not found" }, { status: 404 });
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.16.1",
"version": "1.18.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -21,6 +21,7 @@
"@better-auth/passkey": "1.6.11",
"@better-auth/sso": "1.6.11",
"@hookform/resolvers": "^5.4.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -138,5 +139,5 @@
"typescript": "^5.9.3",
"zenstack": "2.14.2"
},
"packageManager": "pnpm@11.3.0"
"packageManager": "pnpm@11.5.2+sha512.71c631e382066efc25625d5cf029075de07b61b37f6e27350fbd84b1bda5864c8c1967adc280776b45c30a715c0359a3be08fef42d5bb09e2b99029979692916"
}
+2206 -1343
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -43,7 +43,7 @@ export async function proxy(request: NextRequest) {
if (url.pathname.startsWith("/api")) {
if (url.pathname.startsWith("/api/v1")) {
const apiEnabled = String(env.API_ENABLED) === "true";
const apiEnabled = env.API_ENABLED;
if (!apiEnabled) {
return new NextResponse(
JSON.stringify({
@@ -53,7 +53,7 @@ export async function proxy(request: NextRequest) {
{ status: 404, headers: { "Content-Type": "application/json" } },
);
}
const openapiEnabled = String(env.OPENAPI_ENABLED) === "true";
const openapiEnabled = env.OPENAPI_ENABLED;
if (
!openapiEnabled &&
(url.pathname.startsWith("/api/v1/docs") ||
@@ -105,6 +105,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/,
// v1 external API
/^\/api\/v1\/mcp\/?$/,
/^\/api\/v1\/docs\/?$/,
/^\/api\/v1\/openapi\/?$/,
/^\/api\/v1\/agents\/?$/,
+3 -1
View File
@@ -17,6 +17,7 @@ import * as storagePolicy from "@/db/schema/13_storage-policy";
import * as backupStorage from "@/db/schema/14_storage-backup";
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
import * as apiKey from "@/db/schema/16_apikey";
import * as jobLog from "@/db/schema/17_job-log";
const log = logger.child({module: "db"});
@@ -53,7 +54,8 @@ export const schemas = {
...storagePolicy,
...backupStorage,
...healthcheckLog,
...apiKey
...apiKey,
...jobLog
};
export const db = drizzle({
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE "job_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"backup_id" uuid,
"restoration_id" uuid,
"updated_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "job_log" ADD CONSTRAINT "job_log_backup_id_backups_id_fk" FOREIGN KEY ("backup_id") REFERENCES "public"."backups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "job_log" ADD CONSTRAINT "job_log_restoration_id_restorations_id_fk" FOREIGN KEY ("restoration_id") REFERENCES "public"."restorations"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,10 @@
CREATE TYPE "public"."job_log_entry_type" AS ENUM('log', 'command');--> statement-breakpoint
CREATE TYPE "public"."job_log_level" AS ENUM('debug', 'info', 'warn', 'error');--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "logged_at" timestamp with time zone NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "entry_type" "job_log_entry_type" NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "level" "job_log_level" NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "message" text NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "command" text;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "output" text;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "exit_code" integer;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "duration_ms" bigint;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "backups" ADD COLUMN "duration_ms" bigint;
@@ -0,0 +1 @@
ALTER TABLE "restorations" ADD COLUMN "duration_ms" bigint;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+28
View File
@@ -407,6 +407,34 @@
"when": 1779698258492,
"tag": "0057_cooing_nocturne",
"breakpoints": true
},
{
"idx": 58,
"version": "7",
"when": 1780769342681,
"tag": "0058_slim_annihilus",
"breakpoints": true
},
{
"idx": 59,
"version": "7",
"when": 1780770010009,
"tag": "0059_past_fabian_cortez",
"breakpoints": true
},
{
"idx": 60,
"version": "7",
"when": 1780771191196,
"tag": "0060_shiny_sersi",
"breakpoints": true
},
{
"idx": 61,
"version": "7",
"when": 1780822414802,
"tag": "0061_illegal_mole_man",
"breakpoints": true
}
]
}
+13 -2
View File
@@ -9,6 +9,7 @@ import {timestamps} from "@/db/schema/00_common";
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy";
import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup";
import {JobLog, jobLog} from "@/db/schema/17_job-log";
export const database = pgTable("databases", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -38,6 +39,7 @@ export const backup = pgTable(
status: statusEnum("status").default("waiting").notNull(),
file: text("file"),
fileSize: bigint("file_size", { mode: "number" }),
durationMs: bigint("duration_ms", { mode: "number" }),
databaseId: uuid("database_id")
.notNull()
.references(() => database.id, {onDelete: "cascade"}),
@@ -66,7 +68,7 @@ export const retentionPolicy = pgTable("retention_policies", {
export const restoration = pgTable("restorations", {
id: uuid("id").primaryKey().defaultRandom(),
status: statusEnum("status").default("waiting").notNull(),
durationMs: bigint("duration_ms", { mode: "number" }),
backupStorageId: uuid("backup_storage_id")
.references(() => backupStorage.id, {onDelete: "cascade"}),
backupId: uuid("backup_id")
@@ -94,12 +96,15 @@ export const backupRelations = relations(backup, ({one, many}) => ({
database: one(database, {fields: [backup.databaseId], references: [database.id]}),
restorations: many(restoration),
storages: many(backupStorage),
logs: many(jobLog),
}));
export const restorationRelations = relations(restoration, ({one}) => ({
export const restorationRelations = relations(restoration, ({one, many}) => ({
backup: one(backup, {fields: [restoration.backupId], references: [backup.id]}),
database: one(database, {fields: [restoration.databaseId], references: [database.id]}),
backupStorage: one(backupStorage, {fields: [restoration.backupStorageId], references: [backupStorage.id]}),
logs: many(jobLog),
}));
@@ -137,6 +142,12 @@ export type DatabaseWith = Database & {
export type BackupWith = Backup & {
restorations?: Restoration[] | null;
storages?: BackupStorage[] | null;
logs?: JobLog[] | null;
};
export type RestorationWith = Restoration & {
logs?: JobLog[] | null;
};
+66
View File
@@ -0,0 +1,66 @@
import {pgTable, uuid, text, integer, pgEnum, bigint, timestamp} from "drizzle-orm/pg-core";
import {timestamps} from "@/db/schema/00_common";
import {backup, restoration} from "@/db/schema/07_database";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {relations} from "drizzle-orm";
export const jobLogLevelEnum = pgEnum("job_log_level", [
"debug",
"info",
"warn",
"error",
]);
export const jobLogEntryTypeEnum = pgEnum("job_log_entry_type", [
"log",
"command",
]);
export const jobLog = pgTable(
"job_log",
{
id: uuid("id").primaryKey().defaultRandom(),
backupId: uuid("backup_id").references(() => backup.id, {
onDelete: "cascade",
}),
restorationId: uuid("restoration_id").references(() => restoration.id, {
onDelete: "cascade",
}),
loggedAt: timestamp("logged_at", {withTimezone: true}).notNull(),
entryType: jobLogEntryTypeEnum("entry_type").notNull(),
level: jobLogLevelEnum("level").notNull(),
message: text("message").notNull(),
command: text("command"),
output: text("output"),
exitCode: integer("exit_code"),
durationMs: bigint("duration_ms", {mode: "number"}),
...timestamps,
},
);
export const jobLogRelations = relations(jobLog, ({one}) => ({
backup: one(backup, {
fields: [jobLog.backupId],
references: [backup.id],
}),
restoration: one(restoration, {
fields: [jobLog.restorationId],
references: [restoration.id],
}),
}));
export const jobLogSchema = createSelectSchema(jobLog);
export type JobLog = z.infer<typeof jobLogSchema>;
+20 -6
View File
@@ -36,8 +36,8 @@ export const env = createEnv({
SMTP_SECURE: z
.enum(["true", "false"])
.transform((val) => val === "true")
.default("true"),
.default("true")
.transform((val) => val === "true"),
AUTH_GOOGLE_ID: z.string().optional(),
AUTH_GOOGLE_SECRET: z.string().optional(),
@@ -101,13 +101,23 @@ export const env = createEnv({
OPENAPI_ENABLED: z
.enum(["true", "false"])
.transform((val) => val === "true")
.default("false"),
.default("false")
.transform((val) => val === "true"),
API_ENABLED: z
.enum(["true", "false"])
.transform((val) => val === "true")
.default("false"),
.default("false")
.transform((val) => val === "true"),
MCP_ENABLED: z
.enum(["true", "false"])
.default("false")
.transform((val) => val === "true"),
DEMO_ENABLED: z
.enum(["true", "false"])
.default("false")
.transform((val) => val === "true"),
},
client: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
@@ -183,5 +193,9 @@ export const env = createEnv({
OPENAPI_ENABLED: process.env.OPENAPI_ENABLED,
API_ENABLED: process.env.API_ENABLED,
MCP_ENABLED: process.env.MCP_ENABLED,
DEMO_ENABLED: process.env.DEMO_ENABLED,
},
});
+3 -1
View File
@@ -117,7 +117,9 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
<CardsWithPagination
cardsPerPage={4}
numberOfColumns={2}
data={agent.databases}
data={[...agent.databases].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
cardItem={AgentDatabaseCard}
/>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
import {AgentSchema} from "@/features/agents/agents.schema";
import {z} from "zod";
import {eq, and, ne, count} from "drizzle-orm";
import {eq, and, ne, count, desc} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {slugify} from "@/utils/slugify";
+20 -3
View File
@@ -2,23 +2,24 @@
import {ColumnDef} from "@tanstack/react-table";
import {StatusBadge} from "@/components/common/status-badge";
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {BackupWith, DatabaseWith} from "@/db/schema/07_database";
import {Setting} from "@/db/schema/01_setting";
import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {MemberWithUser} from "@/db/schema/03_organization";
import {formatLocalizedDate} from "@/utils/date-formatting";
import {formatBytes} from "@/utils/text";
import {formatBytes, formatDuration} from "@/utils/text";
import {DatabaseActionsCell} from "@/features/database/backup-actions-cell";
import { Badge as BadgeC } from "@/components/ui/badge";
import {backupOnly} from "@/features/database/database-tabs";
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
export function backupColumns(
isAlreadyRestore: boolean,
settings: Setting,
database: DatabaseWith,
activeMember: MemberWithUser
): ColumnDef<Backup>[] {
): ColumnDef<BackupWith>[] {
const isBackupOnly = backupOnly.some((type) => database.dbms === type)
@@ -85,6 +86,14 @@ export function backupColumns(
return formatBytes(row.getValue("fileSize"))
},
},
{
accessorKey: "durationMs",
header: "Duration",
cell: ({row}) => {
const durationMs = row.getValue("durationMs");
return durationMs ? formatDuration(row.getValue("durationMs")) : "-"
},
},
{
accessorKey: "createdAt",
header: "Created At",
@@ -99,6 +108,14 @@ export function backupColumns(
return <StatusBadge status={row.getValue("status")}/>;
},
},
{
accessorKey: "logs",
header: "Logs",
cell: ({row}) => {
const logs = row.original.logs ?? [];
return <LogsModalTrigger logs={logs}/>;
},
},
{
id: "actions",
cell: ({row}) => <DatabaseActionsCell isAlreadyRestore={isAlreadyRestore} activeMember={activeMember} backup={row.original} isBackupOnly={isBackupOnly}/>,
@@ -4,7 +4,7 @@ import {z} from "zod";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {BackupWith, Restoration} from "@/db/schema/07_database";
import {BackupWith, RestorationWith} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
@@ -36,15 +36,19 @@ export const getDatabaseDataAction = userAction
with: {
storageChannel: true
}
}
},
logs: true
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
}) as BackupWith[];
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)],
}) as Restoration[];
}) as RestorationWith[];
const totalBackups = backups.length;
const availableBackups = backups.filter(b => !b.deletedAt).length;
+4 -2
View File
@@ -2,7 +2,7 @@
import {DatabaseBackupActionsModal} from "@/features/database/backup-actions-modal";
import {DatabaseTabs} from "@/features/database/database-tabs";
import {Setting} from "@/db/schema/01_setting";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
import {BackupWith, DatabaseWith, RestorationWith} from "@/db/schema/07_database";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useBackupModal} from "@/features/database/backup-modal-context";
import {DatabaseKpi} from "@/features/database/database-kpi";
@@ -23,11 +23,12 @@ import {BackupButton} from "@/features/database/backup-button";
import {HealthModal} from "@/features/database/health-modal";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
import {Badge} from "@/components/ui/badge";
import {LogsModal} from "@/features/logs/logs-modal";
export type DatabaseContentProps = {
settings: Setting;
backups: BackupWith[];
restorations: Restoration[];
restorations: RestorationWith[];
isAlreadyRestore: boolean;
database: DatabaseWith;
activeMember: MemberWithUser;
@@ -156,6 +157,7 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups}
/>
<LogsModal/>
<DatabaseBackupActionsModal/>
<DatabaseTabs
activeMember={props.activeMember}
+20 -2
View File
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
import { Check, MoreHorizontal, Trash2, X } from "lucide-react";
import { ReloadIcon } from "@radix-ui/react-icons";
import { StatusBadge } from "@/components/common/status-badge";
import { Restoration } from "@/db/schema/07_database";
import {Restoration, RestorationWith} from "@/db/schema/07_database";
import { formatLocalizedDate } from "@/utils/date-formatting";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -24,11 +24,13 @@ import { toast } from "sonner";
import { TooltipCustom } from "@/components/common/tooltip-custom";
import { MemberWithUser } from "@/db/schema/03_organization";
import { ButtonWithConfirm } from "@/components/common/button-with-confirm";
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
import {formatDuration} from "@/utils/text";
export function restoreColumns(
isAlreadyRestore: boolean,
activeMember: MemberWithUser,
): ColumnDef<Restoration>[] {
): ColumnDef<RestorationWith>[] {
return [
{
accessorKey: "id",
@@ -48,6 +50,22 @@ export function restoreColumns(
return <StatusBadge status={row.getValue("status")} />;
},
},
{
accessorKey: "durationMs",
header: "Duration",
cell: ({row}) => {
const durationMs = row.getValue("durationMs");
return durationMs ? formatDuration(row.getValue("durationMs")) : "-"
},
},
{
accessorKey: "logs",
header: "Logs",
cell: ({row}) => {
const logs = row.original.logs ?? [];
return <LogsModalTrigger logs={logs}/>;
},
},
{
id: "actions",
cell: ({ row }) => {
+7 -2
View File
@@ -21,6 +21,9 @@ export const deleteRestoreAction = userAction
.where(and(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId)))
.execute();
await db
.delete(drizzleDb.schemas.jobLog)
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
return {
success: true,
@@ -50,14 +53,16 @@ export const rerunRestorationAction = userAction
)
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
try {
const updateResult = await db
const [updatedRestoration] = await db
.update(drizzleDb.schemas.restoration)
.set({status: "waiting"})
.where(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId))
.returning()
.execute();
const updatedRestoration = updateResult[0];
await db
.delete(drizzleDb.schemas.jobLog)
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
if (!updatedRestoration) {
return {
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
import { Timer } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
function getSecondsUntilNextHour(): number {
const now = new Date();
const s = 3600 - (now.getMinutes() * 60 + now.getSeconds());
return s === 3600 ? 0 : s;
}
function formatTime(seconds: number): string {
const s = Math.max(0, seconds);
const m = Math.floor(s / 60);
const sec = s % 60;
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
export function DemoResetBanner() {
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
useEffect(() => {
const tick = () => setSecondsLeft(getSecondsUntilNextHour());
const timeout = setTimeout(tick, 0);
const interval = setInterval(tick, 1000);
return () => {
clearTimeout(timeout);
clearInterval(interval);
};
}, []);
if (secondsLeft === null) return null;
const isResetting = secondsLeft === 0;
const isWarning = secondsLeft > 0 && secondsLeft <= 300;
return (
<Badge
variant="outline"
className={cn(
'hidden md:inline-flex h-7',
isResetting && 'animate-pulse border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400',
isWarning && 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-400',
)}
>
<Timer aria-hidden="true" />
{isResetting ? 'Resetting…' : `Demo resets in ${formatTime(secondsLeft)}`}
</Badge>
);
}
+4
View File
@@ -5,12 +5,15 @@ import {currentUser} from "@/lib/auth/current-user";
import {BreadCrumbsWrapper} from "@/components/common/bread-crumbs";
import GitHubStarsButtonCustom from "@/components/common/github-button";
import {LoggedInButton} from "@/features/layout/logged-in-button.server";
import { env } from "@/env.mjs";
import { DemoResetBanner } from "@/features/layout/demo-reset-banner";
export const Header = async ({ actions }: { actions?: ReactNode } = {}) => {
const user = await currentUser();
if (!user) {
return notFound();
}
const demoEnabled = env.DEMO_ENABLED;
return (
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center justify-between">
@@ -18,6 +21,7 @@ export const Header = async ({ actions }: { actions?: ReactNode } = {}) => {
<BreadCrumbsWrapper/>
</div>
<div className="flex items-center gap-2">
{demoEnabled && <DemoResetBanner />}
<GitHubStarsButtonCustom/>
{actions}
<LoggedInButton/>
+131
View File
@@ -0,0 +1,131 @@
"use client"
import { useState } from "react"
import { Minus, Plus } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { LevelType } from "@/features/logs/types"
import { JobLog } from "@/db/schema/17_job-log"
import { formatLocalizedDate } from "@/utils/date-formatting"
import { formatDuration } from "@/utils/text"
const levelLabel: Record<LevelType, string> = {
info: "Info",
debug: "Debug",
error: "Error",
warn: "Warning",
}
function TypeBadge({ entry }: { entry: JobLog }) {
const isCommand = entry.entryType === "command"
const label = isCommand ? "Command" : levelLabel[entry.level]
const styles = isCommand
? "border-transparent bg-secondary text-secondary-foreground"
: entry.level === "error"
? "border-transparent bg-destructive/20 text-destructive"
: entry.level === "warn"
? "border-transparent bg-amber-500/20 text-amber-600 dark:text-amber-300"
: entry.level === "debug"
? "border-transparent bg-muted text-muted-foreground"
: "border-transparent bg-sky-500/20 text-sky-600 dark:text-sky-300"
return (
<Badge variant="outline" className={cn("rounded-full", styles)}>
{label}
</Badge>
)
}
export function LogRow({ entry }: { entry: JobLog }) {
const [open, setOpen] = useState(true)
const isCommand = entry.entryType === "command"
const date = formatLocalizedDate(entry.loggedAt)
const accent =
entry.level === "error"
? "bg-destructive"
: entry.level === "warn"
? "bg-amber-500"
: isCommand
? "bg-emerald-500"
: "bg-sky-500"
return (
<div className="relative border-b border-border last:border-b-0">
<div className={cn("absolute left-0 top-0 h-full w-[3px]", accent)} aria-hidden="true" />
<div className="flex flex-col gap-3 py-4 pl-6 pr-4 sm:flex-row sm:items-start sm:gap-4 sm:pr-6">
<span className="shrink-0 font-mono text-sm text-muted-foreground sm:w-[130px] sm:pt-0.5">
{date}
</span>
<span className="shrink-0 sm:w-[90px] sm:pt-0.5">
<TypeBadge entry={entry} />
</span>
<div className="min-w-0 flex-1">
{isCommand ? (
<div className="flex items-start gap-3">
<code className="block flex-1 truncate rounded-md bg-muted px-3 py-1.5 font-mono text-sm text-foreground">
<span className="text-emerald-600 dark:text-emerald-400">$ </span>
{entry.message}
</code>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={open ? "Collapse command output" : "Expand command output"}
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{open ? <Minus className="size-4" /> : <Plus className="size-4" />}
</button>
</div>
) : (
<p className="pt-0.5 text-sm text-foreground">{entry.message}</p>
)}
{isCommand && entry.command && open && (
<div className="mt-4">
<div className="overflow-hidden rounded-xl bg-muted ring-1 ring-border">
<div className="max-h-[15rem] overflow-auto px-4 py-3.5">
<code className="block whitespace-pre font-mono text-sm leading-relaxed text-foreground">
<span className="text-emerald-600 dark:text-emerald-400">$ </span>
{entry.command}
</code>
{entry.output ? (
<pre className="mt-3 whitespace-pre font-mono text-sm leading-relaxed text-muted-foreground">
{entry.output}
</pre>
) : null}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm sm:gap-6">
{entry.exitCode !== undefined && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Exit code:</span>
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold",
entry.exitCode === 0
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-300"
: "bg-destructive/20 text-destructive",
)}
>
{entry.exitCode}
</span>
</div>
)}
{entry.durationMs !== null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Duration:</span>
<span className="font-medium text-foreground">{formatDuration(entry.durationMs)}</span>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import {createContext, useContext, useState, ReactNode} from "react";
import {JobLog} from "@/db/schema/17_job-log";
type LogsModalContextType = {
open: boolean;
logs: JobLog[];
openModal: (logs: JobLog[]) => void;
closeModal: () => void;
};
const LogsModalContext = createContext<LogsModalContextType | undefined>(undefined);
export const LogsModalProvider = ({children}: { children: ReactNode }) => {
const [open, setOpen] = useState(false);
const [logs, setLogs] = useState<JobLog[]>([]);
const openModal = (newLogs: JobLog[]) => {
setLogs(newLogs);
setOpen(true);
};
const closeModal = () => {
setOpen(false);
setLogs([]);
};
return (
<LogsModalContext.Provider value={{open, logs, openModal, closeModal}}>
{children}
</LogsModalContext.Provider>
);
};
export const useLogsModal = () => {
const context = useContext(LogsModalContext);
if (!context) throw new Error("useLogsModal must be used within LogsModalProvider");
return context;
};
+19
View File
@@ -0,0 +1,19 @@
import {FileText} from "lucide-react";
import {JobLog} from "@/db/schema/17_job-log";
import {Button} from "@/components/ui/button";
import {useLogsModal} from "@/features/logs/logs-modal-context";
export type LogsModalTriggerProps = {
logs: JobLog[]
}
export const LogsModalTrigger = ({logs}: LogsModalTriggerProps) => {
const {openModal} = useLogsModal();
return (
<Button disabled={logs.length == 0} variant="outline" size="sm" onClick={()=> {
openModal(logs);
}}>
<FileText />
</Button>
)
}
+41
View File
@@ -0,0 +1,41 @@
"use client"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useLogsModal } from "@/features/logs/logs-modal-context"
import { JobLog } from "@/db/schema/17_job-log"
import { LogRow } from "@/features/logs/log-row-modal"
export const LogsModal = () => {
const { open, logs, closeModal } = useLogsModal()
return (
<Dialog open={open} onOpenChange={closeModal}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden border-border p-0 sm:max-w-6xl">
<DialogHeader className="shrink-0 border-b border-border px-6 py-5">
<DialogTitle className="text-xl font-bold tracking-tight text-foreground">
Job Logs
</DialogTitle>
</DialogHeader>
<div className="hidden shrink-0 items-center gap-4 border-b border-border bg-card py-3 pl-6 pr-4 sm:flex">
<span className="w-[130px] text-sm font-semibold text-foreground">Date</span>
<span className="w-[90px] text-sm font-semibold text-foreground">Type</span>
<span className="flex-1 text-sm font-semibold text-foreground">Message</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div>
{logs.map((entry: JobLog) => (
<LogRow key={entry.id} entry={entry} />
))}
</div>
</div>
</DialogContent>
</Dialog>
)
}
+14
View File
@@ -0,0 +1,14 @@
export type LevelType = "info" | "debug" | "error" | "warn";
export type EntryType = "log" | "command";
export interface JobLogEntry {
timestamp: string
type: EntryType
level: LevelType
message: string
command?: string
output?: string
exit_code?: number
duration_ms?: number
}
+71 -432
View File
@@ -2,16 +2,8 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
AlertCircle,
Check,
Copy,
KeyRound,
Loader2,
Plus,
Trash2,
} from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth/auth-client";
@@ -27,24 +19,7 @@ import {
} from "@/components/ui/form";
import { EmailSchema, EmailSchemaType } from "./account.schema";
import { BetterAuthError } from "@/types/auth";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import { timeAgo } from "@/utils/date-formatting";
import {
createApiKeysAction,
deleteApiKeyAction,
getApiKeysAction,
} from "@/features/profile/profile.action";
import { copyToClipboardWithMeta } from "@/components/common/copy-button";
import { ProfileApiKeys } from "./profile-api-keys";
interface ProfileAccountProps {
user: User;
@@ -52,15 +27,8 @@ interface ProfileAccountProps {
}
export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
const router = useRouter();
const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false);
const [apiKeyName, setApiKeyName] = useState("");
const [createdApiKey, setCreatedApiKey] = useState<string | null>(null);
const [copiedApiKey, setCopiedApiKey] = useState(false);
const emailForm = useZodForm({
schema: EmailSchema,
defaultValues: {
@@ -74,31 +42,21 @@ export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
newEmail: values.email,
callbackURL: window.location.href,
});
if (error) throw error;
return values.email;
},
onSuccess: (newEmail) => {
toast.success("Email updated successfully.");
emailForm.reset({ email: newEmail });
router.refresh();
},
onError: (error: BetterAuthError) => {
if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
toast.error(
"User already exists, use another email address!",
);
toast.error("User already exists, use another email address!");
emailForm.reset({ email: user.email });
router.refresh();
} else {
toast.error(
"An error occurred while trying to update your password!",
);
toast.error("An error occurred while trying to update your password!");
}
},
});
@@ -109,23 +67,19 @@ export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
} = useMutation({
mutationFn: async () => {
const currentEmailInput = emailForm.getValues("email");
let error: BetterAuthError | null = null;
if (currentEmailInput === user.email) {
const sendVerification =
await authClient.sendVerificationEmail({
email: currentEmailInput,
callbackURL: window.location.href,
});
const sendVerification = await authClient.sendVerificationEmail({
email: currentEmailInput,
callbackURL: window.location.href,
});
error = sendVerification.error;
} else {
const result = await authClient.changeEmail({
callbackURL: window.location.href,
newEmail: currentEmailInput,
});
error = result.error;
}
@@ -139,405 +93,90 @@ export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
},
});
const {
data: apikeys,
isLoading: isLoadingApiKeys,
refetch: refetchApiKeys,
} = useQuery({
queryKey: ["apikeys"],
queryFn: async () => {
const result = await getApiKeysAction();
if (result?.data?.success) {
return result.data.value;
}
throw new Error("Failed to fetch API Keys");
},
});
const { mutate: addApiKey, isPending: isAddingApikey } = useMutation({
mutationFn: async () => {
const result = await createApiKeysAction({
name: apiKeyName || "My API Key"
});
console.log(result);
if (!result?.data?.success) {
throw new Error("Failed to create API Key");
}
return result;
},
onSuccess: (result: any) => {
setCreatedApiKey(result.data.value.key);
toast.success("API Key created successfully");
setIsAddApiKeyOpen(false);
setApiKeyName("");
refetchApiKeys();
},
onError: (error: any) => {
toast.error(error.message || "Failed to create API Key");
},
});
const { mutate: revokeApiKey, isPending: isRevokingApiKey } =
useMutation({
mutationFn: async (id: string) => {
const result = await deleteApiKeyAction({ id });
if (!result?.data?.success) {
throw new Error("Failed to revoke API Key");
}
},
onSuccess: () => {
toast.success("API Key revoked successfully");
refetchApiKeys();
},
onError: () => {
toast.error("Failed to revoke API Key");
},
});
const handleCopyApiKey = async () => {
if (!createdApiKey) return;
await copyToClipboardWithMeta(createdApiKey);
setCopiedApiKey(true);
toast.success("API Key copied");
setTimeout(() => {
setCopiedApiKey(false);
}, 2000);
};
return (
<>
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
Account Settings
</h2>
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
Account Settings
</h2>
<p className="text-sm text-muted-foreground">
Update your email and preferences.
</p>
</div>
<p className="text-sm text-muted-foreground">
Update your email and preferences.
</p>
</div>
<div className="space-y-4">
<Form form={emailForm} onSubmit={(values) => updateEmail(values)}>
<div className="grid gap-3">
<FormField
control={emailForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<div className="space-y-4">
<Form
form={emailForm}
onSubmit={(values) => updateEmail(values)}
>
<div className="grid gap-3">
<FormField
control={emailForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email Address
</FormLabel>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
<FormControl>
<Input
{...field}
placeholder="Your email address"
/>
</FormControl>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
<FormControl>
<Input
{...field}
placeholder="Your email address"
/>
</FormControl>
<div className="flex flex-col md:flex-row gap-3">
<Button
type="submit"
variant="secondary"
disabled={
isUpdatingEmail ||
!emailForm.formState.isDirty
}
>
{isUpdatingEmail && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Update
</Button>
<div className="flex flex-col md:flex-row gap-3">
{!user.emailVerified && (
<Button
type="submit"
type="button"
variant="secondary"
onClick={() => resendVerificationEmail()}
disabled={
isUpdatingEmail ||
!emailForm.formState
.isDirty
isResendingVerification ||
emailForm.formState.errors.email !==
undefined
}
>
{isUpdatingEmail && (
{isResendingVerification && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Update
Resend Verification
</Button>
{!user.emailVerified && (
<Button
type="button"
variant="secondary"
onClick={() =>
resendVerificationEmail()
}
disabled={
isResendingVerification ||
emailForm
.formState
.errors
.email !==
undefined
}
>
{isResendingVerification && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Resend Verification
</Button>
)}
</div>
)}
</div>
<FormMessage />
</div>
</FormItem>
)}
/>
{!user.emailVerified && (
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 p-2 rounded-md border border-amber-100 dark:bg-amber-950/30 dark:border-amber-900 dark:text-amber-400 max-w-xl">
<AlertCircle className="w-4 h-4" />
<span>
Your email is not verified. Please check
your inbox.
</span>
</div>
)}
</div>
</Form>
</div>
{apiEnabled === true && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="text-lg font-medium">
API Keys
</h3>
<div className="text-sm text-muted-foreground">
Manage your personal access tokens for API
authentication
</div>
</div>
<Dialog
open={isAddApiKeyOpen}
onOpenChange={setIsAddApiKeyOpen}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add API Key
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
Add New API Key
</DialogTitle>
<DialogDescription>
Give a name to your API Key to identify
it later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">
Key Name
</Label>
<Input
id="name"
placeholder="e.g. flow-1, LLM, Backend"
value={apiKeyName}
onChange={(e) =>
setApiKeyName(e.target.value)
}
/>
<FormMessage />
</div>
</div>
</FormItem>
)}
/>
<DialogFooter>
<Button
variant="outline"
onClick={() =>
setIsAddApiKeyOpen(false)
}
>
Cancel
</Button>
<Button
onClick={() => addApiKey()}
disabled={isAddingApikey}
>
{isAddingApikey && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create API Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-lg divide-y">
{isLoadingApiKeys ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : apikeys && apikeys.length > 0 ? (
apikeys.map((ak: any) => (
<ApiKeyRow
key={ak.id}
apikey={ak}
onRevoke={(id) => revokeApiKey(id)}
isRevoking={isRevokingApiKey}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">
No API Key found.
{!user.emailVerified && (
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 p-2 rounded-md border border-amber-100 dark:bg-amber-950/30 dark:border-amber-900 dark:text-amber-400 max-w-xl">
<AlertCircle className="w-4 h-4" />
<span>
Your email is not verified. Please check your inbox.
</span>
</div>
)}
</div>
</div>
)}
</Form>
</div>
<Dialog
open={!!createdApiKey}
onOpenChange={(open) => {
if (!open) {
setCreatedApiKey(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Your API Key</DialogTitle>
<DialogDescription>
This API Key will only be displayed once.
<br />
Copy it now before closing this dialog.
<br />
For security reasons, it cannot be viewed again.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Input
readOnly
value={createdApiKey || ""}
className="font-mono"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyApiKey}
>
{copiedApiKey ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300">
Store this API Key securely. You will not be able
to see it again after closing this dialog.
</div>
</div>
<DialogFooter>
<Button
onClick={() => {
setCreatedApiKey(null);
}}
>
I copied my API Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function ApiKeyRow({
apikey,
onRevoke,
isRevoking,
}: {
apikey: any;
onRevoke: (id: string) => void;
isRevoking: boolean;
}) {
return (
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
<KeyRound className="w-5 h-5" />
</div>
<div className="space-y-1">
<div className="font-medium text-sm">
{apikey.name || "Unnamed API Key"}
</div>
{apikey?.start && apikey?.prefix ? (
<div className="text-xs font-mono text-muted-foreground">
{apikey.start}
</div>
) : null}
<div className="text-xs text-muted-foreground">
Created {timeAgo(new Date(apikey.createdAt))}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(apikey.id)}
disabled={isRevoking}
>
{isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span>
</Button>
{apiEnabled && <ProfileApiKeys />}
</div>
);
}
}
+282
View File
@@ -0,0 +1,282 @@
"use client";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {Check, Copy, KeyRound, Loader2, Plus, Trash2} from "lucide-react";
import {useMutation, useQuery} from "@tanstack/react-query";
import {toast} from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {Label} from "@/components/ui/label";
import {useState} from "react";
import {timeAgo} from "@/utils/date-formatting";
import {
createApiKeysAction,
deleteApiKeyAction,
getApiKeysAction,
} from "@/features/profile/profile.action";
import {copyToClipboardWithMeta} from "@/components/common/copy-button";
import Link from "next/link";
export function ProfileApiKeys() {
const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false);
const [apiKeyName, setApiKeyName] = useState("");
const [createdApiKey, setCreatedApiKey] = useState<string | null>(null);
const [copiedApiKey, setCopiedApiKey] = useState(false);
const {
data: apikeys,
isLoading: isLoadingApiKeys,
refetch: refetchApiKeys,
} = useQuery({
queryKey: ["apikeys"],
queryFn: async () => {
const result = await getApiKeysAction();
if (result?.data?.success) return result.data.value;
throw new Error("Failed to fetch API Keys");
},
});
const {mutate: addApiKey, isPending: isAddingApikey} = useMutation({
mutationFn: async () => {
const result = await createApiKeysAction({
name: apiKeyName || "My API Key",
});
if (!result?.data?.success) throw new Error("Failed to create API Key");
return result;
},
onSuccess: (result: any) => {
setCreatedApiKey(result.data.value.key);
toast.success("API Key created successfully");
setIsAddApiKeyOpen(false);
setApiKeyName("");
refetchApiKeys();
},
onError: (error: any) => {
toast.error(error.message || "Failed to create API Key");
},
});
const {mutate: revokeApiKey, isPending: isRevokingApiKey} = useMutation({
mutationFn: async (id: string) => {
const result = await deleteApiKeyAction({id});
if (!result?.data?.success) throw new Error("Failed to revoke API Key");
},
onSuccess: () => {
toast.success("API Key revoked successfully");
refetchApiKeys();
},
onError: () => {
toast.error("Failed to revoke API Key");
},
});
const handleCopyApiKey = async () => {
if (!createdApiKey) return;
await copyToClipboardWithMeta(createdApiKey);
setCopiedApiKey(true);
toast.success("API Key copied");
setTimeout(() => setCopiedApiKey(false), 2000);
};
return (
<>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="text-lg font-medium">API Keys</h3>
<div className="text-sm text-muted-foreground">
Manage your personal access tokens for API authentication.{" "}
<Link
href="https://portabase.io/docs/dashboard/api/introduction"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4 hover:text-foreground transition-colors"
>
View docs
</Link>
</div>
</div>
<Dialog open={isAddApiKeyOpen} onOpenChange={setIsAddApiKeyOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4"/>
Add API Key
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New API Key</DialogTitle>
<DialogDescription>
Give a name to your API Key to identify it later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Key Name</Label>
<Input
id="name"
placeholder="e.g. flow-1, LLM, Backend"
value={apiKeyName}
onChange={(e) => setApiKeyName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddApiKeyOpen(false)}
>
Cancel
</Button>
<Button onClick={() => addApiKey()} disabled={isAddingApikey}>
{isAddingApikey && (
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>
)}
Create API Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-lg divide-y">
{isLoadingApiKeys ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground"/>
</div>
) : apikeys && apikeys.length > 0 ? (
apikeys.map((ak: any) => (
<ApiKeyRow
key={ak.id}
apikey={ak}
onRevoke={(id) => revokeApiKey(id)}
isRevoking={isRevokingApiKey}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">
No API Key found.
</div>
)}
</div>
</div>
<Dialog
open={!!createdApiKey}
onOpenChange={(open) => {
if (!open) setCreatedApiKey(null);
}}
>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Your API Key</DialogTitle>
<DialogDescription>
This API Key will only be displayed once.
<br/>
Copy it now before closing this dialog.
<br/>
For security reasons, it cannot be viewed again.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Input
readOnly
value={createdApiKey || ""}
className="font-mono"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyApiKey}
>
{copiedApiKey ? (
<Check className="w-4 h-4"/>
) : (
<Copy className="w-4 h-4"/>
)}
</Button>
</div>
<div
className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300">
Store this API Key securely. You will not be able to see it
again after closing this dialog.
</div>
</div>
<DialogFooter>
<Button onClick={() => setCreatedApiKey(null)}>
I copied my API Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function ApiKeyRow({
apikey,
onRevoke,
isRevoking,
}: {
apikey: any;
onRevoke: (id: string) => void;
isRevoking: boolean;
}) {
return (
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
<KeyRound className="w-5 h-5"/>
</div>
<div className="space-y-1">
<div className="font-medium text-sm">
{apikey.name || "Unnamed API Key"}
</div>
{apikey?.start && apikey?.prefix ? (
<div className="text-xs font-mono text-muted-foreground">
{apikey.start}
</div>
) : null}
<div className="text-xs text-muted-foreground">
Created {timeAgo(new Date(apikey.createdAt))}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(apikey.id)}
disabled={isRevoking}
>
{isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin"/>
) : (
<Trash2 className="w-4 h-4"/>
)}
<span className="sr-only">Revoke</span>
</Button>
</div>
);
}
+36 -11
View File
@@ -66,6 +66,7 @@ export async function requireDatabaseAccess(
const access = await resolveDatabaseAccess(id, user);
if (access === "ok") {
return {
ok: true,
@@ -80,31 +81,55 @@ export async function requireDatabaseAccess(
};
}
if (access === "no_project_link") {
return {
ok: false,
response: jsonError("Database is not linked to any project", 403),
};
}
return {
ok: false,
response: jsonError("Not found", 404),
};
}
export type DatabaseAccessResult = "ok" | "forbidden" | "not_found";
export type DatabaseAccessResult =
| "ok"
| "forbidden"
| "not_found"
| "no_project_link";
export async function resolveDatabaseAccess(
id: string,
user: ApiKeyContext["user"]
): Promise<DatabaseAccessResult> {
const accessibleIds = await getAccessibleDatabaseIds(user);
const [accessibleIds, database] = await Promise.all([
getAccessibleDatabaseIds(user),
db.query.database.findFirst({
where: and(
eq(drizzleDb.schemas.database.id, id),
isNull(drizzleDb.schemas.database.deletedAt)
),
columns: {
id: true,
projectId: true,
},
}),
]);
if (!database) {
return "not_found";
}
if (database.projectId === null) {
return "no_project_link";
}
if (accessibleIds.includes(id)) {
return "ok";
}
const exists = await db.query.database.findFirst({
where: and(
eq(drizzleDb.schemas.database.id, id),
isNull(drizzleDb.schemas.database.deletedAt)
),
columns: { id: true },
});
return exists ? "forbidden" : "not_found";
return "forbidden";
}
+63
View File
@@ -0,0 +1,63 @@
import { getServerUrl } from "@/utils/get-server-url";
export type ApiV1Result =
| { ok: true; status: number; data: unknown }
| { ok: false; status: number; error: string };
/**
* Thin fetch wrapper that proxies calls to the existing /api/v1 REST layer.
* Forwards the caller's API key so the REST route runs its own auth + permission checks.
*/
export async function apiV1Fetch(
path: string,
options: RequestInit,
apiKey: string
): Promise<ApiV1Result> {
const url = `${getServerUrl()}${path}`;
let res: Response;
try {
res = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
"x-api-key": apiKey,
},
});
} catch {
return { ok: false, status: 0, error: "Failed to reach internal API" };
}
// 204 No Content has no body
if (res.status === 204) {
return { ok: true, status: 204, data: null };
}
let body: unknown;
try {
body = await res.json();
} catch {
body = null;
}
if (!res.ok) {
const message =
typeof body === "object" &&
body !== null &&
"error" in body &&
typeof (body as Record<string, unknown>).error === "string"
? (body as { error: string }).error
: `Request failed with status ${res.status}`;
return { ok: false, status: res.status, error: message };
}
const data =
typeof body === "object" &&
body !== null &&
"data" in body
? (body as { data: unknown }).data
: body;
return { ok: true, status: res.status, data };
}
+29
View File
@@ -0,0 +1,29 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { ApiKeyContext } from "@/lib/api-v1/types";
import { registerAgentTools } from "./tools/agents";
import { registerDatabaseTools } from "./tools/databases";
import { registerBackupTools } from "./tools/backups";
/**
* Creates a configured McpServer for a single request.
* A new instance is created per request (stateless transport pattern).
*
* @param _ctx - The validated API key context (user + org memberships). Reserved for
* future tools that need server-side context beyond forwarded HTTP calls.
* @param apiKey - The raw API key forwarded to all /api/v1 REST calls.
*/
export function createPortabaseMcpServer(
_ctx: ApiKeyContext,
apiKey: string
): McpServer {
const server = new McpServer({
name: "portabase",
version: process.env.npm_package_version ?? "1.0.0",
});
registerAgentTools(server, apiKey);
registerDatabaseTools(server, apiKey);
registerBackupTools(server, apiKey);
return server;
}
+70
View File
@@ -0,0 +1,70 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {apiV1Fetch} from "@/lib/mcp/http-client";
import {err, ok} from "@/lib/mcp/tools/response";
export function registerAgentTools(server: McpServer, apiKey: string) {
server.tool(
"list_agents",
"List all agents accessible to the authenticated user",
{},
async () => {
const result = await apiV1Fetch("/api/v1/agents", { method: "GET" }, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"get_agent",
"Get details for a specific agent, including its associated databases",
{ id: z.string().describe("Agent ID") },
async ({ id }) => {
const result = await apiV1Fetch(`/api/v1/agents/${id}`, { method: "GET" }, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"create_agent",
"Create a new agent, optionally scoped to an organization",
{
name: z.string().min(1).describe("Agent name"),
organizationId: z
.string()
.uuid()
.optional()
.describe("Organization ID to scope the agent to (optional)"),
},
async ({ name, organizationId }) => {
const result = await apiV1Fetch(
"/api/v1/agents",
{ method: "POST", body: JSON.stringify({ name, organizationId }) },
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"delete_agent",
"Delete an agent by ID",
{ id: z.string().describe("Agent ID") },
async ({ id }) => {
const result = await apiV1Fetch(`/api/v1/agents/${id}`, { method: "DELETE" }, apiKey);
return result.ok
? ok({ message: `Agent ${id} deleted successfully` })
: err(result.error);
}
);
server.tool(
"get_agent_key",
"Get the edge key for an agent (used by the agent to authenticate with Portabase)",
{ id: z.string().describe("Agent ID") },
async ({ id }) => {
const result = await apiV1Fetch(`/api/v1/agents/${id}/key`, { method: "GET" }, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
}
+75
View File
@@ -0,0 +1,75 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {err, ok} from "@/lib/mcp/tools/response";
import {apiV1Fetch} from "@/lib/mcp/http-client";
export function registerBackupTools(server: McpServer, apiKey: string) {
server.tool(
"list_backups",
"List all backups for a specific database, ordered by most recent first",
{ databaseId: z.string().describe("Database ID") },
async ({ databaseId }) => {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup`,
{ method: "GET" },
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"get_backup",
"Get details for a specific backup, including its storage locations",
{
databaseId: z.string().describe("Database ID"),
backupId: z.string().describe("Backup ID"),
},
async ({ databaseId, backupId }) => {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup/${backupId}`,
{ method: "GET" },
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"trigger_backup",
"Trigger an immediate backup for a database. Returns 409 if a backup is already running.",
{ databaseId: z.string().describe("Database ID") },
async ({ databaseId }) => {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/backup`,
{ method: "POST", body: JSON.stringify({}) },
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"trigger_restore",
"Trigger a database restore from a specific backup storage. Use get_backup to find available backupStorageId values. Returns 409 if a restore is already running.",
{
databaseId: z.string().describe("Database ID"),
backupId: z.string().uuid().describe("Backup ID"),
backupStorageId: z
.string()
.uuid()
.describe("Backup storage ID (from get_backup storages list)"),
},
async ({ databaseId, backupId, backupStorageId }) => {
const result = await apiV1Fetch(
`/api/v1/databases/${databaseId}/restore`,
{
method: "POST",
body: JSON.stringify({ backupId, backupStorageId }),
},
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
}
+41
View File
@@ -0,0 +1,41 @@
import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js";
import {z} from "zod";
import {err, ok} from "@/lib/mcp/tools/response";
import {apiV1Fetch} from "@/lib/mcp/http-client";
export function registerDatabaseTools(server: McpServer, apiKey: string) {
server.tool(
"list_databases",
"List all databases accessible to the authenticated user",
{},
async () => {
const result = await apiV1Fetch("/api/v1/databases", {method: "GET"}, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"get_database",
"Get details for a specific database",
{id: z.string().describe("Database ID")},
async ({id}) => {
const result = await apiV1Fetch(`/api/v1/databases/${id}`, {method: "GET"}, apiKey);
return result.ok ? ok(result.data) : err(result.error);
}
);
server.tool(
"get_database_status",
"Get the current status of a database, including latest backup and restoration state",
{id: z.string().describe("Database ID")},
async ({id}) => {
const result = await apiV1Fetch(
`/api/v1/databases/${id}/status`,
{method: "GET"},
apiKey
);
return result.ok ? ok(result.data) : err(result.error);
}
);
}
+12
View File
@@ -0,0 +1,12 @@
export function ok(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
};
}
export function err(message: string) {
return {
content: [{ type: "text" as const, text: message }],
isError: true as const,
};
}
-1
View File
@@ -1,5 +1,4 @@
import {formatDistanceToNow} from "date-fns";
import {format} from "date-fns";
/**
* Get user's locale and timezone from the browser
+41
View File
@@ -25,4 +25,45 @@ export function formatBytes(bytes: number | null, decimals = 2): string {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
export function formatDuration(ms: number): string {
if (ms == null || Number.isNaN(ms)) return "0 ms";
const totalMs = Math.max(0, Math.floor(ms));
if (totalMs < 1000) {
return `${totalMs} ms`;
}
const totalSeconds = Math.floor(totalMs / 1000);
const seconds = totalSeconds % 60;
if (totalSeconds < 60) {
return `${totalSeconds} s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
if (totalMinutes < 60) {
return seconds > 0
? `${totalMinutes} min ${seconds} s`
: `${totalMinutes} min`;
}
const totalHours = Math.floor(totalMinutes / 60);
const hours = totalHours % 24;
if (totalHours < 24) {
return minutes > 0
? `${totalHours} h ${minutes} min`
: `${totalHours} h`;
}
const days = Math.floor(totalHours / 24);
return hours > 0
? `${days} d ${hours} h`
: `${days} d`;
}