add: specs & database status

This commit is contained in:
Elie Baier 2024-05-08 21:56:23 +02:00
parent 8f47633e86
commit 27e9e2d941
12 changed files with 472 additions and 80 deletions

View File

@ -3,12 +3,12 @@
import { Database } from '@prisma/client';
import { ColumnDef } from '@tanstack/react-table';
import Image from 'next/image';
import { databaseProviders } from './database-new-form';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { EllipsisVertical } from 'lucide-react';
import { databaseProviders } from '@/lib/deploy/database-config';
import { cn } from '@/lib/utils';
interface IDatabase extends Pick<Database, 'id' | 'name' | 'provider' | 'createdAt'> {}
interface IDatabase extends Pick<Database, 'id' | 'name' | 'provider' | 'createdAt'> {
status: any;
}
export const columns: ColumnDef<IDatabase>[] = [
{
@ -16,8 +16,9 @@ export const columns: ColumnDef<IDatabase>[] = [
header: 'Provider',
cell(props) {
return (
<div className="flex items-center">
<div className="flex items-center relative">
<Image src={databaseProviders.filter((pro) => pro.id.toUpperCase() === props.getValue())[0]?.image?.src} alt={databaseProviders.filter((pro) => pro.id.toUpperCase() === props.getValue())[0]?.image?.alt} width={32} height={32} />
<span className={cn('absolute bottom-0 left-8 w-2 h-2 rounded-full', props.row.original.status.status.phase == 'Running' ? 'bg-emerald-400' : 'bg-red-400')}></span>
</div>
);
},

View File

@ -1,5 +1,6 @@
'use client';
import { PostgresSpecsPage } from '@/components/deploy/database/provider/postgres';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@ -8,6 +9,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { useWorkspace } from '@/hooks/useWorkspace';
import { deployDatabase } from '@/lib/deploy/database';
import { databaseProviders, defaultDatabaseConfiguration, IDatabaseConfiguration } from '@/lib/deploy/database-config';
import { cn } from '@/lib/utils';
import { Plus } from 'lucide-react';
import Image from 'next/image';
@ -18,25 +20,6 @@ interface DatabaseNewSteps {
display: string;
}
interface IDatabaseProvider {
id: string;
display: string;
image: {
alt: string;
src: string;
};
}
export interface IDatabaseConfig {
workspaceId: string;
name: string;
provider: IDatabaseProvider;
user: {
username: string;
password: string;
};
}
const steps: DatabaseNewSteps[] = [
{
display: 'Database Type',
@ -52,55 +35,26 @@ const steps: DatabaseNewSteps[] = [
},
];
export const databaseProviders: IDatabaseProvider[] = [
{
id: 'vitess',
display: 'Vitess',
image: {
alt: 'Vitess',
src: '/vitess.png',
},
},
{
id: 'redis',
display: 'Redis',
image: {
alt: 'Redis',
src: '/redis.svg',
},
},
{
id: 'postgres',
display: 'Postgres',
image: {
alt: 'Postgres',
src: '/postgres.png',
},
},
];
export default function DatabaseNewForm() {
const [open, setOpen] = useState<boolean>(false);
const [currentSteps, setCurrentSteps] = useState<number>(1);
const router = useRouter();
// TODO: Generate all data randomly, but leave the user the choice to modify
const { id } = useWorkspace();
const defaultDatabaseConfig: IDatabaseConfig = {
workspaceId: id,
name: 'my-new-cool-db',
provider: databaseProviders[0],
user: {
username: 'my-new-super-user',
password: 'a-super-strong-generated-password',
},
};
const [databaseConfig, setDatabaseConfig] = useState<IDatabaseConfig>(defaultDatabaseConfig);
const [databaseConfig, setDatabaseConfig] = useState<IDatabaseConfiguration>(defaultDatabaseConfiguration(id));
return (
<Sheet open={open} onOpenChange={() => setOpen((open) => !open)}>
<Sheet
open={open}
onOpenChange={() => {
setOpen((open) => !open);
if (!open) {
setDatabaseConfig(defaultDatabaseConfiguration(id));
setCurrentSteps(1);
}
}}
>
<SheetTrigger asChild>
<Button className="flex gap-1 justify-center items-center bg-[#3A7BFE]">
<Plus />
@ -152,8 +106,9 @@ export default function DatabaseNewForm() {
{currentSteps == 2 && (
<div>
<p>WIP for now 1CPU 2048Mb RAM</p>
<p>Storage 1 GB</p>
{databaseConfig.provider.id == 'vitess' && <div>Vitess</div>}
{databaseConfig.provider.id == 'redis' && <div>Redis</div>}
{databaseConfig.provider.id == 'postgres' && <PostgresSpecsPage config={databaseConfig} setConfig={setDatabaseConfig} />}
</div>
)}
@ -197,11 +152,36 @@ export default function DatabaseNewForm() {
{currentSteps == 4 && (
<div>
<h2>Review your new database information :</h2>
<p>Type : {databaseConfig.provider.display}</p>
<p>Name : {databaseConfig.name}</p>
<p>Username : {databaseConfig.user.username}</p>
<p>Password : {databaseConfig.user.password}</p>
<div className="flex flex-col gap-2">
<div>
<p className="text-muted-foreground text-sm">Database Type</p>
<p>{databaseConfig.provider.display}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">CPUs</p>
<p>{databaseConfig.specs.cpu}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">RAM</p>
<p>{databaseConfig.specs.memory}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">Storage</p>
<p>{databaseConfig.specs.storage}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">Name</p>
<p>{databaseConfig.name}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">Username</p>
<p>{databaseConfig.user.username}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">Password</p>
<p>{databaseConfig.user.password}</p>
</div>
</div>
</div>
)}
@ -224,7 +204,7 @@ export default function DatabaseNewForm() {
console.log(res);
setOpen(false);
setCurrentSteps(1);
setDatabaseConfig(defaultDatabaseConfig);
setDatabaseConfig(defaultDatabaseConfiguration(id));
router.refresh();
})
.catch((err) => {

View File

@ -11,6 +11,7 @@ import { EllipsisVertical, X } from 'lucide-react';
import { Dialog, DialogClose, DialogContent, DialogTrigger } from '@/components/ui/dialog';
import { useState } from 'react';
import { Database } from '@prisma/client';
import { deleteDatabase } from '@/lib/deploy/database';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@ -19,6 +20,7 @@ interface DataTableProps<TData, TValue> {
export function DatabaseTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const [open, setOpen] = useState(false);
const [destroyOpen, setDestroyOpen] = useState(false);
const table = useReactTable({
data,
@ -27,7 +29,7 @@ export function DatabaseTable<TData, TValue>({ columns, data }: DataTableProps<T
});
const router = useRouter();
const { slug } = useWorkspace();
const { slug, id } = useWorkspace();
return (
<div className="rounded-lg w-full overflow-hidden">
@ -106,7 +108,51 @@ export function DatabaseTable<TData, TValue>({ columns, data }: DataTableProps<T
</div>
</DialogContent>
</Dialog>
<DropdownMenuItem className="text-red-400">Destroy</DropdownMenuItem>
<Dialog open={destroyOpen} onOpenChange={setDestroyOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setDestroyOpen(true);
}}
className="text-red-400"
>
Destroy
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<div className="flex flex-col">
<div className="flex flex-row justify-between mb-0">
<h1 className="text-3xl font-semibold">Are you sure?</h1>
<DialogClose asChild>
<Button variant="ghost">
<X size={17} />
</Button>
</DialogClose>
</div>
<p className="text-gray-700">Deleting a database if irreversible. This action cannot be undone.</p>
</div>
<div>
<Button
variant={'destructive'}
onClick={(e) => {
e.preventDefault();
deleteDatabase({ id: data[index].id, workspaceId: id })
.then(() => {
setDestroyOpen(false);
router.refresh();
})
.catch((err) => {
console.error(err);
});
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@ -3,6 +3,7 @@ import DatabaseNewForm from './database-new-form';
import prisma from '@/lib/prisma';
import { DatabaseTable } from './database-table';
import { columns } from './database-column';
import { getDatabase } from '@/lib/deploy/database';
export default async function Databases({ params }: { params: { workspace: string } }) {
const workspaceSlug = params.workspace;
@ -15,6 +16,16 @@ export default async function Databases({ params }: { params: { workspace: strin
},
});
const status = await Promise.all(
workspace.Database.map(async (database) => {
const res = await getDatabase({
id: database.id,
workspaceId: workspace.id,
}).catch(() => ({ status: { phase: 'unknown' } }));
return { ...res, id: database.id };
})
);
if (workspace?.Database.length == 0) {
return (
<div className="mt-12">
@ -47,7 +58,7 @@ export default async function Databases({ params }: { params: { workspace: strin
</div>
</div>
))} */}
<DatabaseTable columns={columns} data={workspace.Database} />
<DatabaseTable columns={columns} data={workspace.Database.map((db) => ({ ...db, status: status.find((st) => st.id == db.id) }))} />
</div>
</div>
);

View File

@ -0,0 +1,101 @@
'use client';
import { IDatabaseConfiguration } from '@/lib/deploy/database-config';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { IDatabaseSpecs } from '@/lib/deploy/database-config';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { cn } from '@/lib/utils';
import { Slider } from '@/components/ui/slider';
let specsTiers: IDatabaseSpecs[] = [
{
id: 'postgres-xs',
cpu: 0.5,
memory: 512,
maxConnections: 5,
},
{
id: 'postgres-s',
cpu: 1,
memory: 1024,
maxConnections: 20,
},
{
id: 'postgres-m',
cpu: 2,
memory: 2048,
maxConnections: 50,
},
{
id: 'postgres-l',
cpu: 4,
memory: 4096,
maxConnections: 100,
},
{
id: 'postgres-xl',
cpu: 8,
memory: 8192,
maxConnections: 200,
},
];
let MIN_STORAGE = 10;
let MAX_STORAGE = 100;
export function PostgresSpecsPage({ config, setConfig }: { config: IDatabaseConfiguration; setConfig: Dispatch<SetStateAction<IDatabaseConfiguration>> }) {
const [specsTier, setSpecsTier] = useState<IDatabaseSpecs>(config.specs.cpu ? specsTiers.filter((tier) => tier.id == config.specs.id)[0] : specsTiers[0]);
const [storage, setStorage] = useState<number>(config.specs.storage || MIN_STORAGE);
useEffect(() => {
setConfig((config) => ({
...config,
specs: {
...config.specs,
...specsTier,
storage,
},
}));
}, [specsTier, storage]);
return (
<div>
<RadioGroup
defaultValue={'vitess'}
className="flex flex-col gap-2 mb-4"
value={specsTier.id}
onValueChange={(id) => {
setSpecsTier(specsTiers.filter((tier) => tier.id == id)[0]);
}}
>
{specsTiers.map((specs) => (
<div key={specs.id} className={cn('flex flex-row items-center gap-4 border-[1px] rounded-lg py-2 px-4', specsTier.id == specs.id ? 'border-[#3A7BFE]' : 'border-gray-300')}>
<RadioGroupItem value={specs.id} id={specs.id} />
<label htmlFor={specs.id} className="flex flex-row items-center gap-4">
<div className="w-8 h-8 rounded-full flex justify-center items-center">
<p className="text-xl">{specs.id.replace('postgres-', '').toUpperCase()}</p>
</div>
<p className="text-sm text-gray-700">
{specs.cpu} CPU, {specs.memory} MB RAM, {specs.maxConnections} Max Connections
</p>
</label>
</div>
))}
</RadioGroup>
<div>
<div>
<label>Storage</label>
<p>{storage} GB</p>
</div>
<Slider
min={MIN_STORAGE / 10}
max={MAX_STORAGE / 10}
onValueChange={(e) => {
setStorage(e[0] * 10);
}}
defaultValue={[storage / 10]}
/>
</div>
</div>
);
}

18
components/ui/slider.tsx Normal file
View File

@ -0,0 +1,18 @@
'use client';
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils';
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>>(({ className, ...props }, ref) => (
<SliderPrimitive.Root ref={ref} className={cn('relative flex w-full touch-none select-none items-center', className)} {...props}>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-[#3A7BFE]" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-[#3A7BFE] bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@ -0,0 +1,80 @@
import { generateName } from "@/utils/name-generator";
import { generatePassword } from "@/utils/password-generator";
export interface IDatabaseProvider {
id: string;
display: string;
image: {
alt: string;
src: string;
};
versions?: IDatabaseProviderVersion[];
}
export interface IDatabaseProviderVersion {
id: string;
display: string;
}
export interface IDatabaseSpecs {
id: string;
cpu: number;
memory?: number;
storage?: number;
maxConnections?: number;
}
export interface IDatabaseConfiguration {
workspaceId: string;
name: string;
provider: IDatabaseProvider;
user: {
username: string;
password: string;
};
specs: IDatabaseSpecs;
}
export function defaultDatabaseConfiguration(workspaceId: string): IDatabaseConfiguration {
return {
workspaceId,
name: generateName().toLowerCase(),
provider: databaseProviders[0],
user: {
username: "deployadmin",
password: generatePassword({ length: 20, numberOfDigits: 4, numberOfSpecialCharacters: 4 }),
},
specs: {
cpu: 0,
memory: 0,
storage: 0,
},
};
}
export const databaseProviders: IDatabaseProvider[] = [
{
id: 'vitess',
display: 'Vitess',
image: {
alt: 'Vitess',
src: '/vitess.png',
},
},
{
id: 'redis',
display: 'Redis',
image: {
alt: 'Redis',
src: '/redis.svg',
},
},
{
id: 'postgres',
display: 'Postgres',
image: {
alt: 'Postgres',
src: '/postgres.png',
},
},
];

View File

@ -1,12 +1,11 @@
"use server";
import { IDatabaseConfig } from "@/app/(deploy)/[workspace]/databases/database-new-form";
import { IDatabaseConfiguration } from "@/lib/deploy/database-config";
import prisma from "../prisma";
import { DatabaseProvider } from "@prisma/client";
export async function deployDatabase(config: IDatabaseConfig) {
export async function deployDatabase(config: IDatabaseConfiguration) {
// TODO: Refactor using transactions
const database = await prisma.database.create({
data: {
name: config.name,
@ -35,11 +34,11 @@ export async function deployDatabase(config: IDatabaseConfig) {
username: config.user.username,
password: config.user.password,
},
specs: config.specs,
}),
});
const json = await res.json();
console.log(json)
if (!res.ok) {
throw new Error(json.message);
@ -62,4 +61,65 @@ export async function deployDatabase(config: IDatabaseConfig) {
})
throw new Error("Failed to deploy database");
}
}
export interface IDatabaseDeleteConfig {
id: string;
workspaceId: string;
}
export async function deleteDatabase(config: IDatabaseDeleteConfig) {
const database = await prisma.database.findUnique({
where: {
id: config.id
}
});
if (!database) {
throw new Error("Database not found");
}
try {
const res = await fetch(`http://127.0.0.1:8080/${config.workspaceId}/databases/${config.id}`, {
method: "DELETE",
});
const json = await res.json();
if (!res.ok && !(res.status === 404)) {
throw new Error(json.error);
}
await prisma.database.delete({
where: {
id: config.id,
}
});
return json;
} catch(err) {
throw err;
}
}
export interface IDatabaseGetConfig {
id: string;
workspaceId: string;
}
export async function getDatabase(config: IDatabaseGetConfig) {
try {
const res = await fetch(`http://127.0.0.1:8080/${config.workspaceId}/databases/${config.id}`, {
method: "GET",
});
const json = await res.json();
if (!res.ok) {
throw new Error(json.error);
}
return json;
} catch(err) {
throw err;
}
}

34
package-lock.json generated
View File

@ -20,6 +20,7 @@
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@tanstack/react-table": "^8.16.0",
"class-variance-authority": "^0.7.0",
@ -1279,6 +1280,39 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz",
"integrity": "sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/number": "1.0.1",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-collection": "1.0.3",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-direction": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-controllable-state": "1.0.1",
"@radix-ui/react-use-layout-effect": "1.0.1",
"@radix-ui/react-use-previous": "1.0.1",
"@radix-ui/react-use-size": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",

View File

@ -20,6 +20,7 @@
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@tanstack/react-table": "^8.16.0",
"class-variance-authority": "^0.7.0",

27
utils/name-generator.ts Normal file
View File

@ -0,0 +1,27 @@
var nameList = [
'Time', 'Past', 'Future', 'Dev',
'Fly', 'Flying', 'Soar', 'Soaring', 'Power', 'Falling',
'Fall', 'Jump', 'Cliff', 'Mountain', 'Rend', 'Red', 'Blue',
'Green', 'Yellow', 'Gold', 'Demon', 'Demonic', 'Panda', 'Cat',
'Kitty', 'Kitten', 'Zero', 'Memory', 'Trooper', 'XX', 'Bandit',
'Fear', 'Light', 'Glow', 'Tread', 'Deep', 'Deeper', 'Deepest',
'Mine', 'Your', 'Worst', 'Enemy', 'Hostile', 'Force', 'Video',
'Game', 'Donkey', 'Mule', 'Colt', 'Cult', 'Cultist', 'Magnum',
'Gun', 'Assault', 'Recon', 'Trap', 'Trapper', 'Redeem', 'Code',
'Script', 'Writer', 'Near', 'Close', 'Open', 'Cube', 'Circle',
'Geo', 'Genome', 'Germ', 'Spaz', 'Shot', 'Echo', 'Beta', 'Alpha',
'Gamma', 'Omega', 'Seal', 'Squid', 'Money', 'Cash', 'Lord', 'King',
'Duke', 'Rest', 'Fire', 'Flame', 'Morrow', 'Break', 'Breaker', 'Numb',
'Ice', 'Cold', 'Rotten', 'Sick', 'Sickly', 'Janitor', 'Camel', 'Rooster',
'Sand', 'Desert', 'Dessert', 'Hurdle', 'Racer', 'Eraser', 'Erase', 'Big',
'Small', 'Short', 'Tall', 'Sith', 'Bounty', 'Hunter', 'Cracked', 'Broken',
'Sad', 'Happy', 'Joy', 'Joyful', 'Crimson', 'Destiny', 'Deceit', 'Lies',
'Lie', 'Honest', 'Destined', 'Bloxxer', 'Hawk', 'Eagle', 'Hawker', 'Walker',
'Zombie', 'Sarge', 'Capt', 'Captain', 'Punch', 'One', 'Two', 'Uno', 'Slice',
'Slash', 'Melt', 'Melted', 'Melting', 'Fell', 'Wolf', 'Hound',
'Legacy', 'Sharp', 'Dead', 'Mew', 'Chuckle', 'Bubba', 'Bubble', 'Sandwich', 'Smasher', 'Extreme', 'Multi', 'Universe', 'Ultimate', 'Death', 'Ready', 'Monkey', 'Elevator', 'Wrench', 'Grease', 'Head', 'Theme', 'Grand', 'Cool', 'Kid', 'Boy', 'Girl', 'Vortex', 'Paradox'
];
export function generateName(): string {
return nameList[Math.floor(Math.random() * nameList.length)] + "-" + nameList[Math.floor(Math.random() * nameList.length)];
}

View File

@ -0,0 +1,33 @@
let special = "!@$&*()_+";
let digits = "0123456789";
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
interface PasswordGeneratorConfiguration {
length?: number;
numberOfDigits?: number;
numberOfSpecialCharacters?: number;
}
export function generatePassword({ length = 16, numberOfDigits = 2, numberOfSpecialCharacters = 4 }: PasswordGeneratorConfiguration): string {
let password = "";
for (let i = 0; i < length - numberOfDigits - numberOfSpecialCharacters; i++) {
const randomIndex = Math.floor(Math.random() * length);
const randomLetter = Math.floor(Math.random() * letters.length);
password = password.slice(0, randomIndex) + letters[randomLetter] + password.slice(randomIndex);
}
for (let i = 0; i < numberOfDigits; i++) {
const randomIndex = Math.floor(Math.random() * length);
const randomDigit = Math.floor(Math.random() * 10);
password = password.slice(0, randomIndex) + digits[randomDigit] + password.slice(randomIndex);
}
for (let i = 0; i < numberOfSpecialCharacters; i++) {
const randomIndex = Math.floor(Math.random() * length);
const randomSpecial = Math.floor(Math.random() * special.length);
password = password.slice(0, randomIndex) + special[randomSpecial] + password.slice(randomIndex);
}
return password;
}