mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907caf22fa | ||
|
|
8aed7d5d57 | ||
|
|
bdcd873121 | ||
|
|
797216559b | ||
|
|
fc789e6fe3 | ||
|
|
2b9b672304 | ||
|
|
62021d09f9 | ||
|
|
39ff705aa1 | ||
|
|
435879a562 | ||
|
|
1971ff69e3 | ||
|
|
91b09552a4 | ||
|
|
810931d748 | ||
|
|
3349bfaf3f | ||
|
|
97c53e4613 | ||
|
|
484fbbb714 | ||
|
|
ea42cc58fe | ||
|
|
70ee8f8dc2 | ||
|
|
c5e53b14cb | ||
|
|
11dd570a53 | ||
|
|
8ef79a8dd5 | ||
|
|
186ee90941 | ||
|
|
bd6a798861 | ||
|
|
7392d18cf4 | ||
|
|
11d40b5eef | ||
|
|
991ff2b5be | ||
|
|
31fbc50ae6 | ||
|
|
948d978b14 | ||
|
|
cf072a9853 | ||
|
|
fb51a8c176 | ||
|
|
1745211dc3 | ||
|
|
9162a1980d | ||
|
|
c418ea01e2 | ||
|
|
1e17413e52 | ||
|
|
5d2df7042f | ||
|
|
7f93abff82 | ||
|
|
3e8a6d5006 | ||
|
|
96615333a8 | ||
|
|
b8dba8a746 | ||
|
|
dc591d0b48 | ||
|
|
5a5b60849e | ||
|
|
a0943b5520 | ||
|
|
5e4a430049 | ||
|
|
47d78943d8 | ||
|
|
141abbae9d | ||
|
|
278ac6a17e | ||
|
|
4c63408094 | ||
|
|
adda02e91f | ||
|
|
4fd3125bf9 | ||
|
|
2ef7f7929d | ||
|
|
666d677349 | ||
|
|
1afc6b8c62 | ||
|
|
9f15b49345 | ||
|
|
6d19fb8833 | ||
|
|
df692c4729 | ||
|
|
cd72cfbff4 | ||
|
|
20636bf6f2 | ||
|
|
8f2d6f0b01 | ||
|
|
64b3bf1b16 | ||
|
|
dc4190aa1b | ||
|
|
6efcb55638 | ||
|
|
6b89cdfa1d | ||
|
|
693ef6ec3e | ||
|
|
de33dea50d | ||
|
|
d5e453c390 | ||
|
|
ff65590c97 | ||
|
|
503d5469d4 | ||
|
|
96c51a08c6 | ||
|
|
fe1d077754 | ||
|
|
f2acc103a7 | ||
|
|
c073a30d20 | ||
|
|
bdc863de1f | ||
|
|
97e2c21d52 | ||
|
|
60f57a734d | ||
|
|
6952ac5f48 | ||
|
|
45acc781f2 | ||
|
|
9c2359e87b | ||
|
|
8dcb406790 | ||
|
|
a3d0a6e1df | ||
|
|
8edf0ca742 | ||
|
|
568d9612c5 | ||
|
|
6548945b2f | ||
|
|
4b1d0aba74 | ||
|
|
c726346258 | ||
|
|
0677f14ac5 | ||
|
|
d346f5cdc4 | ||
|
|
1ce08448ec | ||
|
|
77fb1095f1 | ||
|
|
29dd597c7a | ||
|
|
7943f3fd4b | ||
|
|
a63c29b943 | ||
|
|
f4d72090a3 | ||
|
|
8b379067a1 | ||
|
|
896b65f2a5 | ||
|
|
79d725cd12 |
@@ -73,7 +73,7 @@ jobs:
|
||||
tags: ${{ steps.prep.outputs.image }}
|
||||
target: ${{ inputs.target }}
|
||||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }},ignore-error=true
|
||||
|
||||
- name: Save image name for manifest
|
||||
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
||||
|
||||
+3
-1
@@ -46,6 +46,8 @@ public/uploads/*
|
||||
private/uploads/*
|
||||
private/keys/*
|
||||
|
||||
private/telemetry/*
|
||||
|
||||
/.env
|
||||
|
||||
seeds/pocket-id/*.db
|
||||
@@ -64,4 +66,4 @@ certificates
|
||||
.codex
|
||||
/docs
|
||||
.worktrees
|
||||
.superpowers
|
||||
.superpowers
|
||||
|
||||
+1
-1
@@ -33,5 +33,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.20.3
|
||||
version: 1.23.1
|
||||
date-released: '2026-03-02'
|
||||
|
||||
@@ -78,6 +78,7 @@ You have 5 ways to install Portabase:
|
||||
| **Valkey** | ✅ Stable | 7.2+ | No |
|
||||
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
|
||||
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
|
||||
| **Docker Volume** | ✅ Stable | Docker Engine 20.10+ | Yes |
|
||||
|
||||
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
|
||||
|
||||
@@ -89,7 +90,7 @@ See the [Database Servers documentation](https://portabase.io/docs/agent/db) for
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
|
||||
|
||||
[Docker]: https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=for-the-badge
|
||||
@@ -111,4 +112,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ export default async function RoutePage(
|
||||
</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey} />
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={true} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
+22
-31
@@ -2,7 +2,7 @@ import { PageParams } from "@/types/next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Page } from "@/features/layout/components/page";
|
||||
import { db } from "@/db";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { eq, and, inArray, isNull } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { getOrganizationProjectDatabases } from "@/db/services/project";
|
||||
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
|
||||
@@ -49,38 +49,22 @@ export default async function RoutePage(
|
||||
redirect("/dashboard/projects");
|
||||
}
|
||||
|
||||
const backups = await db.query.backup.findMany({
|
||||
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
|
||||
with: {
|
||||
restorations: true,
|
||||
storages: {
|
||||
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)],
|
||||
});
|
||||
|
||||
//const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
|
||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||
|
||||
const totalBackups = await db
|
||||
.select({ count: drizzleDb.schemas.backup.id })
|
||||
.from(drizzleDb.schemas.backup)
|
||||
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
|
||||
.then((rows) => rows.length);
|
||||
|
||||
const availableBackups = backups.filter((b) => !b.deletedAt).length;
|
||||
const availableBackups = await db
|
||||
.select({ count: drizzleDb.schemas.backup.id })
|
||||
.from(drizzleDb.schemas.backup)
|
||||
.where(
|
||||
and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
|
||||
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.length);
|
||||
|
||||
const successfulBackups = await db
|
||||
.select({ count: drizzleDb.schemas.backup.id })
|
||||
@@ -93,6 +77,17 @@ export default async function RoutePage(
|
||||
)
|
||||
.then((rows) => rows.length);
|
||||
|
||||
const isAlreadyRestore = await db
|
||||
.select({ count: drizzleDb.schemas.restoration.id })
|
||||
.from(drizzleDb.schemas.restoration)
|
||||
.where(
|
||||
and(
|
||||
eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
|
||||
eq(drizzleDb.schemas.restoration.status, "waiting"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.length > 0);
|
||||
|
||||
const [settings] = await db
|
||||
.select()
|
||||
.from(drizzleDb.schemas.setting)
|
||||
@@ -109,8 +104,6 @@ export default async function RoutePage(
|
||||
const successRate =
|
||||
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
|
||||
//const isMember = activeMember?.role === "member";
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<LogsModalProvider>
|
||||
@@ -121,8 +114,6 @@ export default async function RoutePage(
|
||||
database={dbItem}
|
||||
databaseHealthLogs={databaseHealthLogs}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
restorations={restorations}
|
||||
backups={backups}
|
||||
totalBackups={totalBackups}
|
||||
availableBackups={availableBackups}
|
||||
successRate={successRate}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Page, PageContent, PageTitle } from "@/features/layout/components/page"
|
||||
import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
|
||||
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
|
||||
import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
|
||||
import { ProjectDatabaseSelection } from "@/features/projects/components/project-database-selection";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -87,20 +88,27 @@ export default async function RoutePage(
|
||||
</div>
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
{proj.databases.length > 0 ? (
|
||||
<CardsWithPagination
|
||||
data={[...proj.databases].sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() -
|
||||
new Date(a.createdAt).getTime(),
|
||||
)}
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
cardsPerPage={20}
|
||||
numberOfColumns={3}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
extendedProps={proj}
|
||||
/>
|
||||
isMember ? (
|
||||
<CardsWithPagination
|
||||
data={[...proj.databases].sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() -
|
||||
new Date(a.createdAt).getTime(),
|
||||
)}
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
cardsPerPage={20}
|
||||
numberOfColumns={3}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
extendedProps={proj}
|
||||
/>
|
||||
) : (
|
||||
<ProjectDatabaseSelection
|
||||
projectId={proj.id}
|
||||
databases={proj.databases}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
|
||||
<p className="text-lg font-medium">No databases found</p>
|
||||
|
||||
@@ -94,7 +94,7 @@ export default async function RoutePage(
|
||||
</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={!!agent.organizationId}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -47,7 +47,9 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const organizationWithMembers = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||
with: {
|
||||
projects: true,
|
||||
projects: {
|
||||
where: eq(drizzleDb.schemas.project.isArchived, false),
|
||||
},
|
||||
members: {
|
||||
with: {
|
||||
user: true,
|
||||
|
||||
@@ -29,7 +29,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
if (!org) notFound();
|
||||
|
||||
const projects = await db.query.project.findMany({
|
||||
where: eq(drizzleDb.schemas.project.organizationId, org.id),
|
||||
where: and(eq(drizzleDb.schemas.project.organizationId, org.id), eq(drizzleDb.schemas.project.isArchived, false))
|
||||
});
|
||||
|
||||
const projectIds = projects.map(project => project.id);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleDatabases } from "./helpers";
|
||||
import { handleDatabases } from "@/features/agents/utils/status/status.helpers";
|
||||
import { Body } from "@/features/agents/types";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { db } from "@/db";
|
||||
import { EDbmsSchema } from "@/db/schema/types";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { withUpdatedAt } from "@/db/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -10,18 +10,6 @@ import { isUUID } from "@/utils/text";
|
||||
|
||||
const log = logger.child({ module: "api/agent/status/route" });
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string;
|
||||
dbms: EDbmsSchema;
|
||||
generatedId: string;
|
||||
pingStatus: boolean;
|
||||
};
|
||||
|
||||
export type Body = {
|
||||
version: string;
|
||||
databases: databaseAgent[];
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ agentId: string }> },
|
||||
@@ -60,7 +48,7 @@ export async function POST(
|
||||
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||
.limit(1);
|
||||
if (!settings) {
|
||||
return NextResponse.json({ error: "An error occured" }, { status: 404 });
|
||||
return NextResponse.json({ error: "An error occurred" }, { status: 404 });
|
||||
}
|
||||
|
||||
const databasesResponse = await handleDatabases(
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {env} from "@/env.mjs";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/tus/hooks"});
|
||||
|
||||
const FILE_PATH_RE = /^backups\/\d{4}-\d{2}-\d{2}\/[A-Za-z0-9._-]+$/;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
@@ -24,17 +29,61 @@ export async function POST(request: Request) {
|
||||
event.Upload.Offset === event.Upload.Size
|
||||
) {
|
||||
const id = event.Upload.ID;
|
||||
const fileName = headers["X-File-Name"]?.[0];
|
||||
const filePath = headers["X-File-Path"]?.[0];
|
||||
|
||||
if (!filePath) {
|
||||
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
||||
if (!filePath || !FILE_PATH_RE.test(filePath)) {
|
||||
log.warn({filePath}, "Rejected invalid X-File-Path in TUS hook");
|
||||
return NextResponse.json({error: "Invalid file path"}, {status: 400});
|
||||
}
|
||||
|
||||
const generatedId = headers["X-Generated-Id"]?.[0];
|
||||
const backupStorageId = headers["X-Backup-Storage-Id"]?.[0];
|
||||
|
||||
|
||||
console.log(`Backup Storage ID : ${backupStorageId}`);
|
||||
console.log(`generatedId : ${generatedId}`);
|
||||
|
||||
if (backupStorageId) {
|
||||
if (!generatedId) {
|
||||
log.warn("Missing X-Generated-Id in TUS hook");
|
||||
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
}
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
log.warn({generatedId}, "No database for X-Generated-Id in TUS hook");
|
||||
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
}
|
||||
|
||||
const storage = await db.query.backupStorage.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
|
||||
),
|
||||
with: {backup: true},
|
||||
});
|
||||
|
||||
if (!storage || storage.backup?.databaseId !== database.id) {
|
||||
log.warn({backupStorageId, generatedId}, "No pending backup storage matches TUS hook");
|
||||
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
}
|
||||
} else {
|
||||
log.warn("TUS hook without X-Backup-Storage-Id (legacy agent)");
|
||||
}
|
||||
|
||||
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
|
||||
|
||||
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||
const newFilePath = path.join(uploadDir, filePath);
|
||||
|
||||
const resolvedBase = path.resolve(uploadDir);
|
||||
const newFilePath = path.resolve(resolvedBase, filePath);
|
||||
const rel = path.relative(resolvedBase, newFilePath);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
log.warn({filePath}, "Rejected path traversal in TUS hook");
|
||||
return NextResponse.json({error: "Invalid file path"}, {status: 400});
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
|
||||
|
||||
@@ -74,7 +123,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
return NextResponse.json({});
|
||||
} catch (error) {
|
||||
log.error({error: error},"TUS Hook error");
|
||||
log.error({error: error}, "TUS Hook error");
|
||||
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -14,7 +14,12 @@ const queryClient = new QueryClient();
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem
|
||||
storageKey="portabase_theme"
|
||||
>
|
||||
<ThemeMetaUpdater />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorLayout>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: portabase/portabase:1
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
# image: portabase/portabase:1
|
||||
ports:
|
||||
- "8887:80"
|
||||
environment:
|
||||
|
||||
@@ -99,12 +99,58 @@ services:
|
||||
# clientEmail: "fake@test.iam.gserviceaccount.com",
|
||||
# privateKey: "unused-when-emulated",
|
||||
|
||||
rustfs:
|
||||
image: rustfs/rustfs:latest
|
||||
container_name: rustfs
|
||||
environment:
|
||||
- RUSTFS_ROOT_USER=admin
|
||||
- RUSTFS_ROOT_PASSWORD=root-password
|
||||
- RUSTFS_ACCESS_KEY=access-key
|
||||
- RUSTFS_SECRET_KEY=secret-key
|
||||
- RUSTFS_VOLUMES=/data
|
||||
- RUSTFS_ADDRESS=:9000
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_CONSOLE_ADDRESS=:9001
|
||||
volumes:
|
||||
- rustfs-data:/data
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-sf", "http://localhost:9000/health" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "9001:9001"
|
||||
- "9000:9000"
|
||||
extra_hosts:
|
||||
- "localhost:host-gateway"
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
rustfs-init:
|
||||
image: rustfs/rc:latest
|
||||
container_name: rustfs-init
|
||||
depends_on:
|
||||
rustfs:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
until (/usr/bin/rc alias set rustfs http://rustfs:9000 access-key secret-key) do echo '...waiting...' && sleep 1; done;
|
||||
/usr/bin/rc mb rustfs/portabase;
|
||||
echo 'RustFS initialization complete';
|
||||
tail -f /dev/null
|
||||
"
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
azurite-data:
|
||||
gcs-data:
|
||||
gcs-storage:
|
||||
rustfs-data:
|
||||
|
||||
networks:
|
||||
portabase:
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
||||
FROM --platform=$BUILDPLATFORM node:22-slim AS runtime-base
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
bash \
|
||||
tzdata \
|
||||
libc6 \
|
||||
nginx \
|
||||
gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \
|
||||
> /etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
postgresql-18 \
|
||||
postgresql-client-18 \
|
||||
postgresql-contrib-18 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
||||
|
||||
|
||||
FROM runtime-base AS build-base
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
g++ \
|
||||
make \
|
||||
@@ -13,25 +35,9 @@ RUN apt-get update && apt-get install -y \
|
||||
automake \
|
||||
libtool \
|
||||
git \
|
||||
nginx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
RUN mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
|
||||
> /etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-18 \
|
||||
postgresql-client-18 \
|
||||
postgresql-contrib-18 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
||||
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
||||
|
||||
ARG TUSD_VERSION=2.8.0
|
||||
@@ -51,7 +57,7 @@ FROM scratch AS tusd-dist
|
||||
COPY --from=tusd-base /tusddist/tusd /tusd
|
||||
|
||||
|
||||
FROM base AS build-env
|
||||
FROM build-base AS build-env
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
@@ -83,7 +89,7 @@ COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base AS prod
|
||||
FROM runtime-base AS prod
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
@@ -105,7 +111,6 @@ COPY --from=builder /app/drizzle.config.ts ./
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||
COPY --chown=1001:1001 src/db ./src/db
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
|
||||
RUN chmod +x /usr/local/bin/tusd
|
||||
|
||||
@@ -30,6 +30,11 @@ http {
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location = /api/tus/hooks {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
|
||||
|
||||
+12
-38
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.20.3",
|
||||
"version": "1.23.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -25,54 +25,45 @@
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@onboardjs/react": "1.0.0-rc.5",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.220.0",
|
||||
"@opentelemetry/resources": "^2.9.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.9.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.42.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-email/components": "^0.0.41",
|
||||
"@t3-oss/env-nextjs": "^0.13.11",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^6.4.23",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.11",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"dockerode": "^4.0.12",
|
||||
"dotenv": "^16.6.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.8.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"googleapis": "^170.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
@@ -82,33 +73,23 @@
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-forge": "^1.4.0",
|
||||
"nodemailer": "8.0.5",
|
||||
"npm-check-updates": "^18.3.1",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.8.3",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "9.7.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-email": "^4.3.2",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-qr-code": "^2.0.21",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-twc": "^1.5.1",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.4",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swiper": "^12.1.4",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tar-stream": "^3.2.0",
|
||||
"uuid": "^11.1.1",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -119,29 +100,22 @@
|
||||
"@release-it/conventional-changelog": "^10.0.6",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.19.19",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@zenstackhq/openapi": "^2.22.1",
|
||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"esbuild": "^0.27.7",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||
"framer-motion": "^12.40.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "8.5.10",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
"release-it": "^19.2.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tsx": "^4.22.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zenstack": "2.14.2"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
|
||||
}
|
||||
|
||||
Generated
+399
-2565
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -9,6 +9,8 @@ import {
|
||||
SortingState,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
PaginationState,
|
||||
OnChangeFn,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
|
||||
@@ -42,6 +44,13 @@ interface DataTableProps<TData, TValue> {
|
||||
};
|
||||
highlightRow?: (row: TData) => boolean;
|
||||
selectedActions?: (rows: TData[]) => ReactNode;
|
||||
manualPagination?: boolean;
|
||||
rowCount?: number;
|
||||
paginationState?: { pageIndex: number; pageSize: number };
|
||||
onPaginationChange?: OnChangeFn<PaginationState>;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
isFetching?: boolean;
|
||||
|
||||
}
|
||||
|
||||
@@ -56,10 +65,19 @@ export function DataTable<TData, TValue>({
|
||||
emptyButton,
|
||||
highlightRow,
|
||||
selectedActions,
|
||||
manualPagination = false,
|
||||
rowCount,
|
||||
paginationState,
|
||||
onPaginationChange,
|
||||
sorting: controlledSorting,
|
||||
onSortingChange: controlledOnSortingChange,
|
||||
isFetching = false,
|
||||
|
||||
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
|
||||
const sorting = controlledSorting ?? internalSorting;
|
||||
const setSorting = controlledOnSortingChange ?? setInternalSorting;
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
@@ -96,7 +114,7 @@ export function DataTable<TData, TValue>({
|
||||
data,
|
||||
columns: finalColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@@ -106,10 +124,15 @@ export function DataTable<TData, TValue>({
|
||||
autoResetPageIndex: false,
|
||||
autoResetExpanded: false,
|
||||
enableRowSelection: true,
|
||||
manualPagination,
|
||||
manualSorting: manualPagination,
|
||||
rowCount: manualPagination ? rowCount : undefined,
|
||||
onPaginationChange: manualPagination ? onPaginationChange : undefined,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
rowSelection,
|
||||
...(manualPagination && paginationState ? { pagination: paginationState } : {}),
|
||||
},
|
||||
});
|
||||
const router = useRouter();
|
||||
@@ -194,18 +217,22 @@ export function DataTable<TData, TValue>({
|
||||
selected.
|
||||
</div>
|
||||
)}
|
||||
{enablePagination && table.getFilteredRowModel().rows.length >= 1 && (
|
||||
{enablePagination && (manualPagination ? (rowCount ?? 0) > 0 : table.getFilteredRowModel().rows.length >= 1) && (
|
||||
<TablePagination
|
||||
table={table}
|
||||
maxVisiblePages={paginationOptions?.pageVisible}
|
||||
pageSizeOptions={(() => {
|
||||
const rowCount = table.getFilteredRowModel().rows.length;
|
||||
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
|
||||
const validSizes = allSizes.filter((size) => size <= rowCount);
|
||||
const nextSize = allSizes.find((size) => size > rowCount);
|
||||
if (nextSize) validSizes.push(nextSize);
|
||||
return validSizes;
|
||||
})()}
|
||||
pageSizeOptions={
|
||||
manualPagination
|
||||
? paginationOptions.pageSize
|
||||
: (() => {
|
||||
const rowCountLocal = table.getFilteredRowModel().rows.length;
|
||||
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
|
||||
const validSizes = allSizes.filter((size) => size <= rowCountLocal);
|
||||
const nextSize = allSizes.find((size) => size > rowCountLocal);
|
||||
if (nextSize) validSizes.push(nextSize);
|
||||
return validSizes;
|
||||
})()
|
||||
}
|
||||
className={paginationOptions.className}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {useState} from "react";
|
||||
import {Card} from "@/components/ui/card";
|
||||
@@ -8,28 +6,54 @@ import {ConnectionIndicator} from "@/components/common/connection-indicator";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react";
|
||||
import {cn} from "@/lib/utils";
|
||||
|
||||
export type DatabaseCardProps = {
|
||||
data: Database;
|
||||
withDetails?: boolean;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: (databaseId: string) => void;
|
||||
deleteButton?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DatabaseCard = (props: DatabaseCardProps) => {
|
||||
const {data: database, withDetails = true} = props;
|
||||
const {data: database, withDetails = true, selectable = false, selected = false, onToggleSelect, deleteButton} = props;
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(database.agentDatabaseId);
|
||||
navigator.clipboard.writeText(database.agentDatabaseId ?? "");
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
className={cn(
|
||||
"relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0",
|
||||
selected && "ring-1 ring-primary border-primary",
|
||||
)}>
|
||||
{selectable && (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-label={selected ? "Deselect database" : "Select database"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleSelect?.(database.id);
|
||||
}}
|
||||
className={cn(
|
||||
"absolute top-3 left-3 z-20 w-5 h-5 rounded-md border-2 flex items-center justify-center transition-colors",
|
||||
selected ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/50 bg-background/70",
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
<div className={cn("flex items-start justify-between mb-2", selectable && "pl-7")}>
|
||||
<div
|
||||
className="relative w-12 h-12 p-2 bg-background rounded-xl border border-border/50 shadow-sm flex items-center justify-center group-hover:border-primary/30 transition-all duration-300 group-hover:scale-105">
|
||||
<Image
|
||||
@@ -82,7 +106,12 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{withDetails && (
|
||||
{deleteButton ? (
|
||||
<div className="mt-4 flex items-center justify-end pt-3 border-t border-border/50"
|
||||
onClick={(e) => e.preventDefault()}>
|
||||
{deleteButton}
|
||||
</div>
|
||||
) : withDetails ? (
|
||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
|
||||
<span
|
||||
className="text-[10px] font-black uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">Open</span>
|
||||
@@ -93,7 +122,7 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
|
||||
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform"/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ export function DateTimePicker({ name }: DateTimePickerProps) {
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className={cn("w-full pl-3 text-left font-normal", !value && "text-muted-foreground")}>
|
||||
{value ? format(value, "MM/dd/yyyy hh:mm aa") : <span>MM/DD/YYYY hh:mm aa</span>}
|
||||
{value ? format(value, "dd/MM/yyyy hh:mm aa") : <span>DD/MM/YYYY hh:mm aa</span>}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -134,9 +134,9 @@ const getRootError = (
|
||||
const errors = errorCodes.map((error) => {
|
||||
switch (error) {
|
||||
case "file-invalid-type":
|
||||
const acceptedTypes = Object.values(limits.accept ?? {})
|
||||
.flat()
|
||||
.join(", ");
|
||||
const acceptedTypes = Array.from(
|
||||
new Set(Object.values(limits.accept ?? {}).flat()),
|
||||
).join(", ");
|
||||
return `only ${acceptedTypes} are allowed`;
|
||||
case "file-too-large":
|
||||
const maxMb = limits.maxSize
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."dbms_status" ADD VALUE 'docker-volume';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "databases" ALTER COLUMN "agent_database_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "databases" ALTER COLUMN "agent_id" DROP NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -484,6 +484,20 @@
|
||||
"when": 1782575192480,
|
||||
"tag": "0068_busy_zeigeist",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 69,
|
||||
"version": "7",
|
||||
"when": 1782937885439,
|
||||
"tag": "0069_worried_alex_wilder",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "7",
|
||||
"when": 1783264084846,
|
||||
"tag": "0070_illegal_joseph",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {JobLog, jobLog} from "@/db/schema/17_job-log";
|
||||
|
||||
export const database = pgTable("databases", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
agentDatabaseId: uuid("agent_database_id").notNull().defaultRandom(),
|
||||
agentDatabaseId: uuid("agent_database_id").defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
dbms: dbmsEnum("dbms").notNull(),
|
||||
description: text("description"),
|
||||
@@ -22,7 +22,6 @@ export const database = pgTable("databases", {
|
||||
backupToRestore: text("backup_to_restore"),
|
||||
healthErrorCount: integer("health_error_count"),
|
||||
agentId: uuid("agent_id")
|
||||
.notNull()
|
||||
.references(() => agent.id, {onDelete: "cascade"}),
|
||||
lastContact: timestamp("last_contact"),
|
||||
projectId: uuid("project_id")
|
||||
@@ -143,10 +142,12 @@ export type BackupWith = Backup & {
|
||||
restorations?: Restoration[] | null;
|
||||
storages?: BackupStorage[] | null;
|
||||
logs?: JobLog[] | null;
|
||||
hasLogs?: boolean;
|
||||
};
|
||||
|
||||
export type RestorationWith = Restoration & {
|
||||
logs?: JobLog[] | null;
|
||||
hasLogs?: boolean;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
|
||||
import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
|
||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "postgresql-cluster", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql"]);
|
||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "postgresql-cluster", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql", "docker-volume"]);
|
||||
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
||||
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
||||
|
||||
|
||||
@@ -16,10 +16,13 @@ export async function getOrganizationAvailableDatabases(
|
||||
projectId?: string,
|
||||
) {
|
||||
const availableDatabases = (await db.query.database.findMany({
|
||||
where: (db, { eq, or, isNull }) =>
|
||||
projectId
|
||||
? or(isNull(db.projectId), eq(db.projectId, projectId))
|
||||
: isNull(db.projectId),
|
||||
where: (db, { eq, or, and, isNull }) =>
|
||||
and(
|
||||
projectId
|
||||
? or(isNull(db.projectId), eq(db.projectId, projectId))
|
||||
: isNull(db.projectId),
|
||||
isNull(db.deletedAt),
|
||||
),
|
||||
with: {
|
||||
agent: {
|
||||
with: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { and, eq, gte, isNotNull, lt } from "drizzle-orm";
|
||||
import { and, eq, gte, isNotNull, isNull, lt } from "drizzle-orm";
|
||||
import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch";
|
||||
import { EventPayload } from "@/features/notifications/types";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -113,7 +113,10 @@ export async function checkAgentsHealthError() {
|
||||
|
||||
export async function checkDatabasesHealthError() {
|
||||
const databases = await db.query.database.findMany({
|
||||
where: isNotNull(drizzleDb.schemas.database.lastContact),
|
||||
where: and(
|
||||
isNotNull(drizzleDb.schemas.database.lastContact),
|
||||
isNull(drizzleDb.schemas.database.deletedAt),
|
||||
),
|
||||
with: {
|
||||
agent: true,
|
||||
alertPolicies: true,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getOrganization } from "@/lib/auth/auth";
|
||||
import { db } from "@/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { project } from "@/db/schema/06_project";
|
||||
|
||||
@@ -10,7 +10,9 @@ export async function getOrganizationProject(organizationId: string) {
|
||||
return db.query.project.findFirst({
|
||||
where: eq(project.organizationId, organizationId),
|
||||
with: {
|
||||
databases: true,
|
||||
databases: {
|
||||
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -39,7 +41,9 @@ export const getOrganizationProjectDatabases = async ({
|
||||
eq(drizzleDb.schemas.project.id, projectId),
|
||||
),
|
||||
with: {
|
||||
databases: true,
|
||||
databases: {
|
||||
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export const env = createEnv({
|
||||
process.env.NODE_ENV === "production" ? "0 * * * *" : "* * * * *",
|
||||
),
|
||||
|
||||
STALE_BACKUP_THRESHOLD_HOURS: z.coerce.number().default(6),
|
||||
|
||||
AUTH_OIDC_ID: z.string().optional().default("oidc"),
|
||||
AUTH_OIDC_TITLE: z.string().optional(),
|
||||
@@ -121,6 +122,11 @@ export const env = createEnv({
|
||||
.enum(["true", "false"])
|
||||
.default("false")
|
||||
.transform((val) => val === "true"),
|
||||
|
||||
TELEMETRY: z
|
||||
.enum(["true", "false"])
|
||||
.default("true")
|
||||
.transform((val) => val === "true"),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
@@ -147,6 +153,7 @@ export const env = createEnv({
|
||||
|
||||
RETENTION_CRON: process.env.RETENTION_CRON,
|
||||
CLEANING_HEALTHCHECK_LOGS_CRON: process.env.CLEANING_HEALTHCHECK_LOGS_CRON,
|
||||
STALE_BACKUP_THRESHOLD_HOURS: process.env.STALE_BACKUP_THRESHOLD_HOURS,
|
||||
|
||||
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
|
||||
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
|
||||
@@ -202,6 +209,8 @@ export const env = createEnv({
|
||||
|
||||
DEMO_ENABLED: process.env.DEMO_ENABLED,
|
||||
|
||||
TELEMETRY: process.env.TELEMETRY,
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {db} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {and, eq, inArray, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {AgentWith} from "@/db/schema/08_agent";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
@@ -25,7 +25,9 @@ export const updateAgentOrganizationsAction = userAction
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
with: {
|
||||
organizations: true,
|
||||
databases: true
|
||||
databases: {
|
||||
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||
},
|
||||
}
|
||||
}) as AgentWith;
|
||||
|
||||
@@ -70,7 +72,7 @@ export const updateAgentOrganizationsAction = userAction
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
const databases = await db.query.database.findMany({
|
||||
where: (db, { inArray }) => inArray(db.projectId, projectIds),
|
||||
where: (db, { inArray, and, isNull }) => and(inArray(db.projectId, projectIds), isNull(db.deletedAt)),
|
||||
columns: { id: true }
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
|
||||
import {AgentSchema} from "@/features/agents/schemas/agents.schema";
|
||||
import {z} from "zod";
|
||||
import {eq, and, ne, count, desc} from "drizzle-orm";
|
||||
import {eq, and, ne, count, desc, isNull} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
@@ -87,7 +87,9 @@ export const getAgentAction = userAction.schema(z.string()).action(async ({parse
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, parsedInput),
|
||||
with: {
|
||||
databases: true
|
||||
databases: {
|
||||
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@ import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
||||
type AgentContentPageProps = {
|
||||
edgeKey: string;
|
||||
agent: AgentWithDatabases
|
||||
canDeleteDatabases?: boolean
|
||||
}
|
||||
|
||||
export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPageProps) => {
|
||||
export const AgentContentPage = ({edgeKey, agent: initialAgent, canDeleteDatabases = false}: AgentContentPageProps) => {
|
||||
|
||||
const {data} = useQuery({
|
||||
queryKey: ["agent-data", initialAgent.id],
|
||||
@@ -121,6 +122,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)}
|
||||
cardItem={AgentDatabaseCard}
|
||||
canDeleteDatabases={canDeleteDatabases}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {DatabaseCard} from "@/components/common/database-card";
|
||||
import { Database } from "@/db/schema/07_database";
|
||||
import { DatabaseCard } from "@/components/common/database-card";
|
||||
import { DatabaseDeleteButton } from "@/features/agents/components/database-delete-button";
|
||||
|
||||
export type agentDatabaseCardProps = {
|
||||
export type AgentDatabaseCardProps = {
|
||||
data: Database;
|
||||
canDeleteDatabases?: boolean;
|
||||
};
|
||||
|
||||
export const AgentDatabaseCard = (props: agentDatabaseCardProps) => {
|
||||
const {data: database} = props;
|
||||
export const AgentDatabaseCard = (props: AgentDatabaseCardProps) => {
|
||||
const { data: database, canDeleteDatabases = false } = props;
|
||||
|
||||
return <DatabaseCard withDetails={false} data={database}/>;
|
||||
};
|
||||
return (
|
||||
<DatabaseCard
|
||||
withDetails={false}
|
||||
data={database}
|
||||
deleteButton={
|
||||
canDeleteDatabases && database.agentId ? (
|
||||
<DatabaseDeleteButton
|
||||
databaseId={database.id}
|
||||
agentId={database.agentId}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import {Trash2} from "lucide-react";
|
||||
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {deleteDatabaseAction} from "@/features/database/actions/database-delete.action";
|
||||
|
||||
export type DatabaseDeleteButtonProps = {
|
||||
databaseId: string;
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
export const DatabaseDeleteButton = (props: DatabaseDeleteButtonProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteDatabaseAction({databaseId: props.databaseId}),
|
||||
onSuccess: async (result: any) => {
|
||||
if (result.data?.success) {
|
||||
toast.success(result.data.actionSuccess.message);
|
||||
await queryClient.invalidateQueries({queryKey: ["agent-data", props.agentId]});
|
||||
} else {
|
||||
toast.error(result.data?.actionError?.message || "Unknown error occurred.");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to delete database.");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
title="Delete database"
|
||||
description="Are you sure you want to delete this database? It will be unlinked from this agent."
|
||||
button={{
|
||||
main: {
|
||||
text: "",
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
icon: <Trash2 color="red"/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: () => {
|
||||
mutation.mutate();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string;
|
||||
dbms: EDbmsSchema;
|
||||
generatedId: string;
|
||||
pingStatus: boolean;
|
||||
};
|
||||
|
||||
export type Body = {
|
||||
version: string;
|
||||
databases: databaseAgent[];
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {logger} from "@/lib/logger";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/utils/notifications.helpers";
|
||||
|
||||
const log = logger.child({module: "api/agent/status/restoration"});
|
||||
|
||||
export async function handleFailedRestoration(restorationId: string, databaseId: string, reason: string): Promise<void> {
|
||||
try {
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.jobLog)
|
||||
.values({
|
||||
backupId: null,
|
||||
restorationId,
|
||||
loggedAt: new Date(),
|
||||
entryType: "log",
|
||||
level: "error",
|
||||
message: reason,
|
||||
command: null,
|
||||
output: null,
|
||||
exitCode: null,
|
||||
durationMs: null,
|
||||
});
|
||||
|
||||
const databaseWithPolicies = await dbClient.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||
with: {alertPolicies: true},
|
||||
});
|
||||
|
||||
if (databaseWithPolicies) {
|
||||
await sendNotificationsBackupRestore(databaseWithPolicies, "error_restore");
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({error: err, name: "handleFailedRestoration"}, "Failed to record restoration failure job log / notification");
|
||||
}
|
||||
}
|
||||
+28
-72
@@ -1,10 +1,10 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {Body} from "./route";
|
||||
import {Body} from "@/features/agents/types";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db, db as dbClient} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {db as dbClient} from "@/db";
|
||||
import {and, eq, inArray, desc, sql} from "drizzle-orm";
|
||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
@@ -12,12 +12,22 @@ import {logger} from "@/lib/logger";
|
||||
import {isUUID} from "@/utils/text";
|
||||
import {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
|
||||
import {getMasterServerKeyContent} from "@/features/agents/actions/keys.action";
|
||||
import {getDatabaseStorageChannels, PingDatabaseStorageChannels} from "./storage-channels.helpers";
|
||||
import {applyStorageEncryption} from "./storage-encryption.helpers";
|
||||
import {handleFailedRestoration} from "./restoration.helpers";
|
||||
|
||||
const log = logger.child({module: "api/agent/status/helpers"});
|
||||
|
||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
|
||||
const databasesResponse = [];
|
||||
|
||||
const masterKeyResult = await getMasterServerKeyContent();
|
||||
const masterKey = Buffer.isBuffer(masterKeyResult) ? masterKeyResult : null;
|
||||
if (!masterKey) {
|
||||
log.error({name: "handleDatabases"}, "Master key unavailable; storages will be sent in plaintext");
|
||||
}
|
||||
|
||||
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null, backupSize: number | null) => ({
|
||||
generatedId: database.agentDatabaseId,
|
||||
dbms: database.dbms,
|
||||
@@ -77,10 +87,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
if (databaseCreated) {
|
||||
|
||||
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.healthcheckLog)
|
||||
.values({
|
||||
@@ -92,7 +99,9 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
|
||||
const storages = await getDatabaseStorageChannels(databaseCreated.id)
|
||||
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null, null));
|
||||
const entry = formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null, null);
|
||||
applyStorageEncryption(entry, body.version, masterKey, agent.id);
|
||||
databasesResponse.push(entry);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -123,7 +132,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
||||
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
|
||||
)
|
||||
),
|
||||
orderBy: [
|
||||
sql`case when ${drizzleDb.schemas.backup.status} = 'waiting' then 0 else 1 end`,
|
||||
desc(drizzleDb.schemas.backup.createdAt)
|
||||
]
|
||||
})
|
||||
|
||||
const restoration = await dbClient.query.restoration.findFirst({
|
||||
@@ -191,6 +204,9 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
|
||||
const errorMessage = "Failed to get backup URL";
|
||||
log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed");
|
||||
|
||||
await handleFailedRestoration(restoration.id, databaseUpdated.id, errorMessage);
|
||||
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -199,6 +215,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set(withUpdatedAt({status: "failed"}))
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
await handleFailedRestoration(restoration.id, databaseUpdated.id, "Restoration crashed unexpectedly");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -208,71 +225,10 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
}
|
||||
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
|
||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta, backupSize));
|
||||
const entry = formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta, backupSize);
|
||||
applyStorageEncryption(entry, body.version, masterKey, agent.id);
|
||||
databasesResponse.push(entry);
|
||||
}
|
||||
}
|
||||
return databasesResponse;
|
||||
}
|
||||
|
||||
|
||||
type PingDatabaseStorageChannels = {
|
||||
id: string;
|
||||
config: any
|
||||
provider: string
|
||||
}
|
||||
|
||||
async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatabaseStorageChannels[]> {
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||
with: {
|
||||
project: true,
|
||||
retentionPolicy: true,
|
||||
alertPolicies: true,
|
||||
storagePolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
return []
|
||||
}
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||
with: {storageChannel: true},
|
||||
});
|
||||
|
||||
const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel
|
||||
? [{
|
||||
id: settings.storageChannel.id,
|
||||
provider: settings.storageChannel.provider,
|
||||
config: settings.storageChannel.config,
|
||||
}]
|
||||
: [];
|
||||
|
||||
|
||||
const enabledDatabaseStorageChannels = await Promise.all(
|
||||
(database.storagePolicies ?? [])
|
||||
.filter(p => p.enabled)
|
||||
.map(async policy => {
|
||||
const storageChannel = await db.query.storageChannel.findFirst({
|
||||
where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId),
|
||||
});
|
||||
|
||||
if (!storageChannel) return null;
|
||||
|
||||
return {
|
||||
id: storageChannel.id,
|
||||
config: storageChannel.config,
|
||||
provider: storageChannel.provider,
|
||||
} as PingDatabaseStorageChannels;
|
||||
})
|
||||
);
|
||||
|
||||
const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter(
|
||||
(c): c is PingDatabaseStorageChannels => c !== null
|
||||
);
|
||||
|
||||
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
|
||||
export type PingDatabaseStorageChannels = {
|
||||
id: string;
|
||||
config: any
|
||||
provider: string
|
||||
}
|
||||
|
||||
export async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatabaseStorageChannels[]> {
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||
with: {
|
||||
project: true,
|
||||
retentionPolicy: true,
|
||||
alertPolicies: true,
|
||||
storagePolicies: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
return []
|
||||
}
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||
with: {storageChannel: true},
|
||||
});
|
||||
|
||||
const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel
|
||||
? [{
|
||||
id: settings.storageChannel.id,
|
||||
provider: settings.storageChannel.provider,
|
||||
config: settings.storageChannel.config,
|
||||
}]
|
||||
: [];
|
||||
|
||||
|
||||
const enabledDatabaseStorageChannels = await Promise.all(
|
||||
(database.storagePolicies ?? [])
|
||||
.filter(p => p.enabled)
|
||||
.map(async policy => {
|
||||
const storageChannel = await db.query.storageChannel.findFirst({
|
||||
where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId),
|
||||
});
|
||||
|
||||
if (!storageChannel) return null;
|
||||
|
||||
return {
|
||||
id: storageChannel.id,
|
||||
config: storageChannel.config,
|
||||
provider: storageChannel.provider,
|
||||
} as PingDatabaseStorageChannels;
|
||||
})
|
||||
);
|
||||
|
||||
const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter(
|
||||
(c): c is PingDatabaseStorageChannels => c !== null
|
||||
);
|
||||
|
||||
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {logger} from "@/lib/logger";
|
||||
import {encryptStorages, isAgentVersionAtLeast, MIN_AGENT_VERSION_STORAGE_ENC} from "@/utils/status-crypto";
|
||||
|
||||
const log = logger.child({module: "api/agent/status/storage-encryption"});
|
||||
|
||||
export function applyStorageEncryption(
|
||||
entry: Record<string, any>,
|
||||
version: string | undefined,
|
||||
masterKey: Buffer | null,
|
||||
agentId: string,
|
||||
): void {
|
||||
if (!masterKey) return;
|
||||
if (!Array.isArray(entry.storages) || entry.storages.length === 0) return;
|
||||
if (!isAgentVersionAtLeast(version, MIN_AGENT_VERSION_STORAGE_ENC)) {
|
||||
console.log(
|
||||
`============================================================\n` +
|
||||
`⚠️OUTDATED AGENT — STORAGE CREDENTIALS SENT UNENCRYPTED\n` +
|
||||
`Agent ${agentId} reports v${version ?? "unknown"} (< required v${MIN_AGENT_VERSION_STORAGE_ENC}).\n` +
|
||||
`Update this agent to v${MIN_AGENT_VERSION_STORAGE_ENC}+ to encrypt storage credentials in transit.\n` +
|
||||
`============================================================`
|
||||
)
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ciphertext = encryptStorages(entry.storages, masterKey);
|
||||
entry.storages_ciphertext = ciphertext;
|
||||
entry.storages_encrypted = true;
|
||||
entry.storages = [];
|
||||
} catch (err) {
|
||||
log.error({error: err, name: "applyStorageEncryption"}, "Storage encryption failed; sending plaintext");
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export const ChannelAddEditModal = ({
|
||||
}: ChannelAddModalProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openInternal, setOpen] = useState(open);
|
||||
const isLocalSystem = channel?.provider == "local";
|
||||
const isLocalSystem = channel?.provider === "local";
|
||||
|
||||
const isCreate = !Boolean(channel);
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export const EditChannelButton = ({
|
||||
}: EditChannelButtonProps) => {
|
||||
const router = useRouter();
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const isLocalSystem = channel.provider == "local";
|
||||
const isLocalSystem = channel.provider === "local";
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -51,10 +51,10 @@ export const EditChannelButton = ({
|
||||
};
|
||||
|
||||
let result: any;
|
||||
if (kind == "notification") {
|
||||
if (kind === "notification") {
|
||||
// @ts-expect-error — payload type varies between notification and storage
|
||||
result = await updateNotificationChannelAction(payload)
|
||||
} else if (kind == "storage") {
|
||||
} else if (kind === "storage") {
|
||||
// @ts-expect-error — payload type varies between notification and storage
|
||||
result = await updateStorageChannelAction(payload)
|
||||
} else {
|
||||
|
||||
@@ -224,13 +224,11 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
||||
|
||||
<div className="flex justify-between mt-4">
|
||||
<div>
|
||||
{(!isCreate || kind == "storage") && (
|
||||
<ChannelTestButton
|
||||
kind={kind}
|
||||
organizationId={organization?.id}
|
||||
channel={form.getValues()}
|
||||
/>
|
||||
)}
|
||||
<ChannelTestButton
|
||||
kind={kind}
|
||||
organizationId={organization?.id}
|
||||
channel={form.getValues()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
@@ -13,6 +13,7 @@ import {cn} from "@/lib/utils";
|
||||
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelKind} from "@/features/channel/components/channels-helpers";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import type {NotificationChannelFormType} from "@/features/channel/schemas/channel-form.schema";
|
||||
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
|
||||
|
||||
|
||||
@@ -34,7 +35,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
|
||||
message: `We are testing channel ${channel.name}`,
|
||||
level: 'info',
|
||||
};
|
||||
const result = await dispatchNotification(payload, undefined, channel.id, organizationId);
|
||||
const result = await dispatchNotification(payload, undefined, undefined, organizationId, channel as unknown as NotificationChannelFormType);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {
|
||||
NotifierSmtpForm
|
||||
} from "@/features/channel/components/notifications/smtp.form";
|
||||
} from "@/features/channel/components/notifications/smtp/smtp.form";
|
||||
import {
|
||||
NotifierSlackForm
|
||||
} from "@/features/channel/components/notifications/slack.form";
|
||||
} from "@/features/channel/components/notifications/slack/slack.form";
|
||||
import {
|
||||
NotifierDiscordForm
|
||||
} from "@/features/channel/components/notifications/discord.form";
|
||||
} from "@/features/channel/components/notifications/discord/discord.form";
|
||||
import {
|
||||
NotifierTelegramForm
|
||||
} from "@/features/channel/components/notifications/telegram.form";
|
||||
} from "@/features/channel/components/notifications/telegram/telegram.form";
|
||||
import {
|
||||
NotifierGotifyForm
|
||||
} from "@/features/channel/components/notifications/gotify.form";
|
||||
} from "@/features/channel/components/notifications/gotify/gotify.form";
|
||||
import {
|
||||
NotifierNtfyForm
|
||||
} from "@/features/channel/components/notifications/ntfy.form";
|
||||
} from "@/features/channel/components/notifications/ntfy/ntfy.form";
|
||||
import {
|
||||
NotifierWebhookForm
|
||||
} from "@/features/channel/components/notifications/webhook.form";
|
||||
} from "@/features/channel/components/notifications/webhook/webhook.form";
|
||||
import {
|
||||
NotifierNextcloudForm
|
||||
} from "@/features/channel/components/notifications/nextcloud.form";
|
||||
} from "@/features/channel/components/notifications/nextcloud/nextcloud.form";
|
||||
import {
|
||||
NotifierPushoverForm
|
||||
} from "@/features/channel/components/notifications/pushover.form";
|
||||
} from "@/features/channel/components/notifications/pushover/pushover.form";
|
||||
import {
|
||||
notificationProviders,
|
||||
} from "@/features/channel/components/channels-notification-helper";
|
||||
import {
|
||||
NotifierTeamsForm
|
||||
} from "@/features/channel/components/notifications/teams.form";
|
||||
} from "@/features/channel/components/notifications/teams/teams.form";
|
||||
import {storageProviders} from "@/features/channel/components/channels-storage-helper";
|
||||
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
|
||||
import {LucideProps} from "lucide-react";
|
||||
import {
|
||||
StorageS3Form
|
||||
} from "@/features/channel/components/storages/s3.form";
|
||||
} from "@/features/channel/components/storages/s3/s3.form";
|
||||
import {
|
||||
StorageGoogleDriveForm
|
||||
} from "@/features/channel/components/storages/google-drive/google-drive.form";
|
||||
import {
|
||||
StorageBlobForm
|
||||
} from "@/features/channel/components/storages/az-blob.form";
|
||||
} from "@/features/channel/components/storages/az-blob/az-blob.form";
|
||||
import {
|
||||
StorageGoogleCloudStorageForm
|
||||
} from "@/features/channel/components/storages/google-cloud-storage/google-cloud-storage.form";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./discord";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./gotify";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./nextcloud";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ntfy";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./pushover";
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
import {PUSHOVER_PRIORITIES} from "@/features/channel/components/notifications/pushover.schema";
|
||||
import {PUSHOVER_PRIORITIES} from "@/features/channel/components/notifications/pushover/pushover.schema";
|
||||
|
||||
type NotifierPushoverFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./slack";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./smtp";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./teams";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./telegram";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./webhook";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./az-blob";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./local";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./s3";
|
||||
+1
-1
@@ -105,7 +105,7 @@ export const StorageS3Form = ({ form }: StorageS3FormProps) => {
|
||||
<FormItem>
|
||||
<FormLabel>Use SSL</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
<Switch checked={field.value ?? true} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user