mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f30b55ba47 | ||
|
|
751f29e340 | ||
|
|
2e629f8423 | ||
|
|
ae431f5b85 | ||
|
|
f5f697ceb0 | ||
|
|
02a8f0baf9 | ||
|
|
2932149b92 | ||
|
|
e1e4d67114 | ||
|
|
1e3f112d9c | ||
|
|
e3600949b0 | ||
|
|
856c80cc20 | ||
|
|
8c77796f91 | ||
|
|
dbeaaa4bac | ||
|
|
f9986e1b7f | ||
|
|
895008ba1d | ||
|
|
09791ae86e | ||
|
|
564dee512f | ||
|
|
56a9922057 | ||
|
|
44f0112620 | ||
|
|
2d7a5ce264 | ||
|
|
48f8d00d2e | ||
|
|
7afcf0f76b | ||
|
|
a7b28ce84d | ||
|
|
8db995010f | ||
|
|
41d8b2c375 | ||
|
|
3b8fb93fb2 | ||
|
|
55be9374e1 | ||
|
|
ce1bade773 | ||
|
|
9fe046617c | ||
|
|
01763a6b36 | ||
|
|
b28bb44f97 | ||
|
|
411d0b4565 | ||
|
|
16697c442c | ||
|
|
a3b7af3ba5 | ||
|
|
33492b8f3a | ||
|
|
150ab83f73 | ||
|
|
35db7a4416 | ||
|
|
1ce93af144 | ||
|
|
f410a8fdd5 | ||
|
|
88914169bc | ||
|
|
8d51bcb74d | ||
|
|
5a87529934 | ||
|
|
0d7a41996e | ||
|
|
dc97de03d0 | ||
|
|
5d4a1248e4 | ||
|
|
f876d8eb09 | ||
|
|
1d1d92a5cf |
+2
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
create-release:
|
||||
needs: check-skip
|
||||
if: ${{ needs.check-skip.outputs.skip == 'false' }}
|
||||
if: ${{ needs.check-skip.outputs.skip == 'false' && github.event.pull_request.merged == true }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
draft_tag: ${{ steps.release_step.outputs.draft_tag }}
|
||||
|
||||
@@ -59,3 +59,4 @@ certificates
|
||||
.claude
|
||||
.codex
|
||||
/docs
|
||||
.worktrees
|
||||
|
||||
+1
-1
@@ -33,5 +33,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.16.1
|
||||
version: 1.18.0
|
||||
date-released: '2026-03-02'
|
||||
|
||||
+27
-20
@@ -9,6 +9,7 @@ import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {BackupModalProvider} from "@/features/database/backup-modal-context";
|
||||
import {DatabaseContent} from "@/features/database/database-content";
|
||||
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||
import {LogsModalProvider} from "@/features/logs/logs-modal-context";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
projectId: string;
|
||||
@@ -34,7 +35,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
project: true,
|
||||
retentionPolicy: true,
|
||||
alertPolicies: true,
|
||||
storagePolicies: true
|
||||
storagePolicies: true,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,13 +51,17 @@ export default async function RoutePage(props: PageParams<{
|
||||
with: {
|
||||
storageChannel: true
|
||||
}
|
||||
}
|
||||
},
|
||||
logs: true
|
||||
},
|
||||
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
||||
});
|
||||
|
||||
const restorations = await db.query.restoration.findMany({
|
||||
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
|
||||
with: {
|
||||
logs: true
|
||||
},
|
||||
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
||||
});
|
||||
|
||||
@@ -84,7 +89,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
notFound();
|
||||
}
|
||||
|
||||
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({ id: dbItem.id }) : []
|
||||
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : []
|
||||
|
||||
|
||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
@@ -93,23 +98,25 @@ export default async function RoutePage(props: PageParams<{
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<BackupModalProvider>
|
||||
<DatabaseContent
|
||||
activeMember={activeMember}
|
||||
settings={settings}
|
||||
database={dbItem}
|
||||
databaseHealthLogs={databaseHealthLogs}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
restorations={restorations}
|
||||
backups={backups}
|
||||
totalBackups={totalBackups}
|
||||
availableBackups={availableBackups}
|
||||
successRate={successRate}
|
||||
organizationId={organization.id}
|
||||
activeOrganizationChannels={[]}
|
||||
activeOrganizationStorageChannels={[]}
|
||||
/>
|
||||
</BackupModalProvider>
|
||||
<LogsModalProvider>
|
||||
<BackupModalProvider>
|
||||
<DatabaseContent
|
||||
activeMember={activeMember}
|
||||
settings={settings}
|
||||
database={dbItem}
|
||||
databaseHealthLogs={databaseHealthLogs}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
restorations={restorations}
|
||||
backups={backups}
|
||||
totalBackups={totalBackups}
|
||||
availableBackups={availableBackups}
|
||||
successRate={successRate}
|
||||
organizationId={organization.id}
|
||||
activeOrganizationChannels={[]}
|
||||
activeOrganizationStorageChannels={[]}
|
||||
/>
|
||||
</BackupModalProvider>
|
||||
</LogsModalProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,9 @@ export default async function RoutePage(props: PageParams<{
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
{proj.databases.length > 0 ? (
|
||||
<CardsWithPagination
|
||||
data={proj.databases}
|
||||
data={[...proj.databases].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)}
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {eventEmitter} from "@/lib/event";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
|
||||
import {EventKind} from "@/features/notifications/notifications.types";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {JobLogEntry} from "@/features/logs/types";
|
||||
|
||||
const log = logger.child({module: "api/agent/backup/route"});
|
||||
|
||||
@@ -22,6 +23,8 @@ export type BodyPatch = {
|
||||
status: "success" | "failed"
|
||||
size: number
|
||||
generatedId: string
|
||||
logs: JobLogEntry[]
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
@@ -30,6 +33,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
}) => {
|
||||
try {
|
||||
const body: BodyPost = await request.json();
|
||||
|
||||
const method = body.method
|
||||
const database = await getDatabaseOrThrow(body.generatedId);
|
||||
|
||||
@@ -127,12 +131,37 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
.update(drizzleDb.schemas.backup)
|
||||
.set(withUpdatedAt({
|
||||
status: status,
|
||||
fileSize: backupSize
|
||||
fileSize: backupSize,
|
||||
durationMs: body.durationMs
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.backup.id, backup.id))
|
||||
.returning();
|
||||
|
||||
|
||||
const logsToInsert = body.logs.map((entry) => ({
|
||||
backupId: backup.id,
|
||||
restorationId: null,
|
||||
|
||||
loggedAt: new Date(entry.timestamp),
|
||||
|
||||
entryType: entry.type,
|
||||
level: entry.level,
|
||||
|
||||
message: entry.message,
|
||||
command: entry.command ?? null,
|
||||
output: entry.output ?? null,
|
||||
|
||||
exitCode: entry.exit_code ?? null,
|
||||
durationMs: entry.duration_ms ?? null,
|
||||
}));
|
||||
|
||||
if (logsToInsert.length > 0) {
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.jobLog)
|
||||
.values(logsToInsert);
|
||||
}
|
||||
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {db as dbClient, db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {JobLogEntry} from "@/features/logs/types";
|
||||
|
||||
const log = logger.child({module: "api/agent/restore"});
|
||||
|
||||
export type BodyResultRestore = {
|
||||
generatedId: string
|
||||
status: string
|
||||
logs: JobLogEntry[]
|
||||
durationMs: number
|
||||
}
|
||||
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
|
||||
|
||||
@@ -60,11 +63,34 @@ export async function POST(
|
||||
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
|
||||
}
|
||||
|
||||
|
||||
await db
|
||||
const [restorationUpdated] = await db
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set(withUpdatedAt({status: body.status as RestorationStatus}))
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
.set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs}))
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning();
|
||||
|
||||
|
||||
const logsToInsert = body.logs.map((entry) => ({
|
||||
backupId: null,
|
||||
restorationId: restorationUpdated.id,
|
||||
|
||||
loggedAt: new Date(entry.timestamp),
|
||||
|
||||
entryType: entry.type,
|
||||
level: entry.level,
|
||||
|
||||
message: entry.message,
|
||||
command: entry.command ?? null,
|
||||
output: entry.output ?? null,
|
||||
|
||||
exitCode: entry.exit_code ?? null,
|
||||
durationMs: entry.duration_ms ?? null,
|
||||
}));
|
||||
|
||||
if (logsToInsert.length > 0) {
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.jobLog)
|
||||
.values(logsToInsert);
|
||||
}
|
||||
|
||||
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+2206
-1343
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -17,6 +17,7 @@ import * as storagePolicy from "@/db/schema/13_storage-policy";
|
||||
import * as backupStorage from "@/db/schema/14_storage-backup";
|
||||
import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
|
||||
import * as apiKey from "@/db/schema/16_apikey";
|
||||
import * as jobLog from "@/db/schema/17_job-log";
|
||||
|
||||
const log = logger.child({module: "db"});
|
||||
|
||||
@@ -53,7 +54,8 @@ export const schemas = {
|
||||
...storagePolicy,
|
||||
...backupStorage,
|
||||
...healthcheckLog,
|
||||
...apiKey
|
||||
...apiKey,
|
||||
...jobLog
|
||||
};
|
||||
|
||||
export const db = drizzle({
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "job_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"backup_id" uuid,
|
||||
"restoration_id" uuid,
|
||||
"updated_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "job_log" ADD CONSTRAINT "job_log_backup_id_backups_id_fk" FOREIGN KEY ("backup_id") REFERENCES "public"."backups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "job_log" ADD CONSTRAINT "job_log_restoration_id_restorations_id_fk" FOREIGN KEY ("restoration_id") REFERENCES "public"."restorations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TYPE "public"."job_log_entry_type" AS ENUM('log', 'command');--> statement-breakpoint
|
||||
CREATE TYPE "public"."job_log_level" AS ENUM('debug', 'info', 'warn', 'error');--> statement-breakpoint
|
||||
ALTER TABLE "job_log" ADD COLUMN "logged_at" timestamp with time zone NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "job_log" ADD COLUMN "entry_type" "job_log_entry_type" NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "job_log" ADD COLUMN "level" "job_log_level" NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "job_log" ADD COLUMN "message" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "job_log" ADD COLUMN "command" text;--> statement-breakpoint
|
||||
ALTER TABLE "job_log" ADD COLUMN "output" text;--> statement-breakpoint
|
||||
ALTER TABLE "job_log" ADD COLUMN "exit_code" integer;--> statement-breakpoint
|
||||
ALTER TABLE "job_log" ADD COLUMN "duration_ms" bigint;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "backups" ADD COLUMN "duration_ms" bigint;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "restorations" ADD COLUMN "duration_ms" bigint;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -407,6 +407,34 @@
|
||||
"when": 1779698258492,
|
||||
"tag": "0057_cooing_nocturne",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 58,
|
||||
"version": "7",
|
||||
"when": 1780769342681,
|
||||
"tag": "0058_slim_annihilus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 59,
|
||||
"version": "7",
|
||||
"when": 1780770010009,
|
||||
"tag": "0059_past_fabian_cortez",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 60,
|
||||
"version": "7",
|
||||
"when": 1780771191196,
|
||||
"tag": "0060_shiny_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 61,
|
||||
"version": "7",
|
||||
"when": 1780822414802,
|
||||
"tag": "0061_illegal_mole_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {timestamps} from "@/db/schema/00_common";
|
||||
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
|
||||
import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy";
|
||||
import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup";
|
||||
import {JobLog, jobLog} from "@/db/schema/17_job-log";
|
||||
|
||||
export const database = pgTable("databases", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -38,6 +39,7 @@ export const backup = pgTable(
|
||||
status: statusEnum("status").default("waiting").notNull(),
|
||||
file: text("file"),
|
||||
fileSize: bigint("file_size", { mode: "number" }),
|
||||
durationMs: bigint("duration_ms", { mode: "number" }),
|
||||
databaseId: uuid("database_id")
|
||||
.notNull()
|
||||
.references(() => database.id, {onDelete: "cascade"}),
|
||||
@@ -66,7 +68,7 @@ export const retentionPolicy = pgTable("retention_policies", {
|
||||
export const restoration = pgTable("restorations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
status: statusEnum("status").default("waiting").notNull(),
|
||||
|
||||
durationMs: bigint("duration_ms", { mode: "number" }),
|
||||
backupStorageId: uuid("backup_storage_id")
|
||||
.references(() => backupStorage.id, {onDelete: "cascade"}),
|
||||
backupId: uuid("backup_id")
|
||||
@@ -94,12 +96,15 @@ export const backupRelations = relations(backup, ({one, many}) => ({
|
||||
database: one(database, {fields: [backup.databaseId], references: [database.id]}),
|
||||
restorations: many(restoration),
|
||||
storages: many(backupStorage),
|
||||
logs: many(jobLog),
|
||||
|
||||
}));
|
||||
|
||||
export const restorationRelations = relations(restoration, ({one}) => ({
|
||||
export const restorationRelations = relations(restoration, ({one, many}) => ({
|
||||
backup: one(backup, {fields: [restoration.backupId], references: [backup.id]}),
|
||||
database: one(database, {fields: [restoration.databaseId], references: [database.id]}),
|
||||
backupStorage: one(backupStorage, {fields: [restoration.backupStorageId], references: [backupStorage.id]}),
|
||||
logs: many(jobLog),
|
||||
}));
|
||||
|
||||
|
||||
@@ -137,6 +142,12 @@ export type DatabaseWith = Database & {
|
||||
export type BackupWith = Backup & {
|
||||
restorations?: Restoration[] | null;
|
||||
storages?: BackupStorage[] | null;
|
||||
logs?: JobLog[] | null;
|
||||
};
|
||||
|
||||
export type RestorationWith = Restoration & {
|
||||
logs?: JobLog[] | null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {pgTable, uuid, text, integer, pgEnum, bigint, timestamp} from "drizzle-orm/pg-core";
|
||||
import {timestamps} from "@/db/schema/00_common";
|
||||
import {backup, restoration} from "@/db/schema/07_database";
|
||||
import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
import {relations} from "drizzle-orm";
|
||||
|
||||
|
||||
export const jobLogLevelEnum = pgEnum("job_log_level", [
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
]);
|
||||
|
||||
export const jobLogEntryTypeEnum = pgEnum("job_log_entry_type", [
|
||||
"log",
|
||||
"command",
|
||||
]);
|
||||
|
||||
export const jobLog = pgTable(
|
||||
"job_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
backupId: uuid("backup_id").references(() => backup.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
restorationId: uuid("restoration_id").references(() => restoration.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
loggedAt: timestamp("logged_at", {withTimezone: true}).notNull(),
|
||||
|
||||
entryType: jobLogEntryTypeEnum("entry_type").notNull(),
|
||||
level: jobLogLevelEnum("level").notNull(),
|
||||
|
||||
message: text("message").notNull(),
|
||||
|
||||
|
||||
command: text("command"),
|
||||
output: text("output"),
|
||||
exitCode: integer("exit_code"),
|
||||
durationMs: bigint("duration_ms", {mode: "number"}),
|
||||
|
||||
...timestamps,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
export const jobLogRelations = relations(jobLog, ({one}) => ({
|
||||
backup: one(backup, {
|
||||
fields: [jobLog.backupId],
|
||||
references: [backup.id],
|
||||
}),
|
||||
|
||||
restoration: one(restoration, {
|
||||
fields: [jobLog.restorationId],
|
||||
references: [restoration.id],
|
||||
}),
|
||||
}));
|
||||
export const jobLogSchema = createSelectSchema(jobLog);
|
||||
export type JobLog = z.infer<typeof jobLogSchema>;
|
||||
|
||||
|
||||
+20
-6
@@ -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,
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
@@ -117,7 +117,9 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
||||
<CardsWithPagination
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={2}
|
||||
data={agent.databases}
|
||||
data={[...agent.databases].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)}
|
||||
cardItem={AgentDatabaseCard}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
|
||||
import {AgentSchema} from "@/features/agents/agents.schema";
|
||||
import {z} from "zod";
|
||||
import {eq, and, ne, count} from "drizzle-orm";
|
||||
import {eq, and, ne, count, desc} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {StatusBadge} from "@/components/common/status-badge";
|
||||
import {Backup, DatabaseWith} from "@/db/schema/07_database";
|
||||
import {BackupWith, DatabaseWith} from "@/db/schema/07_database";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||
import {formatLocalizedDate} from "@/utils/date-formatting";
|
||||
import {formatBytes} from "@/utils/text";
|
||||
import {formatBytes, formatDuration} from "@/utils/text";
|
||||
import {DatabaseActionsCell} from "@/features/database/backup-actions-cell";
|
||||
import { Badge as BadgeC } from "@/components/ui/badge";
|
||||
import {backupOnly} from "@/features/database/database-tabs";
|
||||
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
|
||||
|
||||
export function backupColumns(
|
||||
isAlreadyRestore: boolean,
|
||||
settings: Setting,
|
||||
database: DatabaseWith,
|
||||
activeMember: MemberWithUser
|
||||
): ColumnDef<Backup>[] {
|
||||
): ColumnDef<BackupWith>[] {
|
||||
|
||||
const isBackupOnly = backupOnly.some((type) => database.dbms === type)
|
||||
|
||||
@@ -85,6 +86,14 @@ export function backupColumns(
|
||||
return formatBytes(row.getValue("fileSize"))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "durationMs",
|
||||
header: "Duration",
|
||||
cell: ({row}) => {
|
||||
const durationMs = row.getValue("durationMs");
|
||||
return durationMs ? formatDuration(row.getValue("durationMs")) : "-"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
@@ -99,6 +108,14 @@ export function backupColumns(
|
||||
return <StatusBadge status={row.getValue("status")}/>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "logs",
|
||||
header: "Logs",
|
||||
cell: ({row}) => {
|
||||
const logs = row.original.logs ?? [];
|
||||
return <LogsModalTrigger logs={logs}/>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({row}) => <DatabaseActionsCell isAlreadyRestore={isAlreadyRestore} activeMember={activeMember} backup={row.original} isBackupOnly={isBackupOnly}/>,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {z} from "zod";
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {BackupWith, Restoration} from "@/db/schema/07_database";
|
||||
import {BackupWith, RestorationWith} from "@/db/schema/07_database";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||
@@ -36,15 +36,19 @@ export const getDatabaseDataAction = userAction
|
||||
with: {
|
||||
storageChannel: true
|
||||
}
|
||||
}
|
||||
},
|
||||
logs: true
|
||||
},
|
||||
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
||||
}) as BackupWith[];
|
||||
|
||||
const restorations = await db.query.restoration.findMany({
|
||||
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
|
||||
with: {
|
||||
logs: true
|
||||
},
|
||||
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
||||
}) as Restoration[];
|
||||
}) as RestorationWith[];
|
||||
|
||||
const totalBackups = backups.length;
|
||||
const availableBackups = backups.filter(b => !b.deletedAt).length;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {DatabaseBackupActionsModal} from "@/features/database/backup-actions-modal";
|
||||
import {DatabaseTabs} from "@/features/database/database-tabs";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
|
||||
import {BackupWith, DatabaseWith, RestorationWith} from "@/db/schema/07_database";
|
||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||
import {useBackupModal} from "@/features/database/backup-modal-context";
|
||||
import {DatabaseKpi} from "@/features/database/database-kpi";
|
||||
@@ -23,11 +23,12 @@ import {BackupButton} from "@/features/database/backup-button";
|
||||
import {HealthModal} from "@/features/database/health-modal";
|
||||
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {LogsModal} from "@/features/logs/logs-modal";
|
||||
|
||||
export type DatabaseContentProps = {
|
||||
settings: Setting;
|
||||
backups: BackupWith[];
|
||||
restorations: Restoration[];
|
||||
restorations: RestorationWith[];
|
||||
isAlreadyRestore: boolean;
|
||||
database: DatabaseWith;
|
||||
activeMember: MemberWithUser;
|
||||
@@ -156,6 +157,7 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
||||
availableBackups={stats.availableBackups}
|
||||
totalBackups={stats.totalBackups}
|
||||
/>
|
||||
<LogsModal/>
|
||||
<DatabaseBackupActionsModal/>
|
||||
<DatabaseTabs
|
||||
activeMember={props.activeMember}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Check, MoreHorizontal, Trash2, X } from "lucide-react";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Restoration } from "@/db/schema/07_database";
|
||||
import {Restoration, RestorationWith} from "@/db/schema/07_database";
|
||||
import { formatLocalizedDate } from "@/utils/date-formatting";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -24,11 +24,13 @@ import { toast } from "sonner";
|
||||
import { TooltipCustom } from "@/components/common/tooltip-custom";
|
||||
import { MemberWithUser } from "@/db/schema/03_organization";
|
||||
import { ButtonWithConfirm } from "@/components/common/button-with-confirm";
|
||||
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
|
||||
import {formatDuration} from "@/utils/text";
|
||||
|
||||
export function restoreColumns(
|
||||
isAlreadyRestore: boolean,
|
||||
activeMember: MemberWithUser,
|
||||
): ColumnDef<Restoration>[] {
|
||||
): ColumnDef<RestorationWith>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "id",
|
||||
@@ -48,6 +50,22 @@ export function restoreColumns(
|
||||
return <StatusBadge status={row.getValue("status")} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "durationMs",
|
||||
header: "Duration",
|
||||
cell: ({row}) => {
|
||||
const durationMs = row.getValue("durationMs");
|
||||
return durationMs ? formatDuration(row.getValue("durationMs")) : "-"
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "logs",
|
||||
header: "Logs",
|
||||
cell: ({row}) => {
|
||||
const logs = row.original.logs ?? [];
|
||||
return <LogsModalTrigger logs={logs}/>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
|
||||
@@ -21,6 +21,9 @@ export const deleteRestoreAction = userAction
|
||||
.where(and(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId)))
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.delete(drizzleDb.schemas.jobLog)
|
||||
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -50,14 +53,16 @@ export const rerunRestorationAction = userAction
|
||||
)
|
||||
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
|
||||
try {
|
||||
const updateResult = await db
|
||||
const [updatedRestoration] = await db
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({status: "waiting"})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId))
|
||||
.returning()
|
||||
.execute();
|
||||
|
||||
const updatedRestoration = updateResult[0];
|
||||
await db
|
||||
.delete(drizzleDb.schemas.jobLog)
|
||||
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
|
||||
|
||||
if (!updatedRestoration) {
|
||||
return {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Minus, Plus } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { LevelType } from "@/features/logs/types"
|
||||
import { JobLog } from "@/db/schema/17_job-log"
|
||||
import { formatLocalizedDate } from "@/utils/date-formatting"
|
||||
import { formatDuration } from "@/utils/text"
|
||||
|
||||
const levelLabel: Record<LevelType, string> = {
|
||||
info: "Info",
|
||||
debug: "Debug",
|
||||
error: "Error",
|
||||
warn: "Warning",
|
||||
}
|
||||
|
||||
function TypeBadge({ entry }: { entry: JobLog }) {
|
||||
const isCommand = entry.entryType === "command"
|
||||
const label = isCommand ? "Command" : levelLabel[entry.level]
|
||||
const styles = isCommand
|
||||
? "border-transparent bg-secondary text-secondary-foreground"
|
||||
: entry.level === "error"
|
||||
? "border-transparent bg-destructive/20 text-destructive"
|
||||
: entry.level === "warn"
|
||||
? "border-transparent bg-amber-500/20 text-amber-600 dark:text-amber-300"
|
||||
: entry.level === "debug"
|
||||
? "border-transparent bg-muted text-muted-foreground"
|
||||
: "border-transparent bg-sky-500/20 text-sky-600 dark:text-sky-300"
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn("rounded-full", styles)}>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogRow({ entry }: { entry: JobLog }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const isCommand = entry.entryType === "command"
|
||||
const date = formatLocalizedDate(entry.loggedAt)
|
||||
|
||||
const accent =
|
||||
entry.level === "error"
|
||||
? "bg-destructive"
|
||||
: entry.level === "warn"
|
||||
? "bg-amber-500"
|
||||
: isCommand
|
||||
? "bg-emerald-500"
|
||||
: "bg-sky-500"
|
||||
|
||||
return (
|
||||
<div className="relative border-b border-border last:border-b-0">
|
||||
<div className={cn("absolute left-0 top-0 h-full w-[3px]", accent)} aria-hidden="true" />
|
||||
|
||||
<div className="flex flex-col gap-3 py-4 pl-6 pr-4 sm:flex-row sm:items-start sm:gap-4 sm:pr-6">
|
||||
<span className="shrink-0 font-mono text-sm text-muted-foreground sm:w-[130px] sm:pt-0.5">
|
||||
{date}
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 sm:w-[90px] sm:pt-0.5">
|
||||
<TypeBadge entry={entry} />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{isCommand ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<code className="block flex-1 truncate rounded-md bg-muted px-3 py-1.5 font-mono text-sm text-foreground">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">$ </span>
|
||||
{entry.message}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={open ? "Collapse command output" : "Expand command output"}
|
||||
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{open ? <Minus className="size-4" /> : <Plus className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="pt-0.5 text-sm text-foreground">{entry.message}</p>
|
||||
)}
|
||||
|
||||
{isCommand && entry.command && open && (
|
||||
<div className="mt-4">
|
||||
<div className="overflow-hidden rounded-xl bg-muted ring-1 ring-border">
|
||||
<div className="max-h-[15rem] overflow-auto px-4 py-3.5">
|
||||
<code className="block whitespace-pre font-mono text-sm leading-relaxed text-foreground">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">$ </span>
|
||||
{entry.command}
|
||||
</code>
|
||||
{entry.output ? (
|
||||
<pre className="mt-3 whitespace-pre font-mono text-sm leading-relaxed text-muted-foreground">
|
||||
{entry.output}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm sm:gap-6">
|
||||
{entry.exitCode !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Exit code:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold",
|
||||
entry.exitCode === 0
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-300"
|
||||
: "bg-destructive/20 text-destructive",
|
||||
)}
|
||||
>
|
||||
{entry.exitCode}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.durationMs !== null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-medium text-foreground">{formatDuration(entry.durationMs)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import {createContext, useContext, useState, ReactNode} from "react";
|
||||
import {JobLog} from "@/db/schema/17_job-log";
|
||||
|
||||
type LogsModalContextType = {
|
||||
open: boolean;
|
||||
logs: JobLog[];
|
||||
openModal: (logs: JobLog[]) => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
const LogsModalContext = createContext<LogsModalContextType | undefined>(undefined);
|
||||
|
||||
export const LogsModalProvider = ({children}: { children: ReactNode }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [logs, setLogs] = useState<JobLog[]>([]);
|
||||
|
||||
const openModal = (newLogs: JobLog[]) => {
|
||||
setLogs(newLogs);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setOpen(false);
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<LogsModalContext.Provider value={{open, logs, openModal, closeModal}}>
|
||||
{children}
|
||||
</LogsModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLogsModal = () => {
|
||||
const context = useContext(LogsModalContext);
|
||||
if (!context) throw new Error("useLogsModal must be used within LogsModalProvider");
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import {FileText} from "lucide-react";
|
||||
import {JobLog} from "@/db/schema/17_job-log";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useLogsModal} from "@/features/logs/logs-modal-context";
|
||||
|
||||
export type LogsModalTriggerProps = {
|
||||
logs: JobLog[]
|
||||
}
|
||||
|
||||
export const LogsModalTrigger = ({logs}: LogsModalTriggerProps) => {
|
||||
const {openModal} = useLogsModal();
|
||||
return (
|
||||
<Button disabled={logs.length == 0} variant="outline" size="sm" onClick={()=> {
|
||||
openModal(logs);
|
||||
}}>
|
||||
<FileText />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useLogsModal } from "@/features/logs/logs-modal-context"
|
||||
import { JobLog } from "@/db/schema/17_job-log"
|
||||
import { LogRow } from "@/features/logs/log-row-modal"
|
||||
|
||||
export const LogsModal = () => {
|
||||
const { open, logs, closeModal } = useLogsModal()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden border-border p-0 sm:max-w-6xl">
|
||||
<DialogHeader className="shrink-0 border-b border-border px-6 py-5">
|
||||
<DialogTitle className="text-xl font-bold tracking-tight text-foreground">
|
||||
Job Logs
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="hidden shrink-0 items-center gap-4 border-b border-border bg-card py-3 pl-6 pr-4 sm:flex">
|
||||
<span className="w-[130px] text-sm font-semibold text-foreground">Date</span>
|
||||
<span className="w-[90px] text-sm font-semibold text-foreground">Type</span>
|
||||
<span className="flex-1 text-sm font-semibold text-foreground">Message</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div>
|
||||
{logs.map((entry: JobLog) => (
|
||||
<LogRow key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type LevelType = "info" | "debug" | "error" | "warn";
|
||||
export type EntryType = "log" | "command";
|
||||
|
||||
export interface JobLogEntry {
|
||||
timestamp: string
|
||||
type: EntryType
|
||||
level: LevelType
|
||||
message: string
|
||||
command?: string
|
||||
output?: string
|
||||
exit_code?: number
|
||||
duration_ms?: number
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,5 +1,4 @@
|
||||
import {formatDistanceToNow} from "date-fns";
|
||||
import {format} from "date-fns";
|
||||
|
||||
/**
|
||||
* Get user's locale and timezone from the browser
|
||||
|
||||
@@ -25,4 +25,45 @@ export function formatBytes(bytes: number | null, decimals = 2): string {
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms == null || Number.isNaN(ms)) return "0 ms";
|
||||
|
||||
const totalMs = Math.max(0, Math.floor(ms));
|
||||
|
||||
if (totalMs < 1000) {
|
||||
return `${totalMs} ms`;
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(totalMs / 1000);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds} s`;
|
||||
}
|
||||
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (totalMinutes < 60) {
|
||||
return seconds > 0
|
||||
? `${totalMinutes} min ${seconds} s`
|
||||
: `${totalMinutes} min`;
|
||||
}
|
||||
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const hours = totalHours % 24;
|
||||
|
||||
if (totalHours < 24) {
|
||||
return minutes > 0
|
||||
? `${totalHours} h ${minutes} min`
|
||||
: `${totalHours} h`;
|
||||
}
|
||||
|
||||
const days = Math.floor(totalHours / 24);
|
||||
|
||||
return hours > 0
|
||||
? `${days} d ${hours} h`
|
||||
: `${days} d`;
|
||||
}
|
||||
Reference in New Issue
Block a user