feat/add S3 Target model and service for managing S3 targets in the application

This commit is contained in:
biersoeckli
2024-12-31 16:21:04 +00:00
parent bb23a11f11
commit bea201fc0c
13 changed files with 430 additions and 7 deletions

View File

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "S3Target" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"bucketName" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"region" TEXT NOT NULL,
"accessKeyId" TEXT NOT NULL,
"secretKey" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);

View File

@@ -219,3 +219,16 @@ model Parameter {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model S3Target {
id String @id @default(uuid())
name String
bucketName String
endpoint String
region String
accessKeyId String
secretKey String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -25,7 +25,6 @@ export function NavBar() {
<Link href="/settings/profile" className={pathname.startsWith('/settings') ? activeCss : inactiveCss}>Settings</Link>
</nav>
<div className="ml-auto flex items-center space-x-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground px-4 py-2 relative h-8 w-8 rounded-full" type="button" id="radix-:reh:" aria-haspopup="menu" aria-expanded="false" data-state="closed" control-id="ControlID-46">

View File

@@ -0,0 +1,22 @@
'use server'
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { S3TargetEditModel, s3TargetEditZodModel } from "@/shared/model/s3-target-edit.model";
import s3TargetService from "@/server/services/s3-target.service";
export const saveS3Target = async (prevState: any, inputData: S3TargetEditModel) =>
saveFormAction(inputData, s3TargetEditZodModel, async (validatedData) => {
await getAuthUserSession();
await s3TargetService.save({
...validatedData,
id: validatedData.id ?? undefined,
});
});
export const deleteS3Target = async (s3TargetId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await s3TargetService.deleteById(s3TargetId);
return new SuccessActionResult(undefined, 'Successfully deleted S3 Target');
});

View File

@@ -0,0 +1,27 @@
'use server'
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import PageTitle from "@/components/custom/page-title";
import s3TargetService from "@/server/services/s3-target.service";
import S3TargetsTable from "./s3-targets-table";
import S3TargetEditOverlay from "./s3-target-edit-overlay";
import { Button } from "@/components/ui/button";
export default async function S3TargetsPage() {
await getAuthUserSession();
const data = await s3TargetService.getAll();
return (
<div className="flex-1 space-y-4 pt-6">
<PageTitle
title={'S3 Targets'}
subtitle={`View all S3 Targets which are configured in the QuickStack Cluster.`}>
<S3TargetEditOverlay>
<Button>Add S3 Target</Button>
</S3TargetEditOverlay>
</PageTitle>
<S3TargetsTable targets={data} />
</div>
)
}

View File

@@ -0,0 +1,189 @@
'use client'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Input } from "@/components/ui/input"
import { cn } from "@/frontend/utils/utils"
import { Button } from "@/components/ui/button"
import { Check, ChevronsUpDown } from "lucide-react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { useFormState } from 'react-dom'
import { useEffect, useState } from "react";
import { FormUtils } from "@/frontend/utils/form.utilts";
import { SubmitButton } from "@/components/custom/submit-button";
import { AppVolume, S3Target } from "@prisma/client"
import { AppVolumeEditModel, appVolumeEditZodModel } from "@/shared/model/volume-edit.model"
import { ServerActionResult } from "@/shared/model/server-action-error-return.model"
import { toast } from "sonner"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"
import { AppExtendedModel } from "@/shared/model/app-extended.model"
import { S3TargetEditModel, s3TargetEditZodModel } from "@/shared/model/s3-target-edit.model"
import { saveS3Target } from "./actions"
export default function S3TargetEditOverlay({ children, target }: { children: React.ReactNode; target?: S3Target; }) {
const [isOpen, setIsOpen] = useState<boolean>(false);
const form = useForm<S3TargetEditModel>({
resolver: zodResolver(s3TargetEditZodModel),
defaultValues: target
});
const [state, formAction] = useFormState((state: ServerActionResult<any, any>,
payload: S3TargetEditModel) =>
saveS3Target(state, {
...payload,
id: target?.id
}), FormUtils.getInitialFormState<typeof s3TargetEditZodModel>());
useEffect(() => {
if (state.status === 'success') {
form.reset();
toast.success('Volume saved successfully', {
description: "Click \"deploy\" to apply the changes to your app.",
});
setIsOpen(false);
}
FormUtils.mapValidationErrorsToForm<typeof s3TargetEditZodModel>(state, form);
}, [state]);
useEffect(() => {
form.reset(target);
}, [target]);
return (
<>
<div onClick={() => setIsOpen(true)}>
{children}
</div>
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(false)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit S3 Target</DialogTitle>
</DialogHeader>
<Form {...form}>
<form action={(e) => form.handleSubmit((data) => {
return formAction(data);
})()}>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>S3 Endpoint</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItem>
<FormLabel>S3 Region</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucketName"
render={({ field }) => (
<FormItem>
<FormLabel>S3 Bucket Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>S3 Access Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretKey"
render={({ field }) => (
<FormItem>
<FormLabel>S3 Secret Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<p className="text-red-500">{state.message}</p>
<SubmitButton>Save</SubmitButton>
</div>
</form>
</Form >
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,60 @@
'use client';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Download, EditIcon, TrashIcon } from "lucide-react";
import DialogEditDialog from "./s3-target-edit-overlay";
import { Toast } from "@/frontend/utils/toast.utils";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { AppVolume, S3Target } from "@prisma/client";
import React from "react";
import { DropdownMenu } from "@/components/ui/dropdown-menu";
import { SimpleDataTable } from "@/components/custom/simple-data-table";
import { formatDateTime } from "@/frontend/utils/format.utils";
import S3TargetEditOverlay from "./s3-target-edit-overlay";
import { deleteVolume } from "@/app/project/app/[appId]/volumes/actions";
import { deleteS3Target } from "./actions";
export default function S3TargetsTable({ targets }: {
targets: S3Target[]
}) {
const { openConfirmDialog: openDialog } = useConfirmDialog();
const asyncDeleteTarget = async (id: string) => {
const confirm = await openDialog({
title: "Delete S3 Target",
description: "Do you really want to delete this S3 Target?",
okButton: "Delete S3 Target"
});
if (confirm) {
await Toast.fromAction(() => deleteS3Target(id));
}
};
return <>
<SimpleDataTable columns={[
['id', 'ID', false],
['name', 'Name', true],
["createdAt", "Created At", true, (item) => formatDateTime(item.createdAt)],
["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)],
]}
data={targets}
onItemClickLink={(item) => `/project?projectId=${item.id}`}
actionCol={(item) =>
<>
<div className="flex">
<div className="flex-1"></div>
<DialogEditDialog target={item}>
<Button variant="ghost"><EditIcon /></Button>
</DialogEditDialog>
<Button variant="ghost" onClick={() => asyncDeleteTarget(item.id)}>
<TrashIcon />
</Button>
</div>
</>}
/>
</>;
}

View File

@@ -1,6 +1,5 @@
'use client'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import {
Sidebar,
@@ -18,19 +17,15 @@ import {
SidebarMenuAction,
useSidebar
} from "@/components/ui/sidebar"
import { AppleIcon, BookOpen, Boxes, Calendar, ChartNoAxesCombined, ChevronDown, ChevronRight, ChevronUp, Dot, FolderClosed, Home, Inbox, Info, Plus, Radio, Search, Server, Settings, Settings2, User, User2 } from "lucide-react"
import { BookOpen, Boxes, ChartNoAxesCombined, ChevronDown, ChevronRight, ChevronUp, Dot, FolderClosed, Info, Plus, Server, Settings, Settings2, User } from "lucide-react"
import Link from "next/link"
import { EditProjectDialog } from "./projects/edit-project-dialog"
import projectService from "@/server/services/project.service"
import { getAuthUserSession, getUserSession } from "@/server/utils/action-wrapper.utils"
import { SidebarLogoutButton } from "./sidebar-logout-button"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { App, Project } from "@prisma/client"
import { useIsMobile } from "@/frontend/hooks/use-mobile"
import { UserSession } from "@/shared/model/sim-session.model"
@@ -40,6 +35,11 @@ const settingsMenu = [
url: "/settings/profile",
icon: User,
},
{
title: "S3 Targets",
url: "/settings/s3-targets",
icon: Settings,
},
{
title: "QuickStack Settings",
url: "/settings/server",

View File

@@ -0,0 +1,68 @@
import { revalidateTag, unstable_cache } from "next/cache";
import dataAccess from "../adapter/db.client";
import { Tags } from "../utils/cache-tag-generator.utils";
import { Prisma, S3Target } from "@prisma/client";
import namespaceService from "./namespace.service";
class S3TargetService {
async deleteById(id: string) {
const existingItem = await this.getById(id);
if (!existingItem) {
return;
}
try {
await dataAccess.client.s3Target.delete({
where: {
id
}
});
} finally {
revalidateTag(Tags.s3Targets());
}
}
async getAll() {
return await unstable_cache(() => dataAccess.client.s3Target.findMany({
orderBy: {
name: 'asc'
}
}),
[Tags.s3Targets()], {
tags: [Tags.s3Targets()]
})();
}
async getById(id: string) {
return dataAccess.client.s3Target.findFirstOrThrow({
where: {
id
}
});
}
async save(item: Prisma.S3TargetUncheckedCreateInput | Prisma.S3TargetUncheckedUpdateInput) {
let savedItem: S3Target;
try {
if (item.id) {
savedItem = await dataAccess.client.s3Target.update({
where: {
id: item.id as string
},
data: item
});
} else {
savedItem = await dataAccess.client.s3Target.create({
data: item as Prisma.S3TargetUncheckedCreateInput
});
}
await namespaceService.createNamespaceIfNotExists(savedItem.id);
} finally {
revalidateTag(Tags.s3Targets());
}
return savedItem;
}
}
const s3TargetService = new S3TargetService();
export default s3TargetService;

View File

@@ -8,6 +8,10 @@ export class Tags {
return `projects`;
}
static s3Targets() {
return `targets`;
}
static apps(projectId: string) {
return `apps-${projectId}`;
}

View File

@@ -10,3 +10,4 @@ export * from "./appdomain"
export * from "./appvolume"
export * from "./appfilemount"
export * from "./parameter"
export * from "./s3target"

View File

@@ -0,0 +1,14 @@
import * as z from "zod"
export const S3TargetModel = z.object({
id: z.string(),
name: z.string(),
bucketName: z.string(),
endpoint: z.string(),
region: z.string(),
accessKeyId: z.string(),
secretKey: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
})

View File

@@ -0,0 +1,14 @@
import { stringToNumber } from "@/shared/utils/zod.utils";
import { z } from "zod";
export const s3TargetEditZodModel = z.object({
id: z.string().optional(),
name: z.string().trim().min(1),
endpoint: z.string().trim().min(1),
bucketName: z.string().trim().min(1),
region: z.string().trim().min(1),
accessKeyId: z.string().trim().min(1),
secretKey: z.string().trim().min(1),
})
export type S3TargetEditModel = z.infer<typeof s3TargetEditZodModel>;