Compare commits

..
25 Commits
Author SHA1 Message Date
charlesgauthereau e32539e02c chore(release): 1.2.1-rc.2 2026-01-19 13:58:35 +01:00
charlesgauthereau 46ba931a6c fix: Retention cron error. 2026-01-19 13:58:04 +01:00
charlesgauthereau 4bac5143d6 feat: working on mongodb 2026-01-18 21:19:19 +01:00
charlesgauthereau bcb1674491 feat: adding support for mongodb 2026-01-18 20:20:10 +01:00
charlesgauthereau 5074748055 chore(release): 1.2.1-rc.1 2026-01-18 15:30:14 +01:00
charlesgauthereau 6fa11f834b fix: retention delete backup cron action 2026-01-18 15:29:29 +01:00
charlesgauthereau b546556d08 chore(release): 1.2.0 2026-01-18 15:04:11 +01:00
charlesgauthereau 379a85f6f6 chore: .env.example 2026-01-18 15:03:54 +01:00
Charles GTEandGitHub 92d2c14d6a Merge pull request #27 from Portabase/feat/storage
Feat/storage
2026-01-18 15:01:52 +01:00
charlesgauthereau a6c1f7d3f2 chore: Merging errors due to merge with main. 2026-01-18 15:00:53 +01:00
charlesgauthereau 6fa6187cc4 Merge remote-tracking branch 'origin/main' into feat/storage
# Conflicts:
#	src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx
#	src/components/wrappers/dashboard/admin/notifications/helpers.tsx
#	src/db/migrations/meta/0020_snapshot.json
#	src/db/migrations/meta/_journal.json
2026-01-18 14:40:26 +01:00
charlesgauthereau 848e7a1609 chore: .gitignore 2026-01-18 14:35:39 +01:00
Charles GTEandGitHub 3e8c0a2896 Delete .vscode directory 2026-01-18 14:20:12 +01:00
Charles GTEandGitHub 11900b1500 Delete .idea directory 2026-01-18 14:19:16 +01:00
Killian LarcherandGitHub 5668a1411f Merge pull request #26 from Portabase/feat/coming-soon-preview
feat(ui): add preview for upcoming notification provider
2026-01-18 12:37:29 +01:00
killianlarcher 6db60f111d feat(ui): add preview for upcoming notification provider 2026-01-18 12:36:12 +01:00
Killian LarcherandGitHub 5c682addc9 Merge pull request #25 from Portabase/fix/profile
fix(ui) : improve profile modal
2026-01-18 10:29:38 +01:00
Killian LarcherandGitHub 24810cc818 Merge pull request #24 from Portabase/feat/last-connected
feat : add last login date
2026-01-18 10:29:19 +01:00
Killian LarcherandGitHub 8caeb11da5 Merge pull request #21 from Portabase/feat/connection-indicator
feat(ui): add pulse effect to connection indicator
2026-01-18 10:28:52 +01:00
Killian LarcherandGitHub 5e07b446fc Merge pull request #23 from Portabase/fix/development-conf
fix: update configuration for local development
2026-01-18 10:28:37 +01:00
killianlarcher 760388307c fix(ui): reduce opacity on avatar hover and add bottom padding to profile modal 2026-01-17 20:40:57 +01:00
killianlarcher eebd8500ee feat: add lastConnectedAt to user schema and display it in user settings 2026-01-17 20:27:06 +01:00
killianlarcher 12112afa37 feat(ui):resize connection indicator and add pulse effect for all use cases. 2026-01-17 20:06:01 +01:00
killianlarcher cddb0a7e05 feat(ui): add pulse effect to connection indicator 2026-01-17 19:36:28 +01:00
killianlarcher 21b317e4f1 fix: Update .env.example and docker-compose.yml for local development configuration 2026-01-17 17:44:31 +01:00
37 changed files with 4886 additions and 167 deletions
+4 -15
View File
@@ -1,18 +1,18 @@
# Environnement # Environnement
NODE_ENV=production NODE_ENV=development
# Base de données # Base de données
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public DATABASE_URL=postgresql://devuser:changeme@localhost:5432/devdb?schema=public
# Projet # Projet
PROJECT_NAME="Portabase" PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager" PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io PROJECT_URL=http://localhost:8887
PROJECT_SECRET= PROJECT_SECRET=
# SMTP (email) # SMTP (email)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM= SMTP_FROM=
@@ -22,16 +22,5 @@ AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET= AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD= AUTH_GOOGLE_METHOD=
# S3
S3_ENDPOINT=http://app.s3.portabase.io
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET_NAME=portabase
S3_PORT=9000
S3_USE_SSL=true
# Storage Type (s3, local)
STORAGE_TYPE=local
# Retention # Retention
RETENTION_CRON="* * * * *" RETENTION_CRON="* * * * *"
+1
View File
@@ -1,5 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea .idea
.vscode
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
-8
View File
@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/portabase.iml" filepath="$PROJECT_DIR$/.idea/portabase.iml" />
</modules>
</component>
</project>
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-3
View File
@@ -1,3 +0,0 @@
{
"WillLuke.nextjs.hasPrompted": true
}
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.2.0-rc.1 version: 1.2.1-rc.2
date-released: "2026-01-18" date-released: "2026-01-19"
+9 -1
View File
@@ -34,6 +34,9 @@ export async function POST(
const generatedId = formData.get("generatedId") as string | null; const generatedId = formData.get("generatedId") as string | null;
const method = formData.get("method") as string | null; const method = formData.get("method") as string | null;
if (!generatedId || !isUuidv4(generatedId)) { if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json( return NextResponse.json(
{error: "generatedId is not a valid UUID"}, {error: "generatedId is not a valid UUID"},
@@ -113,6 +116,7 @@ export async function POST(
} }
if (!file) { if (!file) {
return NextResponse.json( return NextResponse.json(
{error: "File is required for successful backup"}, {error: "File is required for successful backup"},
@@ -127,7 +131,11 @@ export async function POST(
const fileName = `${uuid}${fileExtension}`; const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(await decryptedFile.arrayBuffer()); const buffer = Buffer.from(await decryptedFile.arrayBuffer());
await storeBackupFiles(backup, database, buffer, fileName)
console.log(extension)
const storageResults = await storeBackupFiles(backup, database, buffer, fileName)
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, "success_backup"); await sendNotificationsBackupRestore(database, "success_backup");
+7 -1
View File
@@ -7,7 +7,7 @@ import {Database} from "@/db/schema/07_database";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db as dbClient} from "@/db"; import {db as dbClient} from "@/db";
import {and, eq, inArray} from "drizzle-orm"; import {and, eq, inArray} from "drizzle-orm";
import {EDbmsSchema} from "@/db/schema/types"; import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {ServerActionResult} from "@/types/action-type"; import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action"; import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod"; import {ZodString} from "zod";
@@ -50,6 +50,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
{status: 500} {status: 500}
); );
} }
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
console.log(`Database type not available: ${db.dbms}`);
continue;
}
const [databaseCreated] = await dbClient const [databaseCreated] = await dbClient
.insert(drizzleDb.schemas.database) .insert(drizzleDb.schemas.database)
.values({ .values({
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.2.0-rc.1", "version": "1.2.1-rc.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -74,6 +74,7 @@
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"npm-check-updates": "^18.0.1", "npm-check-updates": "^18.0.1",
"pg": "^8.16.0", "pg": "^8.16.0",
"prettier": "^3.8.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "9.7.0", "react-day-picker": "9.7.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
+8 -5
View File
@@ -188,6 +188,9 @@ importers:
pg: pg:
specifier: ^8.16.0 specifier: ^8.16.0
version: 8.16.3 version: 8.16.3
prettier:
specifier: ^3.8.0
version: 3.8.0
react: react:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.3 version: 19.2.3
@@ -5669,8 +5672,8 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prettier@3.7.4: prettier@3.8.0:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} resolution: {integrity: sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==}
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
@@ -9473,7 +9476,7 @@ snapshots:
'@react-email/render@1.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': '@react-email/render@1.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies: dependencies:
html-to-text: 9.0.5 html-to-text: 9.0.5
prettier: 3.7.4 prettier: 3.8.0
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
react-promise-suspense: 0.3.4 react-promise-suspense: 0.3.4
@@ -9481,7 +9484,7 @@ snapshots:
'@react-email/render@2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': '@react-email/render@2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies: dependencies:
html-to-text: 9.0.5 html-to-text: 9.0.5
prettier: 3.7.4 prettier: 3.8.0
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
@@ -12798,7 +12801,7 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier@3.7.4: {} prettier@3.8.0: {}
pretty-bytes@6.1.1: {} pretty-bytes@6.1.1: {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,27 +0,0 @@
import {cn} from "@/lib/utils";
export type ConnectionCircleProps = {
date?: Date | null;
};
export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
let style = "bg-gray-300 border-gray-400";
if (date instanceof Date && !isNaN(date.getTime())) {
const now = Date.now();
const timestamp = date.getTime();
const interval_seconds = (now - timestamp) / 1000;
if (interval_seconds < 55) {
style = "bg-green-400 border-green-600";
} else if (interval_seconds <= 60) {
style = "bg-orange-400 border-orange-600";
} else {
style = "bg-red-400 border-red-600";
}
} else {
console.warn("Invalid date passed to ConnectionCircle:", date);
}
return <div className={cn("w-5 h-5 rounded-full border-4", style)}/>;
};
@@ -0,0 +1,46 @@
import {cn} from "@/lib/utils";
export type ConnectionIndicatorProps = {
date?: Date | null;
};
export const ConnectionIndicator = ({date}: ConnectionIndicatorProps) => {
let style = "bg-gray-300";
if (date instanceof Date && !isNaN(date.getTime())) {
const intervalSeconds = (Date.now() - date.getTime()) / 1000;
if (intervalSeconds < 55) {
style = "bg-green-500";
} else if (intervalSeconds <= 60) {
style = "bg-orange-400";
} else {
style = "bg-red-500";
}
}
return (
<div className="relative w-3 h-3">
<span
className={cn(
"absolute -inset-0.25 rounded-full opacity-60 animate-ping",
style
)}
style={{
animationDuration: "2s",
}}
/>
<div
className={cn(
"relative w-3 h-3 rounded-full shadow-sm animate-pulse",
style
)}
style={{
animationDuration: "2s",
}}
/>
</div>
);
};
@@ -25,8 +25,8 @@ import {
ChannelKind, ChannelKind,
renderChannelForm renderChannelForm
} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; } from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {storageTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/storage"; import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
import {notificationTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/notification"; import {notificationProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
import { import {
ChannelTestButton ChannelTestButton
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button"; } from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button";
@@ -94,7 +94,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
}); });
const provider = form.watch("provider"); const provider = form.watch("provider");
const channelTypes = kind == "notification" ? notificationTypes : storageTypes const channelTypes = kind == "notification" ? notificationProviders : storageProviders
const selectedProviderDetails = channelTypes.find(t => t.value === provider); const selectedProviderDetails = channelTypes.find(t => t.value === provider);
if (isCreate && !provider) { if (isCreate && !provider) {
@@ -106,18 +106,43 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
<Card <Card
key={type.value} key={type.value}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-3 p-4 cursor-pointer hover:bg-accent/50 hover:border-primary/50 transition-all", "relative flex flex-col items-center justify-center gap-3 p-4 transition-all",
type.preview
? "cursor-not-allowed bg-gray-200 border-gray-300 opacity-70"
: "cursor-pointer hover:bg-accent/50 hover:border-primary/50"
)} )}
onClick={() => { onClick={() => {
if (type.preview) return;
form.setValue("provider", type.value as any); form.setValue("provider", type.value as any);
form.setValue("config", {}); form.setValue("config", {});
}} }}
> >
<div className="h-10 w-10 bg-secondary rounded-full flex items-center justify-center"> {type.preview && (
<Icon className="h-6 w-6 text-foreground"/> <div
className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
<div
className="absolute bottom-0 right-0 w-38 h-6 flex items-center justify-center
transform -rotate-45 translate-x-14 -translate-y-4"
style={{backgroundColor: "#FE6702"}}
>
<span className="text-white text-[8px] pr-3 font-medium text-center w-full">coming soon</span>
</div>
</div>
)}
<div className={cn(
"h-10 w-10 rounded-full flex items-center justify-center",
type.preview ? "bg-gray-400" : "bg-secondary"
)}>
<Icon className={cn("h-6 w-6 text-foreground", type.preview && "text-gray-700")}/>
</div> </div>
<span className="font-medium text-sm">{type.label}</span> <span className={cn(
"font-medium text-sm align-middle text-center",
type.preview ? "text-gray-500" : "text-foreground"
)}>
{type.label}
</span>
</Card> </Card>
); );
})} })}
</div> </div>
@@ -20,11 +20,10 @@ import {
import { import {
NotifierWebhookForm NotifierWebhookForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form"; } from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form";
import {notificationTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/notification"; import {
import {storageTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/storage"; notificationProviders,
import {OrganizationWithMembers} from "@/db/schema/03_organization"; } from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react"; import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
import {LucideProps} from "lucide-react"; import {LucideProps} from "lucide-react";
import { import {
@@ -45,19 +44,21 @@ export function getChannelTextBasedOnKind(kind: ChannelKind) {
} }
type ProviderIconTypes = { export type ProviderIconTypes = {
value: string value: string
label: string label: string
icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>> icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>
preview?: boolean
} | { } | {
value: string value: string
label: string label: string
icon: (props: SVGProps<SVGSVGElement>) => JSX.Element icon: (props: SVGProps<SVGSVGElement>) => JSX.Element
preview?: boolean
} }
const providerIcons: ProviderIconTypes[] = [ export const providerIcons: ProviderIconTypes[] = [
...notificationTypes, ...notificationProviders,
...storageTypes, ...storageProviders,
]; ];
@@ -1,9 +1,11 @@
import {Mail} from "lucide-react"; import {Mail} from "lucide-react";
import type { SVGProps } from 'react'; import type { SVGProps } from 'react';
import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
export const notificationTypes = [
export const notificationProviders: ProviderIconTypes[] = [
{value: "smtp", label: "Email", icon: Mail}, {value: "smtp", label: "Email", icon: Mail},
{value: "slack", label: "Slack", icon: SlackIcon}, {value: "slack", label: "Slack", icon: SlackIcon},
{value: "discord", label: "Discord", icon: DiscordIcon}, {value: "discord", label: "Discord", icon: DiscordIcon},
@@ -11,6 +13,7 @@ export const notificationTypes = [
{value: "gotify", label: "Gotify", icon: GotifyIcon}, {value: "gotify", label: "Gotify", icon: GotifyIcon},
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon}, {value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
{value: "webhook", label: "Webhook", icon: WebhookIcon}, {value: "webhook", label: "Webhook", icon: WebhookIcon},
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
] ]
@@ -481,3 +484,49 @@ export function WebhookIcon(props: SVGProps<SVGSVGElement>) {
) )
} }
export function MSTeamsIcon(props: SVGProps<SVGSVGElement>) {
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 239" {...props}>
<defs>
<path id="logosMicrosoftTeams0"
d="M136.93 64.476v12.8a32.674 32.674 0 0 1-5.953-.952a38.698 38.698 0 0 1-26.79-22.742h21.848c6.008.022 10.872 4.887 10.895 10.894Z"
opacity=".2"/>
</defs>
<defs>
<linearGradient id="logosMicrosoftTeams1" x1="17.372%" x2="82.628%" y1="-6.51%" y2="106.51%">
<stop offset="0%" stop-color="#5A62C3"/>
<stop offset="50%" stop-color="#4D55BD"/>
<stop offset="100%" stop-color="#3940AB"/>
</linearGradient>
</defs>
<path fill="#5059C9"
d="M178.563 89.302h66.125c6.248 0 11.312 5.065 11.312 11.312v60.231c0 22.96-18.613 41.574-41.573 41.574h-.197c-22.96.003-41.576-18.607-41.579-41.568V95.215a5.912 5.912 0 0 1 5.912-5.913Z"/>
<circle cx="223.256" cy="50.605" r="26.791" fill="#5059C9"/>
<circle cx="139.907" cy="38.698" r="38.698" fill="#7B83EB"/>
<path fill="#7B83EB"
d="M191.506 89.302H82.355c-6.173.153-11.056 5.276-10.913 11.449v68.697c-.862 37.044 28.445 67.785 65.488 68.692c37.043-.907 66.35-31.648 65.489-68.692v-68.697c.143-6.173-4.74-11.296-10.913-11.449Z"/>
<path
d="M142.884 89.302v96.268a10.96 10.96 0 0 1-6.787 10.062c-1.3.55-2.697.833-4.108.833H76.68c-.774-1.965-1.488-3.93-2.084-5.953a72.509 72.509 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h60.547Z"
opacity=".1"/>
<path
d="M136.93 89.302v102.222c0 1.411-.283 2.808-.833 4.108a10.96 10.96 0 0 1-10.062 6.787H79.48c-1.012-1.965-1.965-3.93-2.798-5.954a59.049 59.049 0 0 1-2.084-5.953a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h54.593Z"
opacity=".2"/>
<path
d="M136.93 89.302v90.315c-.045 5.998-4.896 10.85-10.895 10.895H74.597a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h54.593Z"
opacity=".2"/>
<path
d="M130.977 89.302v90.315c-.046 5.998-4.897 10.85-10.895 10.895H74.597a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h48.64Z"
opacity=".2"/>
<path
d="M142.884 58.523v18.753c-1.012.06-1.965.12-2.977.12c-1.012 0-1.965-.06-2.977-.12a32.674 32.674 0 0 1-5.953-.952a38.698 38.698 0 0 1-26.791-22.742a33.082 33.082 0 0 1-1.905-5.954h29.708c6.007.023 10.872 4.887 10.895 10.895Z"
opacity=".1"/>
<use href="#logosMicrosoftTeams0" opacity=".2"/>
<use href="#logosMicrosoftTeams0" opacity=".2"/>
<path d="M130.977 64.476v11.848a38.698 38.698 0 0 1-26.791-22.743h15.896c6.008.023 10.872 4.888 10.895 10.895Z"
opacity=".2"/>
<path fill="url(#logosMicrosoftTeams1)"
d="M10.913 53.581h109.15c6.028 0 10.914 4.886 10.914 10.913v109.151c0 6.027-4.886 10.913-10.913 10.913H10.913C4.886 184.558 0 179.672 0 173.645V64.495C0 58.466 4.886 53.58 10.913 53.58Z"/>
<path fill="#FFF" d="M94.208 95.125h-21.82v59.416H58.487V95.125H36.769V83.599h57.439z"/>
</svg>)
}
@@ -1,7 +1,8 @@
import {Server} from "lucide-react"; import {Server} from "lucide-react";
import type {SVGProps} from "react"; import type {SVGProps} from "react";
import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
export const storageTypes = [ export const storageProviders: ProviderIconTypes[] = [
{value: "local", label: "Local", icon: Server}, {value: "local", label: "Local", icon: Server},
{value: "s3", label: "s3", icon: S3Icon}, {value: "s3", label: "s3", icon: S3Icon},
] ]
@@ -46,14 +46,14 @@ export function usersListColumns(): ColumnDef<User>[] {
<TableCell <TableCell
className="text-right text-muted-foreground">{formatLocalizedDate(row.original.createdAt)}</TableCell> className="text-right text-muted-foreground">{formatLocalizedDate(row.original.createdAt)}</TableCell>
</TableRow> </TableRow>
{/*{row.original.lastConnectedAt && (*/} {row.original.lastConnectedAt && (
{/* <TableRow>*/} <TableRow>
{/* <TableCell>Last connected</TableCell>*/} <TableCell>Last connected</TableCell>
{/* <TableCell className="text-right text-muted-foreground">*/} <TableCell className="text-right text-muted-foreground">
{/* {formatLocalizedDate(row.original.lastConnectedAt)} ({timeAgo(row.original.lastConnectedAt, locale)})*/} {formatLocalizedDate(row.original.lastConnectedAt)} ({timeAgo(row.original.lastConnectedAt)})
{/* </TableCell>*/} </TableCell>
{/* </TableRow>*/} </TableRow>
{/*)}*/} )}
{row.original.lastChangedPasswordAt && ( {row.original.lastChangedPasswordAt && (
<TableRow> <TableRow>
<TableCell>Last password change</TableCell> <TableCell>Last password change</TableCell>
@@ -3,10 +3,10 @@
import {useState} from "react"; import {useState} from "react";
import Link from "next/link"; import Link from "next/link";
import {Card} from "@/components/ui/card"; import {Card} from "@/components/ui/card";
import {ConnectionCircle} from "@/components/wrappers/common/connection-circle"; import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
import {formatDateLastContact} from "@/utils/date-formatting"; import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentWith} from "@/db/schema/08_agent"; import {AgentWith} from "@/db/schema/08_agent";
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database, ShieldCheck} from "lucide-react"; import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database} from "lucide-react";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text"; import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
@@ -86,7 +86,7 @@ export const AgentCard = (props: agentCardProps) => {
</div> </div>
<div className="scale-110"> <div className="scale-110">
<ConnectionCircle date={agent.lastContact}/> <ConnectionIndicator date={agent.lastContact}/>
</div> </div>
<ChevronRight className="w-5 h-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" /> <ChevronRight className="w-5 h-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
@@ -26,7 +26,7 @@ type ProfileModalProps = {
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => { export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background"> <DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Settings</DialogTitle> <DialogTitle>Settings</DialogTitle>
<DialogDescription>Manage your account settings</DialogDescription> <DialogDescription>Manage your account settings</DialogDescription>
@@ -73,7 +73,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
fileInput.onchange = handleImageUpload; fileInput.onchange = handleImageUpload;
fileInput.click(); fileInput.click();
}} }}
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-100 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32" className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32"
> >
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary"/> <UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary"/>
</div> </div>
@@ -4,7 +4,7 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import {useState} from "react"; import {useState} from "react";
import {Card} from "@/components/ui/card"; import {Card} from "@/components/ui/card";
import {ConnectionCircle} from "@/components/wrappers/common/connection-circle"; import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
import {formatDateLastContact} from "@/utils/date-formatting"; import {formatDateLastContact} from "@/utils/date-formatting";
import {Database} from "@/db/schema/07_database"; import {Database} from "@/db/schema/07_database";
import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react"; import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react";
@@ -60,7 +60,7 @@ export const DatabaseCard = (props: databaseCardProps) => {
</div> </div>
<div className="flex flex-col items-end gap-3"> <div className="flex flex-col items-end gap-3">
<div className="scale-100 origin-right"> <div className="scale-100 origin-right">
<ConnectionCircle date={database.lastContact}/> <ConnectionIndicator date={database.lastContact}/>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "lastConnectedAt" timestamp;
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "lastConnectedAt" timestamp;
@@ -0,0 +1 @@
ALTER TYPE "public"."dbms_status" ADD VALUE 'mongodb';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -204,6 +204,20 @@
"when": 1768733221276, "when": 1768733221276,
"tag": "0028_graceful_ben_parker", "tag": "0028_graceful_ben_parker",
"breakpoints": true "breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1768743644834,
"tag": "0029_lowly_white_tiger",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1768763883339,
"tag": "0030_dizzy_morlocks",
"breakpoints": true
} }
] ]
} }
+2 -1
View File
@@ -3,7 +3,7 @@ import {boolean, integer, pgEnum, pgTable, text, timestamp, uuid} from "drizzle-
import {createSelectSchema} from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import {z} from "zod"; import {z} from "zod";
import {project} from "./06_project"; import {project} from "./06_project";
import {member, OrganizationMember} from "@/db/schema/04_member"; import {member} from "@/db/schema/04_member";
import {invitation} from "@/db/schema/05_invitation"; import {invitation} from "@/db/schema/05_invitation";
import {organization} from "@/db/schema/03_organization"; import {organization} from "@/db/schema/03_organization";
import {Account as BetterAuthAccount} from "better-auth"; import {Account as BetterAuthAccount} from "better-auth";
@@ -23,6 +23,7 @@ export const user = pgTable("user", {
banned: boolean("banned"), banned: boolean("banned"),
banReason: text("ban_reason"), banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"), banExpires: timestamp("ban_expires"),
lastConnectedAt: timestamp(),
lastChangedPasswordAt: timestamp(), lastChangedPasswordAt: timestamp(),
twoFactorEnabled: boolean("two_factor_enabled").default(false), twoFactorEnabled: boolean("two_factor_enabled").default(false),
...timestamps ...timestamps
+4 -4
View File
@@ -1,8 +1,8 @@
import { pgEnum } from "drizzle-orm/pg-core"; import {pgEnum} from "drizzle-orm/pg-core";
import { createSelectSchema } from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import { z } from "zod"; import {z} from "zod";
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql"]); export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb"]);
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]); export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]); export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
+3 -3
View File
@@ -18,9 +18,9 @@ export const retentionCleanTask = async () => {
}, },
}, },
}); });
console.log(`Retention databases number: ${databases.length}`);
for (const db of databases) { for (const db of databases) {
if (!db.retentionPolicy) continue; // no policy = skip if (!db.retentionPolicy) continue;
await enforceRetention(db.id, db.retentionPolicy); await enforceRetention(db.id, db.retentionPolicy);
} }
} catch (e: any) { } catch (e: any) {
@@ -33,7 +33,7 @@ export async function enforceRetention(
databaseId: string, databaseId: string,
policy: typeof retentionPolicy.$inferSelect policy: typeof retentionPolicy.$inferSelect
) { ) {
console.log(`Retention started for ${databaseId}`);
switch (policy.type) { switch (policy.type) {
case "count": case "count":
await enforceRetentionCount(databaseId, policy.count ?? 7); await enforceRetentionCount(databaseId, policy.count ?? 7);
+12 -4
View File
@@ -2,8 +2,10 @@ import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {and, desc, eq, isNull} from "drizzle-orm"; import {and, desc, eq, isNull} from "drizzle-orm";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
import {toast} from "sonner";
export async function enforceRetentionCount(databaseId: string, count: number) { export async function enforceRetentionCount(databaseId: string, count: number) {
console.log(`[Retention Count] - ${databaseId} : started`);
const backups = await db.query.backup.findMany({ const backups = await db.query.backup.findMany({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)), where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)),
orderBy: desc(drizzleDb.schemas.backup.createdAt), orderBy: desc(drizzleDb.schemas.backup.createdAt),
@@ -16,14 +18,20 @@ export async function enforceRetentionCount(databaseId: string, count: number) {
} }
}); });
const toDelete = backups.slice(count); // keep first `count`, delete rest const toDelete = backups.slice(count);
console.log(`[Retention Count] - ${databaseId} : ${toDelete.length} backups to delete`);
for (const b of toDelete) { for (const b of toDelete) {
await deleteBackupCronAction({ const result = await deleteBackupCronAction({
backupId: b.id, backupId: b.id,
databaseId: b.databaseId, databaseId: b.databaseId,
file: b.file ?? "",
projectSlug: b.database.project?.slug!
}); });
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention Count] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
} else {
console.log(`[Retention Count] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
}
} }
} }
+9 -3
View File
@@ -4,6 +4,7 @@ import * as drizzleDb from "@/db";
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
export async function enforceRetentionDays(databaseId: string, days: number) { export async function enforceRetentionDays(databaseId: string, days: number) {
console.log(`Enforce Retention Days starting for ${databaseId}`);
const cutoff = new Date(Date.now() - days * 86400000); const cutoff = new Date(Date.now() - days * 86400000);
const expiredBackups = await db.query.backup.findMany({ const expiredBackups = await db.query.backup.findMany({
@@ -22,13 +23,18 @@ export async function enforceRetentionDays(databaseId: string, days: number) {
}); });
for (const backup of expiredBackups) { for (const backup of expiredBackups) {
await deleteBackupCronAction({
const result = await deleteBackupCronAction({
backupId: backup.id, backupId: backup.id,
databaseId: backup.databaseId, databaseId: backup.databaseId,
file: backup.file ?? "",
projectSlug: backup.database.project?.slug!
}); });
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention Days] - (databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : successfully deleted`);
} else {
console.log(`[Retention Days] - (databaseId:${backup.databaseId}) - (backupId: ${backup.id}) : an error occurred - ${inner?.actionError?.message}`);
}
} }
} }
+11 -15
View File
@@ -10,6 +10,8 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
monthly: number; monthly: number;
yearly: number; yearly: number;
}) { }) {
console.log(`Enforce Retention GFS starting for ${databaseId}`);
const backups = await db.query.backup.findMany({ const backups = await db.query.backup.findMany({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)), where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)),
orderBy: desc(drizzleDb.schemas.backup.createdAt), orderBy: desc(drizzleDb.schemas.backup.createdAt),
@@ -30,15 +32,7 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
if (b.createdAt >= subDays(now, gfsSettings.daily)) toKeep.add(b.id); if (b.createdAt >= subDays(now, gfsSettings.daily)) toKeep.add(b.id);
}); });
// WEEKLY const weekStartDates = Array.from({length: gfsSettings.weekly}, (_, i) => startOfWeek(subWeeks(now, i), {weekStartsOn: 1}));
// const weekStartDates = Array.from({length: gfsSettings.weekly}, (_, i) => startOfWeek(subWeeks(now, i)));
// weekStartDates.forEach((weekStart) => {
// const backupOfWeek = backups.find(
// (b) => b.createdAt >= weekStart && b.createdAt < subWeeks(weekStart, -1)
// );
// if (backupOfWeek) toKeep.add(backupOfWeek.id);
// });
const weekStartDates = Array.from({length: gfsSettings.weekly}, (_, i) => startOfWeek(subWeeks(now, i), { weekStartsOn: 1 }));
weekStartDates.forEach((weekStart) => { weekStartDates.forEach((weekStart) => {
const backupOfWeek = backups.find( const backupOfWeek = backups.find(
(b) => b.createdAt >= weekStart && b.createdAt < subWeeks(weekStart, -1) (b) => b.createdAt >= weekStart && b.createdAt < subWeeks(weekStart, -1)
@@ -46,9 +40,6 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
if (backupOfWeek) toKeep.add(backupOfWeek.id); if (backupOfWeek) toKeep.add(backupOfWeek.id);
}); });
// MONTHLY // MONTHLY
const monthStartDates = Array.from({length: gfsSettings.monthly}, (_, i) => startOfMonth(subMonths(now, i))); const monthStartDates = Array.from({length: gfsSettings.monthly}, (_, i) => startOfMonth(subMonths(now, i)));
monthStartDates.forEach((monthStart) => { monthStartDates.forEach((monthStart) => {
@@ -71,12 +62,17 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
for (const b of backups) { for (const b of backups) {
if (!toKeep.has(b.id)) { if (!toKeep.has(b.id)) {
await deleteBackupCronAction({ const result = await deleteBackupCronAction({
backupId: b.id, backupId: b.id,
databaseId: b.databaseId, databaseId: b.databaseId,
file: b.file ?? "",
projectSlug: b.database.project?.slug!
}); });
const inner = result?.data;
if (inner?.success) {
console.log(`[Retention GFS] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : successfully deleted`);
} else {
console.log(`[Retention GFS] - (databaseId:${b.databaseId}) - (backupId: ${b.id}) : an error occurred - ${inner?.actionError?.message}`);
}
} }
} }
} }
+3 -15
View File
@@ -16,17 +16,16 @@ export const deleteBackupCronAction = action
z.object({ z.object({
backupId: z.string(), backupId: z.string(),
databaseId: z.string(), databaseId: z.string(),
projectSlug: z.string(),
file: z.string(),
}) })
) )
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => { .action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
try { try {
const backup = await db.query.backup.findFirst({ const backup = await db.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.backupId)) where: and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))
}); });
if (!backup) { if (!backup) {
return { return {
success: false, success: false,
@@ -44,17 +43,6 @@ export const deleteBackupCronAction = action
}); });
if (!backupStorages) {
return {
success: false,
actionError: {
message: "Backup storage not found.",
status: 404,
messageParams: {backupId: parsedInput.backupId},
},
};
}
for (const backupStorage of backupStorages) { for (const backupStorage of backupStorages) {
await db await db
@@ -83,7 +71,7 @@ export const deleteBackupCronAction = action
.set(withUpdatedAt({ .set(withUpdatedAt({
deletedAt: new Date(), deletedAt: new Date(),
})) }))
.where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.backupId))) .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
return { return {
success: true, success: true,