mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-11 05:59:23 -06:00
feat/add S3 Target model and service for managing S3 targets in the application
This commit is contained in:
12
prisma/migrations/20241231161704_migration/migration.sql
Normal file
12
prisma/migrations/20241231161704_migration/migration.sql
Normal 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
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
22
src/app/settings/s3-targets/actions.ts
Normal file
22
src/app/settings/s3-targets/actions.ts
Normal 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');
|
||||
});
|
||||
27
src/app/settings/s3-targets/page.tsx
Normal file
27
src/app/settings/s3-targets/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
189
src/app/settings/s3-targets/s3-target-edit-overlay.tsx
Normal file
189
src/app/settings/s3-targets/s3-target-edit-overlay.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
60
src/app/settings/s3-targets/s3-targets-table.tsx
Normal file
60
src/app/settings/s3-targets/s3-targets-table.tsx
Normal 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>
|
||||
</>}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
68
src/server/services/s3-target.service.ts
Normal file
68
src/server/services/s3-target.service.ts
Normal 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;
|
||||
@@ -8,6 +8,10 @@ export class Tags {
|
||||
return `projects`;
|
||||
}
|
||||
|
||||
static s3Targets() {
|
||||
return `targets`;
|
||||
}
|
||||
|
||||
static apps(projectId: string) {
|
||||
return `apps-${projectId}`;
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from "./appdomain"
|
||||
export * from "./appvolume"
|
||||
export * from "./appfilemount"
|
||||
export * from "./parameter"
|
||||
export * from "./s3target"
|
||||
|
||||
14
src/shared/model/generated-zod/s3target.ts
Normal file
14
src/shared/model/generated-zod/s3target.ts
Normal 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(),
|
||||
})
|
||||
14
src/shared/model/s3-target-edit.model.ts
Normal file
14
src/shared/model/s3-target-edit.model.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user