feat: org agent management (#259)

* feat: org agent management

* feat: org agent management

* fix: adding migrations for legacy and refactoring.

* fix: refactoring

* fix: delete agent

* fix: refactoring

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
Charles GTE
2026-04-10 18:37:35 +02:00
committed by GitHub
co-authored by charles-gauthereau
parent a620d7a9f7
commit e38519aec2
57 changed files with 13273 additions and 2089 deletions
@@ -6,7 +6,7 @@ import z from "zod";
import { db } from "@/db";
import {action} from "@/lib/safe-actions/actions";
//todo: to be continued...
//TODO: to be continued...
export const forgotPasswordAction = action
.schema(
z.object({
@@ -44,7 +44,7 @@ export function BreadCrumbsWrapper() {
)
}
const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "settings", "notifications", "storages"];
const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "notifications", "storages"];
export function BreadCrumbs({}: BreadCrumbsProps) {
@@ -1,6 +1,6 @@
import Link from "next/link";
import {cn} from "@/lib/utils";
import {Plus} from "lucide-react";
import {CircleSlash2, Plus} from "lucide-react";
import {forwardRef, HTMLAttributes} from "react";
type EmptyStatePlaceholderProps = {
@@ -8,25 +8,32 @@ type EmptyStatePlaceholderProps = {
onClick?: () => void;
text: string;
className?: string;
state?: string
} & HTMLAttributes<HTMLDivElement>;
export const EmptyStatePlaceholder = forwardRef<HTMLDivElement, EmptyStatePlaceholderProps>(({
url,
onClick,
text,
className,
...props
}, ref) => {
url,
onClick,
text,
state,
className,
...props
}, ref) => {
const Container = (
<div
className={cn(
"flex h-full flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-4",
"transition-colors text-muted-foreground text-center space-y-4",
state != "empty" && "hover:bg-muted/50 hover:text-primary",
(onClick || url) && "cursor-pointer"
)}
onClick={onClick}
>
<Plus className="w-5 h-5 lg:w-6 lg:h-6" />
{state == "empty" ?
<CircleSlash2 className="w-5 h-5 lg:w-6 lg:h-6"/>
:
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
}
<p className="text-sm">{text}</p>
</div>
);
@@ -1,7 +1,7 @@
import {z} from "zod";
export const ChannelsOrganizationSchema = z.object({
organizations: z.array(z.string())
organizations: z.array(z.string().uuid())
});
export type ChannelsOrganizationType = z.infer<typeof ChannelsOrganizationSchema>;
@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {withUpdatedAt} from "@/db/utils";
export const updateEmailSettingsAction = userAction
.schema(
@@ -18,9 +19,9 @@ export const updateEmailSettingsAction = userAction
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
.set(withUpdatedAt({
...data,
})
}))
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
@@ -10,6 +10,7 @@ import {z} from "zod";
import {
DefaultNotificationSchema
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification.schema";
import {withUpdatedAt} from "@/db/utils";
export const updateNotificationSettingsAction = userAction
.schema(
@@ -24,9 +25,9 @@ export const updateNotificationSettingsAction = userAction
try {
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
.set(withUpdatedAt({
defaultNotificationChannelId: data.notificationChannelId ?? null,
})
}))
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
return {
@@ -8,6 +8,7 @@ import {ServerActionResult} from "@/types/action-type";
import {Setting} from "@/db/schema/01_setting";
import {z} from "zod";
import {DefaultStorageSchema} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema";
import {withUpdatedAt} from "@/db/utils";
export const updateStorageSettingsAction = userAction
.schema(
@@ -22,10 +23,10 @@ export const updateStorageSettingsAction = userAction
try {
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({
.set(withUpdatedAt({
defaultStorageChannelId: data.storageChannelId,
encryption: data.encryption,
})
}))
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
return {
@@ -6,6 +6,7 @@ import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboa
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {withUpdatedAt} from "@/db/utils";
export const updateS3SettingsAction = userAction
.schema(
@@ -19,7 +20,7 @@ export const updateS3SettingsAction = userAction
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
.set({ ...data })
.set(withUpdatedAt({ ...data }))
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
@@ -40,6 +41,7 @@ export const updateStorageSettingsAction = userAction
const [updatedSettings] = await db
.update(drizzleDb.schemas.setting)
// @ts-ignore
.set({ ...data })
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
@@ -15,6 +15,7 @@ import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-chec
export type agentCardProps = {
data: AgentWith;
organizationView?: boolean;
};
export const AgentCard = (props: agentCardProps) => {
@@ -34,7 +35,7 @@ export const AgentCard = (props: agentCardProps) => {
return (
<Link
href={`/dashboard/agents/${agent.id}`}
href={props.organizationView ? `/dashboard/settings/agents/${agent.id}`: `/dashboard/agents/${agent.id}`}
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
>
<Card className="flex flex-row items-center p-4 gap-5 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-md overflow-hidden">
@@ -9,8 +9,10 @@ import {deleteAgentAction} from "@/components/wrappers/dashboard/agent/button-de
import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteAgentProps = {
text?: string;
agentId: string;
text?: string,
agentId: string,
organizationId?: string
organizationIds?: string[]
};
export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
@@ -18,11 +20,11 @@ export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
const isMobile = useIsMobile();
const mutation = useMutation({
mutationFn: () => deleteAgentAction(props.agentId),
mutationFn: () => deleteAgentAction({agentId: props.agentId, organizationId: props.organizationId, organizationIds: props.organizationIds}),
onSuccess: async (result: any) => {
if (result.data?.success) {
toast.success(result.data.actionSuccess.message);
router.push("/dashboard/agents");
router.push(props.organizationId ? "/dashboard/settings?tab=agents" : "/dashboard/agents");
} else {
toast.error(result.data.actionError.message || "Unknown error occurred.");
}
@@ -35,7 +37,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 ? !isMobile ? props.text: "" : "",
text: props.text ? !isMobile ? props.text : "" : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
@@ -3,51 +3,135 @@
import {z} from "zod";
import {v4 as uuidv4} from "uuid";
import {ServerActionResult} from "@/types/action-type";
import {eq} from "drizzle-orm";
import {and, eq, inArray} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Agent} from "@/db/schema/08_agent";
import {userAction} from "@/lib/safe-actions/actions";
import {zString} from "@/lib/zod";
import {withUpdatedAt} from "@/db/utils";
export const deleteAgentAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
try {
export const deleteAgentAction = userAction
.schema(
z.object({
agentId: zString(),
organizationId: zString().optional(),
organizationIds: z.array(z.string()).optional()
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
const {agentId, organizationId, organizationIds} = parsedInput;
// const deletedAgent: Agent[] = await db.delete(drizzleDb.schemas.agent).where(eq(drizzleDb.schemas.agent.id, parsedInput)).returning();
try {
let projectIds: string[] = [];
const uuid = uuidv4();
const uuid = uuidv4();
if (organizationId) {
await db
.delete(drizzleDb.schemas.organizationAgent)
.where(
and(
eq(drizzleDb.schemas.organizationAgent.organizationId, organizationId),
eq(drizzleDb.schemas.organizationAgent.agentId, agentId)
)
);
const updatedAgent = await db
.update(drizzleDb.schemas.agent)
.set({
isArchived: true,
slug: uuid,
})
.where(eq(drizzleDb.schemas.agent.id, parsedInput))
.returning();
const organization = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organizationId),
with: {
projects: true,
}
});
projectIds = organization?.projects?.map(project => project.id) ?? [];
if (!updatedAgent[0]) {
throw new Error("Agent not found or update failed");
} else if (organizationIds) {
const organizationsToRemoveDetails = await db.query.organization.findMany({
where: inArray(drizzleDb.schemas.organization.id, organizationIds),
with: {
projects: true
}
});
projectIds = organizationsToRemoveDetails.flatMap(org =>
org.projects.map(project => project.id)
);
}
if (projectIds?.length > 0) {
const databases = await db.query.database.findMany({
where: (db, {inArray}) => inArray(db.projectId, projectIds),
columns: {id: true}
});
const databaseIds = databases.map(d => d.id);
await db
.update(drizzleDb.schemas.database)
.set(withUpdatedAt({
backupPolicy: null,
projectId: null
}))
.where(inArray(drizzleDb.schemas.database.projectId, projectIds))
.execute();
await db.delete(drizzleDb.schemas.retentionPolicy)
.where(inArray(drizzleDb.schemas.retentionPolicy.databaseId, databaseIds))
.execute();
await db.delete(drizzleDb.schemas.alertPolicy)
.where(inArray(drizzleDb.schemas.alertPolicy.databaseId, databaseIds))
.execute();
await db.delete(drizzleDb.schemas.storagePolicy)
.where(inArray(drizzleDb.schemas.storagePolicy.databaseId, databaseIds))
.execute();
}
const updatedAgent = await db
.update(drizzleDb.schemas.agent)
.set(withUpdatedAt({
isArchived: true,
slug: uuid,
deletedAt: new Date()
}))
.where(eq(drizzleDb.schemas.agent.id, agentId))
.returning();
if (!updatedAgent[0]) {
return {
success: false,
actionError: {
message: "Agent not found or update failed",
status: 404,
messageParams: {agentId: agentId},
},
};
}
return {
success: true,
value: updatedAgent[0],
actionSuccess: {
message: "Agent has been successfully deleted.",
messageParams: {projectId: agentId},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete agent.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {agentId: agentId},
},
};
}
return {
success: true,
value: updatedAgent[0],
actionSuccess: {
message: "Agent has been successfully deleted.",
messageParams: {projectId: parsedInput},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete agent.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {projectId: parsedInput},
},
};
}
});
});
@@ -55,9 +55,7 @@ export const UploadBackupZone = ({onSuccessAction, database}: UploadRetentionZon
},
});
const acceptDbImportFiles = getFileHeadersBasedOnDbms(database.dbms)
console.log(acceptDbImportFiles)
const fileKindDescription = Object.values(acceptDbImportFiles)
.flat()
@@ -10,6 +10,7 @@ import {useIsMobile} from "@/hooks/use-mobile";
export type DeleteOrganizationButtonProps = {
organizationSlug: string;
disabled?: boolean
};
export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) => {
@@ -51,6 +52,7 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
main: {
text: !isMobile ? "Delete Organization" : "",
variant: "outline",
disabled: props.disabled,
icon: <Trash2 color="red"/>,
},
confirm: {
@@ -0,0 +1,38 @@
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {cn} from "@/lib/utils";
import {Agent} from "@/db/schema/08_agent";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
export type OrganizationAgentsTabProps = {
organization: OrganizationWithMembers;
agents: Agent[];
};
export const OrganizationAgentsTab = ({
organization,
agents,
}: OrganizationAgentsTabProps) => {
const hasAgent = agents.length > 0;
return (
<div className="flex flex-col gap-y-6 h-full py-4">
<div className="h-full flex flex-col gap-y-6">
<div className={cn("hidden flex-row justify-between items-start", hasAgent && "flex")}>
<div className="max-w-2xl ">
<h3 className="text-xl font-semibold text-balance mb-1">
Agent Settings
</h3>
</div>
<AgentDialog organization={organization} typeTrigger="create"/>
</div>
{hasAgent ? (
<CardsWithPagination data={agents} organizationView={true} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
) : (
<AgentDialog organization={organization} typeTrigger="empty"/>
)}
</div>
</div>
);
};
@@ -29,11 +29,6 @@ export const OrganizationNotifiersTab = ({
Notification Settings
</h3>
</div>
{/*<NotifierAddEditModal*/}
{/* organization={organization}*/}
{/* open={isAddModalOpen}*/}
{/* onOpenChangeAction={setIsAddModalOpen}*/}
{/*/>*/}
<ChannelAddEditModal
kind={kind}
organization={organization}
@@ -16,22 +16,32 @@ import {
import {
OrganizationStoragesTab
} from "@/components/wrappers/dashboard/organization/tabs/organization-channels-tab/organization-storages-tab";
import {
OrganizationAgentsTab
} from "@/components/wrappers/dashboard/organization/tabs/organization-channels-tab/organization-agents-tab";
import {Agent} from "@/db/schema/08_agent";
export type OrganizationTabsProps = {
organization: OrganizationWithMembers;
notificationChannels: NotificationChannel[];
storageChannels: StorageChannel[];
activeMember: MemberWithUser
activeMember: MemberWithUser;
agents: Agent[]
};
export const OrganizationTabs = ({activeMember, organization, notificationChannels, storageChannels}: OrganizationTabsProps) => {
export const OrganizationTabs = ({
activeMember,
organization,
notificationChannels,
storageChannels,
agents
}: OrganizationTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "users");
const {
canManageUsers,
canManageNotifications,
canManageStorages
} = useOrganizationPermissions(activeMember);
@@ -71,6 +81,12 @@ export const OrganizationTabs = ({activeMember, organization, notificationChanne
>
Storages
</TabsTrigger>
<TabsTrigger
className="w-full"
value="agents"
>
Agents
</TabsTrigger>
</TabsList>
<TabsContent className="h-full" value="users">
<SettingsOrganizationMembersTable organization={organization}/>
@@ -87,6 +103,12 @@ export const OrganizationTabs = ({activeMember, organization, notificationChanne
storageChannels={storageChannels}
/>
</TabsContent>
<TabsContent className="h-full" value="agents">
<OrganizationAgentsTab
organization={organization}
agents={agents}
/>
</TabsContent>
</Tabs>
:
<SettingsOrganizationMembersTable organization={organization}/>