Compare commits

..
Author SHA1 Message Date
charlesgauthereau 2bd25353da chore(release): 1.1.12-rc.3 2026-01-11 14:13:58 +01:00
charlesgauthereau e1bee19c18 fix(ci): docker.yml 2026-01-11 14:13:51 +01:00
charlesgauthereau 0ec49af72a chore(release): 1.1.12-rc.2 2026-01-11 14:12:43 +01:00
charlesgauthereau 6d4eb836ab fix(ci): working on github actions to add new docker repository 2026-01-11 14:12:40 +01:00
charlesgauthereau 0c3cfecb1d chore(release): 1.1.12-rc.1 2026-01-11 13:54:35 +01:00
charlesgauthereau f9186e7354 fix: overflow in agent-card.tsx 2026-01-11 13:54:22 +01:00
charlesgauthereau bc603a08cf fix: Remove card in some forms. 2026-01-11 13:52:02 +01:00
charlesgauthereau 077aada056 chore(release): 1.1.11-rc.2 2026-01-11 13:36:57 +01:00
charlesgauthereau 4ed5f9872d fix: delete backup crash if no file. 2026-01-11 13:36:11 +01:00
charlesgauthereau 2d6be20b30 fix: improving organization-form.tsx UX/UI and removed the card. 2026-01-11 12:41:39 +01:00
charlesgauthereau be8f3bd98c fix: organization settings page responsive on mobile. 2026-01-11 12:37:57 +01:00
charlesgauthereau a3a20bee4a fix: agent-card.tsx overflow on mobile. 2026-01-11 12:23:32 +01:00
charlesgauthereau 09a1e75acf fix: notifier-card.tsx overflow on mobile. 2026-01-11 12:12:14 +01:00
charlesgauthereau ce6bdd01ef fix: overflow mobile view on the notification provider card in detabase details page. 2026-01-11 11:57:18 +01:00
charlesgauthereau a03a1d635f fix: responsive in project details. 2026-01-11 10:59:42 +01:00
charlesgauthereau 419b6d4891 chore(release): 1.1.11-rc.1 2026-01-09 19:16:17 +01:00
charlesgauthereau b4840318d5 fix: database page Backup button and route api. 2026-01-09 19:15:27 +01:00
Théo LAGACHE bf81f437d1 chore(release): 1.1.10 2026-01-07 15:29:53 +01:00
Théo LAGACHEandGitHub 81a4d33df1 Merge pull request #7 from Portabase/feat/webhooks
Feat/webhooks
2026-01-07 15:29:30 +01:00
27 changed files with 402 additions and 403 deletions
+35 -8
View File
@@ -1,3 +1,4 @@
name: Docker Publish
on:
@@ -7,6 +8,10 @@ on:
required: false
type: string
default: 'solucetechnologies/portabase'
image_name2:
required: false
type: string
default: 'portabase/portabase'
add_latest:
required: false
type: boolean
@@ -24,6 +29,10 @@ on:
required: true
DOCKER_PASSWORD:
required: true
DOCKER_USERNAME_2:
required: true
DOCKER_PASSWORD_2:
required: true
jobs:
build-and-push:
@@ -37,27 +46,45 @@ jobs:
with:
fetch-depth: 0
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set tags
id: set-tags
run: |
REF_NAME=${GITHUB_REF#refs/tags/}
TAGS="${{ inputs.image_name }}:$REF_NAME"
TAGS2="${{ inputs.image_name2 }}:$REF_NAME"
if [[ "${{ inputs.add_latest }}" == "true" ]]; then
TAGS="$TAGS,${{ inputs.image_name }}:latest"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "tags=$TAGS2" >> $GITHUB_OUTPUT
- name: Build and push Docker image
- name: Log in to Docker Hub account 1
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push to solucetechnologies/portabase
uses: docker/build-push-action@v6
with:
context: .
file: ${{ inputs.dockerfile }}
push: true
tags: ${{ steps.set-tags.outputs.tags }}
tags: ${{ steps.set-tags.outputs.tags1 }}
target: ${{ inputs.target }}
- name: Log in to Docker Hub account 2
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME_2 }}
password: ${{ secrets.DOCKER_PASSWORD_2 }}
- name: Build and push to portabase/portabase
uses: docker/build-push-action@v6
with:
context: .
file: ${{ inputs.dockerfile }}
push: true
tags: ${{ steps.set-tags.outputs.tags2 }}
target: ${{ inputs.target }}
+2
View File
@@ -17,6 +17,8 @@ jobs:
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release:
needs: docker_publish
+2
View File
@@ -18,6 +18,8 @@ jobs:
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
github_release:
needs: docker_publish
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.1.10-rc.2
date-released: "2026-01-07"
version: 1.1.12-rc.3
date-released: "2026-01-11"
@@ -33,16 +33,22 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{capitalizeFirstLetter(agent.name)}
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/agents/${agent.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(agent.name)}
</div>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/agents/${agent.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</div>
</div>
</PageTitle>
<PageActions className="justify-between">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</PageActions>
</div>
{agent.description && (
@@ -22,7 +22,7 @@ export default async function RoutePage(props: PageParams<{}>) {
with: {
databases: true
},
orderBy: (fields) => desc(fields.createdAt),
orderBy: (fields) => desc(fields.lastContact),
});
@@ -60,7 +60,7 @@ export default async function RoutePage(props: PageParams<{
orderBy: (r, {desc}) => [desc(r.createdAt)],
});
const isAlreadyBackup = backups.some((b) => b.status === "waiting");
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})
@@ -15,6 +15,11 @@ import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {AlertPolicyModal} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-modal";
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
export default async function RoutePage(props: PageParams<{
projectId: string
@@ -55,22 +60,25 @@ export default async function RoutePage(props: PageParams<{
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{capitalizeFirstLetter(proj.name)}
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(proj.name)}
</div>
{!isMember && (
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</div>
</div>
)}
</PageTitle>
{!isMember && (
<PageActions className="justify-between">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</PageActions>
)}
</div>
<PageDescription>The list of associated databases</PageDescription>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
@@ -11,6 +11,13 @@ import {Metadata} from "next";
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import {capitalizeFirstLetter} from "@/utils/text";
import Link from "next/link";
import {buttonVariants} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import {
ButtonDeleteProject
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
export const metadata: Metadata = {
title: "Settings",
@@ -26,25 +33,29 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
}
const notificationChannels = await getOrganizationChannels(organization.id)
const permissions = computeOrganizationPermissions(activeMember);
return (
<Page>
<PageHeader>
<PageTitle className="flex items-center">
Organization settings
{permissions.canManageSettings && organization.slug !== "default" && (
<EditButtonSettings/>
)}
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
Organization settings
</div>
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
{permissions.canManageSettings && organization.slug !== "default" && (
<EditButtonSettings/>
)}
</div>
<div className="flex items-center gap-2">
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</div>
</div>
</PageTitle>
<PageActions>
{permissions.canManageDangerZone && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</PageActions>
</PageHeader>
<PageContent>
<OrganizationTabs
+12 -5
View File
@@ -6,7 +6,7 @@ import {Agent} from "@/db/schema/08_agent";
import {Database} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
import {db as dbClient} from "@/db";
import {and, eq} from "drizzle-orm";
import {and, eq, inArray} from "drizzle-orm";
import {EDbmsSchema} from "@/db/schema/types";
import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action";
@@ -75,8 +75,15 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.returning();
const backup = await dbClient.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
// const backup = await dbClient.query.backup.findFirst({
// where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
// })
const activeBackup = await dbClient.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
)
})
@@ -85,13 +92,13 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
})
if (backup) {
if (activeBackup && activeBackup.status == "waiting") {
backupAction = true
await dbClient
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
.where(eq(drizzleDb.schemas.backup.id, activeBackup.id));
}
if (restoration) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.1.10-rc.2",
"version": "1.1.12-rc.3",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -95,8 +95,6 @@ export const EmailForm = (props: EmailFormProps) => {
return (
<TooltipProvider>
<Card>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4 mt-3"
@@ -182,23 +180,6 @@ export const EmailForm = (props: EmailFormProps) => {
)}
/>
<div className="flex justify-between gap-4">
{/*{props.defaultValues?.smtpFrom && (*/}
{/* <ButtonWithLoading*/}
{/* type="button"*/}
{/* isPending={mutationSendEmailTest.isPending}*/}
{/* onClick={async () => {*/}
{/* await mutationSendEmailTest.mutateAsync();*/}
{/* }}*/}
{/* icon={<Send/>}*/}
{/* size="default"*/}
{/* className="bg-green-600 hover:bg-green-700 text-white font-medium shadow-sm transition-all"*/}
{/* >Send email test</ButtonWithLoading>*/}
{/*)}*/}
{props.defaultValues?.smtpFrom && (
<Tooltip>
<TooltipTrigger asChild>
@@ -227,14 +208,13 @@ export const EmailForm = (props: EmailFormProps) => {
)}
</Tooltip>
)}
<Button
type="submit"
>Save</Button>
<div className="flex justify-end">
<Button type="submit">
Save
</Button>
</div>
</div>
</Form>
</CardContent>
</Card>
</TooltipProvider>
);
};
@@ -8,6 +8,8 @@ import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentWith} from "@/db/schema/08_agent";
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database, ShieldCheck} from "lucide-react";
import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
export type agentCardProps = {
data: AgentWith;
@@ -15,6 +17,7 @@ export type agentCardProps = {
export const AgentCard = (props: agentCardProps) => {
const {data: agent} = props;
const isMobile = useIsMobile();
const [isCopied, setIsCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
@@ -37,8 +40,8 @@ export const AgentCard = (props: agentCardProps) => {
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
<div className="flex items-center gap-3">
<h3 className="text-lg font-black text-foreground group-hover:text-primary transition-colors truncate tracking-tight leading-none">
{agent.name}
<h3 className="text-lg font-black text-foreground group-hover:text-primary transition-colors truncate tracking-tight ">
{isMobile ? truncateWords(agent.name, 2) : agent.name}
</h3>
{agent.version && (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
@@ -47,8 +50,8 @@ export const AgentCard = (props: agentCardProps) => {
)}
</div>
<div className="flex items-center gap-4 text-muted-foreground group-hover:text-foreground transition-colors">
<div className="flex items-center gap-1.5 min-w-0">
<div className="flex items-center gap-4 text-muted-foreground group-hover:text-foreground transition-colors">
<div className="hidden sm:flex items-center gap-1.5 min-w-0">
<Fingerprint className="w-3.5 h-3.5 shrink-0" />
<span className="font-mono text-xs font-bold truncate opacity-80">
{agent.id}
@@ -62,7 +65,7 @@ export const AgentCard = (props: agentCardProps) => {
</button>
</div>
<div className="hidden sm:flex items-center gap-1.5 shrink-0">
<div className="flex items-center gap-1.5 shrink-0">
<Database className="w-3.5 h-3.5" />
<span className="text-xs font-bold uppercase tracking-tight opacity-80">
{agent.databases?.length ?? 0} DBs
@@ -10,9 +10,9 @@ export type agentDatabaseCardProps = {
export const AgentDatabaseCard = (props: agentDatabaseCardProps) => {
const {data: database} = props;
return <DatabaseCard data={database}/>;
return <DatabaseCard withDetails={false} data={database}/>;
//todo: à remettre quand on aura fait l'impersonation des admins
//Todo: Add again when impersonation system will be implemented
/* if (!database.projectId) {
return <DatabaseCard data={database}/>;
}
@@ -57,54 +57,53 @@ export const AgentForm = (props: agentFormProps) => {
});
return (
<TooltipProvider>
<Card>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4 mt-3"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Agent 1" {...field} />
</FormControl>
<FormDescription>Your agent project name</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
defaultValue=""
name="description"
render={({field}) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="This agent is for the client exemple.com" {...field}
value={field.value ?? ""}/>
</FormControl>
<FormDescription>Enter your project agent description</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<Button>{isCreate ? `Create agent` : `Save agent`}</Button>
</Form>
</CardContent>
</Card>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Agent 1" {...field} />
</FormControl>
<FormDescription>Your agent project name</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
defaultValue=""
name="description"
render={({field}) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="This agent is for the client exemple.com" {...field}
value={field.value ?? ""}/>
</FormControl>
<FormDescription>Enter your project agent description</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit">
{isCreate ? "Create" : "Update"}
</Button>
</div>
</Form>
</TooltipProvider>
);
};
@@ -6,6 +6,7 @@ import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {deleteAgentAction} from "@/components/wrappers/dashboard/agent/button-delete-agent/delete-agent.action";
import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteAgentProps = {
text?: string;
@@ -14,6 +15,7 @@ export type ButtonDeleteAgentProps = {
export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
const router = useRouter();
const isMobile = useIsMobile();
const mutation = useMutation({
mutationFn: () => deleteAgentAction(props.agentId),
@@ -33,7 +35,7 @@ export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
description="Are you sure you want to remove this agent? This action cannot be undone."
button={{
main: {
text: props.text ? props.text : "",
text: props.text ? !isMobile ? props.text: "" : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
@@ -9,6 +9,8 @@ import {
import {EditNotifierButton} from "@/components/wrappers/dashboard/common/notifier/notifier-card/button-edit-notifier";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
export type NotifierCardProps = {
data: NotificationChannelWith;
@@ -19,6 +21,7 @@ export type NotifierCardProps = {
export const NotifierCard = (props: NotifierCardProps) => {
const {data, organization} = props;
const isMobile = useIsMobile()
return (
<div className="block transition-all duration-200 rounded-xl">
@@ -33,13 +36,14 @@ export const NotifierCard = (props: NotifierCardProps) => {
<div className="flex justify-start w-full">
<div className="flex flex-col items-start md:flex-row md:items-center gap-2 ">
<h3 className="font-medium text-foreground">{data.name}</h3>
<h3 className="font-medium text-foreground">{isMobile ? truncateWords(data.name, 2) : data.name}</h3>
<Badge variant="secondary" className="text-xs font-mono">
{data.provider}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<EditNotifierButton
organizations={props.organizations}
@@ -23,8 +23,8 @@ import {useRouter} from "next/navigation";
import {Switch} from "@/components/ui/switch";
import {getNotificationChannelIcon} from "@/components/wrappers/dashboard/admin/notifications/helpers";
import {Card} from "@/components/ui/card";
import {cn} from "@/lib/utils";
import Link from "next/link";
import {useIsMobile} from "@/hooks/use-mobile";
type AlertPolicyFormProps = {
onSuccess?: () => void;
@@ -35,6 +35,7 @@ type AlertPolicyFormProps = {
export const AlertPolicyForm = ({database, notificationChannels, organizationId, onSuccess}: AlertPolicyFormProps) => {
const router = useRouter()
const isMobile = useIsMobile()
const organizationNotificationChannels = notificationChannels.map(channel => channel.id) ?? [];
@@ -159,9 +160,11 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Configure Alerts</Label>
<p className="text-xs text-muted-foreground mt-1">
Choose which channels receive notifications for specific events.
</p>
{!isMobile && (
<p className="text-xs text-muted-foreground mt-1">
Choose which channels receive notifications for specific events.
</p>
)}
</div>
<Button
disabled={
@@ -184,7 +187,9 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
<InfoIcon className="h-8 w-8 text-muted-foreground/50"/>
<p className="font-medium text-sm text-foreground">No notification channels</p>
<p className="text-xs text-muted-foreground max-w-xs">
Please <Link href={`/dashboard/settings`} className="underline underline-offset-4 hover:text-primary transition-colors">configure notification channels</Link> in your organization settings first.
Please <Link href={`/dashboard/settings`}
className="underline underline-offset-4 hover:text-primary transition-colors">configure
notification channels</Link> in your organization settings first.
</p>
</div>
) : fields.length === 0 ? (
@@ -201,17 +206,17 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
) : (
<div className="grid gap-4">
{fields.map((field, index) => (
<Card key={field.id} className="p-4 transition-all hover:border-primary/50 relative group">
<Card key={field.id} className="p-4 transition-all hover:border-primary/50 relative group min-w-0 overflow-hidden">
<div className="flex flex-col gap-4">
<div className="grid grid-cols-[1fr_auto_auto] gap-3 items-end">
<div className="flex flex-col gap-1.5">
<div className="flex flex-row gap-2 items-start md:items-end flex-nowrap min-w-0 ">
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">
Notification Channel
</Label>
<FormField
control={form.control}
name={`alertPolicies.${index}.notificationChannelId`}
render={({field}) => {
render={({ field }) => {
const selectedIds = form
.watch("alertPolicies")
.map((a: AlertPolicyType) => a.notificationChannelId)
@@ -226,36 +231,38 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
const selectedChannel = notificationChannels.find(c => c.id === field.value);
return (
<FormItem className="space-y-0">
<Select onValueChange={field.onChange}
value={field.value?.toString() || ""}
>
<FormItem className="space-y-0 min-w-0">
<Select onValueChange={field.onChange} value={field.value?.toString() || ""}>
<FormControl>
<SelectTrigger className="h-9 w-full bg-background border-input">
<SelectTrigger className="h-9 w-full bg-background border-input min-w-0">
<SelectValue placeholder="Select channel">
{selectedChannel ? (
<div className="flex items-center gap-2">
<div className={cn("flex items-center justify-center h-4 w-4 rounded")}>
{selectedChannel && (
<div className="flex items-center gap-2 min-w-0 w-full">
<div className="flex items-center justify-center h-4 w-4 shrink-0">
{getNotificationChannelIcon(selectedChannel.provider)}
</div>
<span className="truncate font-medium text-sm">{selectedChannel.name}</span>
<span className="text-[9px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground ml-auto font-mono uppercase tracking-tighter">{selectedChannel.provider}</span>
<span className="truncate font-medium text-sm min-w-0">
{selectedChannel.name}
</span>
<span className="shrink-0 text-[9px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground font-mono uppercase">
{selectedChannel.provider}
</span>
</div>
) : "Select channel"}
)}
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
{availableNotificationChannels.map((channel) => (
<SelectItem key={channel.id.toString()}
value={channel.id.toString()}>
<div className="flex items-center gap-2 w-full">
<div className="text-muted-foreground scale-90">
{availableNotificationChannels.map(channel => (
<SelectItem key={channel.id.toString()} value={channel.id.toString()}>
<div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getNotificationChannelIcon(channel.provider)}
</div>
<span className="font-medium">{channel.name}</span>
<span
className="text-xs text-muted-foreground ml-2 capitalize">({channel.provider})</span>
<span className="font-medium truncate min-w-0">{channel.name}</span>
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">
({channel.provider})
</span>
</div>
</SelectItem>
))}
@@ -267,63 +274,61 @@ export const AlertPolicyForm = ({database, notificationChannels, organizationId,
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">
<div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">
Status
</Label>
<FormField
<FormField
control={form.control}
name={`alertPolicies.${index}.enabled`}
render={({field}) => (
render={({ field }) => (
<FormItem className="space-y-0">
<FormControl>
<div className="flex items-center h-9 px-3 rounded-md border border-input bg-background min-w-[90px] justify-between">
<Label htmlFor={`switch-${index}`} className="text-xs cursor-pointer font-medium text-foreground mr-2">
{field.value ? "Active" : "Off"}
</Label>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
id={`switch-${index}`}
className="scale-75 origin-right"
/>
<div className="flex items-center h-9 px-1 md:px-3 rounded-md border border-input bg-background justify-between min-w-0">
{!isMobile && (
<Label htmlFor={`switch-${index}`} className="text-xs cursor-pointer font-medium text-foreground mr-2">
{field.value ? "Active" : "Off"}
</Label>
)}
<Switch checked={field.value} onCheckedChange={field.onChange} id={`switch-${index}`} className="scale-75 origin-right"/>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex flex-col gap-1.5 shrink-0 mt-auto">
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 hover:bg-destructive/10 transition-colors border-input bg-background"
onClick={() => removeAlertPolicy(index)}>
onClick={() => removeAlertPolicy(index)}
>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
</div>
<FormField
control={form.control}
name={`alertPolicies.${index}.eventKinds`}
render={({field}) => (
<FormItem className="space-y-1.5">
<FormLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Trigger Events</FormLabel>
render={({ field }) => (
<FormItem className="space-y-1.5 min-w-0">
<FormLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Trigger Events
</FormLabel>
<FormControl>
<MultiSelect
options={EVENT_KIND_OPTIONS}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select events to trigger notifications..."
variant="inverted"
animation={0}
className="bg-background/50"
/>
<div className="max-w-full overflow-hidden">
<MultiSelect
options={EVENT_KIND_OPTIONS}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder={isMobile ? "Select events...": "Select events to trigger notifications..."}
variant="inverted"
animation={0}
className="bg-background/50 w-full min-w-0 flex-wrap"
/>
</div>
</FormControl>
<FormMessage/>
</FormItem>
@@ -6,6 +6,7 @@ import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {authClient} from "@/lib/auth/auth-client";
import {Trash2} from "lucide-react";
import {useIsMobile} from "@/hooks/use-mobile";
export type DeleteOrganizationButtonProps = {
organizationSlug: string;
@@ -13,6 +14,7 @@ export type DeleteOrganizationButtonProps = {
export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) => {
const router = useRouter();
const isMobile = useIsMobile();
const {data: organizations, refetch} = authClient.useListOrganizations();
const mutation = useMutation({
@@ -47,7 +49,7 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
description="Are you sure you want to remove this organization? This action cannot be undone."
button={{
main: {
text: "Delete Organization",
text: !isMobile ? "Delete Organization" : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
@@ -1,6 +1,5 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {
FormControl,
FormDescription,
@@ -22,7 +21,6 @@ import {
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
import {
deleteOrganizationAction,
updateOrganizationAction
} from "@/components/wrappers/dashboard/organization/organization.action";
import {toast} from "sonner";
@@ -95,76 +93,75 @@ export const OrganizationForm = (props: organizationFormProps) => {
return (
<Card>
<CardHeader></CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Organization 1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="project-1"
{...field}
onChange={(e) => {
const value = e.target.value.replaceAll(" ", "-").toLowerCase();
field.onChange(value);
}}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="users"
render={({field}) => (
<FormItem>
<FormLabel>Users</FormLabel>
<FormControl>
<MultiSelect
options={formatUsersList(props.users)}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select users"
variant="inverted"
animation={2}
// maxCount={100}
/>
</FormControl>
<FormDescription>Select users you want to add to this organization</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<Button>{isCreate ? `Create Organization` : `Update Organization`}</Button>
</Form>
</CardContent>
</Card>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Organization 1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="project-1"
{...field}
onChange={(e) => {
const value = e.target.value.replaceAll(" ", "-").toLowerCase();
field.onChange(value);
}}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="users"
render={({field}) => (
<FormItem>
<FormLabel>Users</FormLabel>
<FormControl>
<MultiSelect
options={formatUsersList(props.users)}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select users"
variant="inverted"
animation={2}
// maxCount={100}
/>
</FormControl>
<FormDescription>Select users you want to add to this organization</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit">
{isCreate ? "Create" : "Update"}
</Button>
</div>
</Form>
);
};
@@ -28,10 +28,6 @@ export const OrganizationNotifiersTab = ({
<h3 className="text-xl font-semibold text-balance mb-1">
Notification Settings
</h3>
<p className="text-muted-foreground leading-relaxed ">
Configure how and when you receive alerts about your services and
infrastructure.
</p>
</div>
<NotifierAddEditModal
organization={organization}
@@ -8,6 +8,7 @@ import {
} from "@/components/wrappers/dashboard/projects/button-delete-project/delete-project.action";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteProjectProps = {
text?: string;
@@ -16,6 +17,8 @@ export type ButtonDeleteProjectProps = {
export const ButtonDeleteProject = (props: ButtonDeleteProjectProps) => {
const router = useRouter();
const isMobile = useIsMobile()
const mutation = useMutation({
mutationFn: () => deleteProjectAction(props.projectId),
onSuccess: async (result: any) => {
@@ -35,7 +38,7 @@ export const ButtonDeleteProject = (props: ButtonDeleteProjectProps) => {
description="Are you sure you want to delete this project ? This action cannot be undone."
button={{
main: {
text: props.text ? props.text : "",
text: props.text ? !isMobile ? props.text: "" : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
@@ -19,8 +19,9 @@ export const ProjectDatabaseCard = (props: projectDatabaseCardProps) => {
const {data: database, extendedProps: extendedProps} = props;
return (
<Link className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
<Link
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
<DatabaseCard data={database}/>
</Link>
);
@@ -28,10 +29,11 @@ export const ProjectDatabaseCard = (props: projectDatabaseCardProps) => {
export type databaseCardProps = {
data: Database;
withDetails?: boolean;
};
export const DatabaseCard = (props: databaseCardProps) => {
const {data: database} = props;
const {data: database, withDetails = true} = props;
const [isCopied, setIsCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
@@ -43,13 +45,15 @@ export const DatabaseCard = (props: databaseCardProps) => {
};
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">
<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">
<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
src={`/images/${database.dbms}.png`}
<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
src={`/images/${database.dbms}.png`}
alt={`${database.dbms} icon`}
width={32}
width={32}
height={32}
className="object-contain w-full h-full"
/>
@@ -60,16 +64,17 @@ export const DatabaseCard = (props: databaseCardProps) => {
</div>
</div>
</div>
<div className="flex flex-col gap-2 flex-1">
<h3 className="text-lg font-black text-foreground group-hover:text-primary transition-colors truncate tracking-tight">
{database.name}
</h3>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
<div
className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
<div className="p-1 bg-muted/50 rounded-lg">
<Fingerprint className="w-3.5 h-3.5" />
<Fingerprint className="w-3.5 h-3.5"/>
</div>
<span className="font-mono text-[10px] font-bold truncate">
{database.agentDatabaseId}
@@ -79,12 +84,14 @@ export const DatabaseCard = (props: databaseCardProps) => {
className="ml-1 p-0.5 hover:bg-muted rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 z-10"
title="Copy ID"
>
{isCopied ? <Check className="w-2.5 h-2.5 text-green-500" /> : <Copy className="w-2.5 h-2.5" />}
{isCopied ? <Check className="w-2.5 h-2.5 text-green-500"/> :
<Copy className="w-2.5 h-2.5"/>}
</button>
</div>
<div className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
<div
className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
<div className="p-1 bg-muted/50 rounded-lg">
<Activity className="w-3.5 h-3.5" />
<Activity className="w-3.5 h-3.5"/>
</div>
<span className="text-[10px] font-bold uppercase tracking-tight">
{formatDateLastContact(database.lastContact)}
@@ -92,14 +99,19 @@ export const DatabaseCard = (props: databaseCardProps) => {
</div>
</div>
</div>
<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>
<div className="flex items-center gap-1 text-muted-foreground group-hover:text-primary transition-colors">
<span className="text-[10px] font-bold uppercase tracking-widest group-hover:hidden">Details</span>
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
{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>
<div
className="flex items-center gap-1 text-muted-foreground group-hover:text-primary transition-colors">
<span
className="text-[10px] font-bold uppercase tracking-widest group-hover:hidden">Details</span>
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform"/>
</div>
</div>
</div>
)}
</Card>
);
};
@@ -78,9 +78,6 @@ export const ProjectForm = (props: projectFormProps) => {
});
return (
<Card>
<CardHeader></CardHeader>
<CardContent>
<Form
form={form}
className="flex flex-col gap-4"
@@ -124,9 +121,11 @@ export const ProjectForm = (props: projectFormProps) => {
</FormItem>
)}
/>
<Button>{isCreate ? `Create Project` : `Update Project`}</Button>
<div className="flex justify-end">
<Button type="submit">
{isCreate ? "Create" : "Update"}
</Button>
</div>
</Form>
</CardContent>
</Card>
);
};
@@ -7,12 +7,9 @@ import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import {Backup, Restoration} from "@/db/schema/07_database";
import {NextResponse} from "next/server";
import {
deleteFileS3Private,
deleteLocalPrivate,
uploadLocalPrivate,
uploadS3Private
} from "@/features/upload/private/upload.action";
import {env} from "@/env.mjs";
import {withUpdatedAt} from "@/db/utils";
@@ -89,98 +86,32 @@ export const deleteBackupAction = userAction
let success: boolean, message: string;
const result =
settings.storage === "local"
? await deleteLocalPrivate(parsedInput.file)
: await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
if (parsedInput.file) {
const result =
settings.storage === "local"
? await deleteLocalPrivate(parsedInput.file)
: await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
({success, message} = result);
({success, message} = result);
if (!success) {
return {
success: false,
actionError: {
message: message,
status: 404,
cause: "Unable to delete backup from storage",
messageParams: {message: "Error deleting the backup"},
},
};
if (!success) {
return {
success: false,
actionError: {
message: message,
status: 404,
cause: "Unable to delete backup from storage",
messageParams: {message: "Error deleting the backup"},
},
};
}
}
return {
success: true,
actionSuccess: {
message: `Backup deleted successfully (ref: ${parsedInput.backupId}).`,
},
};
// const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
// if (!settings) {
// return {
// success: false,
// actionError: {
// message: "No settings found.",
// status: 404,
// cause: "No settings found.",
// messageParams: {message: "Error deleting the backup"},
// },
// };
// }
//
//
// let success: boolean, message: string;
//
// const result =
// settings.storage === "local"
// ? await deleteLocalPrivate(parsedInput.file)
// : await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
//
// ({success, message} = result);
//
// if (!success) {
// return {
// success: false,
// actionError: {
// message: message,
// status: 404,
// cause: "Unable to delete backup from storage",
// messageParams: {message: "Error deleting the backup"},
// },
// };
// }
//
// await db
// .delete(drizzleDb.schemas.backup)
// .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
// .execute();
//
// const backupExists = await db
// .select()
// .from(drizzleDb.schemas.backup)
// .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
// .execute();
//
//
// if (backupExists.length === 0) {
// return {
// success: true,
// actionSuccess: {
// message: "Backup deleted successfully.",
// },
// };
// } else {
// return {
// success: false,
// actionError: {
// message: "Backup not found or already deleted.",
// status: 404,
// cause: "Backup could not be deleted (from database or remote storage).",
// messageParams: {message: "Error deleting the backup"},
// },
// };
// }
} catch (error) {
return {
success: false,
@@ -246,7 +177,6 @@ export const rerunRestorationAction = userAction
});
// Create Restoration Action (Drizzle version)
export const createRestorationAction = userAction
.schema(
z.object({
@@ -256,7 +186,6 @@ export const createRestorationAction = userAction
)
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
try {
// Insert new restoration into the database
const restorationData = await db
.insert(drizzleDb.schemas.restoration)
.values({
+2 -1
View File
@@ -75,8 +75,9 @@ export async function deleteFileS3Private(fileName: string, bucketName: string)
*/
export async function deleteLocalPrivate(fileName: string) {
try {
console.log(fileName);
const filePath = path.join(process.cwd(), privateLocalDir, fileName);
console.log(`Deleted ${filePath}`);
// Delete locally
await unlink(filePath);
+20 -16
View File
@@ -43,27 +43,31 @@ export const deleteBackupCronAction = action
}))
.where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
let success: boolean, message: string;
const result =
settings.storage === "local"
? await deleteLocalPrivate(parsedInput.file)
: await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
if (parsedInput.file) {
let success: boolean, message: string;
({success, message} = result);
const result =
settings.storage === "local"
? await deleteLocalPrivate(parsedInput.file)
: await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
if (!success) {
return {
success: false,
actionError: {
message: message,
status: 404,
cause: "Unable to delete backup from storage",
messageParams: {message: "Error deleting the backup"},
},
};
({success, message} = result);
if (!success) {
return {
success: false,
actionError: {
message: message,
status: 404,
cause: "Unable to delete backup from storage",
messageParams: {message: "Error deleting the backup"},
},
};
}
}
return {
success: true,
actionSuccess: {