diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1495e77 Binary files /dev/null and b/.DS_Store differ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/backend/.gitignore b/backend/.gitignore index f3f23b8..8078b14 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -3,3 +3,4 @@ node_modules/ storage/ package-lock.json bun.lockb +.env diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 52a3e35..eca606e 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -1,24 +1,11 @@ -FROM alpine:3.19.0 - -ENV GLIBC_VERSION 2.34-r0 +FROM oven/bun:alpine WORKDIR /app -# Install bun -ADD https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip bun-linux-x64.zip -RUN apk add --no-cache --update unzip curl && \ - curl -Lo /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ - curl -Lo glibc.apk "https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-${GLIBC_VERSION}.apk" && \ - curl -Lo glibc-bin.apk "https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-bin-${GLIBC_VERSION}.apk" && \ - apk add --force-overwrite glibc-bin.apk glibc.apk && \ - /usr/glibc-compat/sbin/ldconfig /lib /usr/glibc-compat/lib && \ - echo 'hosts: files mdns4_minimal [NOTFOUND=return] dns mdns4' >> /etc/nsswitch.conf && \ - apk del curl && \ - rm -rf /var/cache/apk/* glibc.apk glibc-bin.apk - -RUN unzip bun-linux-x64.zip && chmod +x ./bun-linux-x64/bun && mv ./bun-linux-x64/bun /usr/bin && rm -f bun-linux-x64.zip +COPY ["package.json", "bun.lockb", "./"] +RUN bun install # Add db clients -RUN apk --no-cache add postgresql16-client +RUN apk --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main add postgresql16-client ENTRYPOINT ["bun", "run", "dev"] diff --git a/backend/Dockerfile.dev.bak b/backend/Dockerfile.dev.bak deleted file mode 100644 index 67ad9ed..0000000 --- a/backend/Dockerfile.dev.bak +++ /dev/null @@ -1,28 +0,0 @@ -FROM alpine:3.19 -WORKDIR /app - -ENV GLIBC_VERSION 2.35-r1 - -RUN apk update && \ - apk add --no-cache --update unzip curl - # curl -Lo /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ - # curl -Lo glibc.apk "https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-${GLIBC_VERSION}.apk" && \ - # curl -Lo glibc-bin.apk "https://github.com/sgerrand/alpine-pkg-glibc/releases/download/${GLIBC_VERSION}/glibc-bin-${GLIBC_VERSION}.apk" && \ - # apk add --force-overwrite glibc-bin.apk glibc.apk && \ - # /usr/glibc-compat/sbin/ldconfig /lib /usr/glibc-compat/lib && \ - # echo 'hosts: files mdns4_minimal [NOTFOUND=return] dns mdns4' >> /etc/nsswitch.conf && \ - # apk del curl && \ - # rm -rf /var/cache/apk/* glibc.apk glibc-bin.apk - -ADD https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip bun-linux-x64.zip -# RUN unzip bun-linux-x64.zip && chmod +x ./bun-linux-x64/bun && mv ./bun-linux-x64/bun /usr/local/bin && rm -rf bun-linux-x64.zip -RUN unzip bun-linux-x64.zip && ls bun-linux-x64 && ./bun-linux-x64/bun --version - -RUN chmod +x /usr/local/bin/bun -RUN /usr/local/bin/bun --version - -# CMD ["bun", "--version"] - -# RUN apk --no-cache add postgresql16-client - -# ENTRYPOINT ["bun", "run", "dev"] diff --git a/backend/docker-compose.dev.yml b/backend/docker-compose.dev.yml index 41efbfc..86e3ea1 100644 --- a/backend/docker-compose.dev.yml +++ b/backend/docker-compose.dev.yml @@ -10,3 +10,5 @@ services: - ./:/app:rw extra_hosts: - "host.docker.internal:host-gateway" + ports: + - "3000:3000" diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts new file mode 100644 index 0000000..3ab544e --- /dev/null +++ b/backend/drizzle.config.ts @@ -0,0 +1,11 @@ +import { STORAGE_DIR } from "@/consts"; +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + dbCredentials: { + url: STORAGE_DIR + "/database.db", + }, + schema: "./src/db/models.ts", + out: "./src/db/migrations", +}); diff --git a/backend/package.json b/backend/package.json index ca2c39a..701a105 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,15 +3,26 @@ "module": "index.ts", "type": "module", "scripts": { - "dev": "bun --watch index.ts", - "dev:compose": "docker compose -f docker-compose.dev.yml up --build", + "dev": "bun --watch src/main.ts", + "dev:compose": "cp ../bun.lockb . && docker compose -f docker-compose.dev.yml up --build", "build": "bun build index.ts --outdir dist --target bun", - "start": "bun dist/index.js" + "start": "bun dist/main.js", + "generate": "drizzle-kit generate", + "migrate": "bun src/db/migrate.ts", + "reset": "rm -f storage/database.db && bun run migrate" }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "drizzle-kit": "^0.21.0" }, "peerDependencies": { "typescript": "^5.0.0" + }, + "dependencies": { + "@hono/zod-validator": "^0.2.1", + "drizzle-orm": "^0.30.10", + "hono": "^4.3.4", + "nanoid": "^5.0.7", + "zod": "^3.23.8" } -} \ No newline at end of file +} diff --git a/backend/src/consts.ts b/backend/src/consts.ts new file mode 100644 index 0000000..e4b3952 --- /dev/null +++ b/backend/src/consts.ts @@ -0,0 +1,6 @@ +import path from "path"; + +export const DOCKER_HOST = "host.docker.internal"; +export const STORAGE_DIR = path.resolve(__dirname, "../storage"); +export const BACKUP_DIR = STORAGE_DIR + "/backups"; +export const DATABASE_PATH = path.join(STORAGE_DIR, "database.db"); diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts new file mode 100644 index 0000000..81568b1 --- /dev/null +++ b/backend/src/db/index.ts @@ -0,0 +1,16 @@ +import path from "path"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { Database } from "bun:sqlite"; +import { DATABASE_PATH } from "@/consts"; +import { mkdir } from "@/utility/utils"; +import schema from "./schema"; + +// Create database directory if not exists +mkdir(path.dirname(DATABASE_PATH)); + +// Initialize database +const sqlite = new Database(DATABASE_PATH); +const db = drizzle(sqlite, { schema }); + +export { sqlite }; +export default db; diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts new file mode 100644 index 0000000..8a300b9 --- /dev/null +++ b/backend/src/db/migrate.ts @@ -0,0 +1,17 @@ +import fs from "fs"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { DATABASE_PATH } from "@/consts"; +import db, { sqlite } from "."; +import { seed } from "./seed"; + +const initializeData = fs.existsSync(DATABASE_PATH); + +await migrate(db, { + migrationsFolder: __dirname + "/migrations", +}); + +if (initializeData) { + await seed(); +} + +await sqlite.close(); diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts new file mode 100644 index 0000000..db246d7 --- /dev/null +++ b/backend/src/db/models.ts @@ -0,0 +1,48 @@ +import type { DatabaseConfig } from "@/types/database.types"; +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { nanoid } from "nanoid"; + +export const userModel = sqliteTable("users", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + username: text("username").notNull().unique(), + password: text("password").notNull(), + isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const serverModel = sqliteTable("servers", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + name: text("name").notNull(), + type: text("type", { enum: ["postgres"] }).notNull(), + connection: text("connection"), + ssh: text("ssh"), + isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const databaseModel = sqliteTable("databases", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + serverId: text("server_id") + .references(() => serverModel.id, { + onUpdate: "cascade", + onDelete: "cascade", + }) + .notNull(), + name: text("name").notNull(), + isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), + lastBackupAt: text("last_backup_at"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts new file mode 100644 index 0000000..934c779 --- /dev/null +++ b/backend/src/db/schema.ts @@ -0,0 +1,9 @@ +import { databaseModel, serverModel, userModel } from "./models"; + +const schema = { + users: userModel, + servers: serverModel, + database: databaseModel, +}; + +export default schema; diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts new file mode 100644 index 0000000..1090f34 --- /dev/null +++ b/backend/src/db/seed.ts @@ -0,0 +1,12 @@ +import db from "."; +import { userModel } from "./models"; + +export const seed = async () => { + await db + .insert(userModel) + .values({ + username: "admin", + password: await Bun.password.hash("admin", { algorithm: "bcrypt" }), + }) + .execute(); +}; diff --git a/backend/src/lib/database.ts b/backend/src/lib/database-util.ts similarity index 89% rename from backend/src/lib/database.ts rename to backend/src/lib/database-util.ts index b7d0f6f..82b721f 100644 --- a/backend/src/lib/database.ts +++ b/backend/src/lib/database-util.ts @@ -1,5 +1,5 @@ -import BaseDbms from "../dbms/base"; -import PostgresDbms from "../dbms/postgres"; +import BaseDbms from "./dbms/base"; +import PostgresDbms from "./dbms/postgres"; import type { DatabaseConfig, DatabaseListItem } from "../types/database.types"; class DatabaseUtil { diff --git a/backend/src/dbms/base.ts b/backend/src/lib/dbms/base.ts similarity index 80% rename from backend/src/dbms/base.ts rename to backend/src/lib/dbms/base.ts index 5a5a2b0..536786e 100644 --- a/backend/src/dbms/base.ts +++ b/backend/src/lib/dbms/base.ts @@ -1,4 +1,4 @@ -import type { DatabaseListItem } from "../types/database.types"; +import type { DatabaseListItem } from "../../types/database.types"; class BaseDbms { async getDatabases(): Promise { diff --git a/backend/src/dbms/postgres.ts b/backend/src/lib/dbms/postgres.ts similarity index 90% rename from backend/src/dbms/postgres.ts rename to backend/src/lib/dbms/postgres.ts index 899d8e0..d9d2f52 100644 --- a/backend/src/dbms/postgres.ts +++ b/backend/src/lib/dbms/postgres.ts @@ -1,5 +1,8 @@ -import type { DatabaseListItem, PostgresConfig } from "../types/database.types"; -import { exec } from "../utility/process"; +import type { + DatabaseListItem, + PostgresConfig, +} from "../../types/database.types"; +import { exec } from "../../utility/process"; import BaseDbms from "./base"; class PostgresDbms extends BaseDbms { diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..25c5d17 --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,5 @@ +import routers from "./routers"; + +console.log("Starting app.."); + +export default routers; diff --git a/backend/src/routers/index.ts b/backend/src/routers/index.ts new file mode 100644 index 0000000..df5cecf --- /dev/null +++ b/backend/src/routers/index.ts @@ -0,0 +1,17 @@ +import { Hono, type Context } from "hono"; +import server from "./server.router"; + +const handleError = (err: Error, c: Context) => { + return c.json({ + success: false, + error: err, + message: err.message, + }); +}; + +const routers = new Hono() + .onError(handleError) + .get("/health-check", (c) => c.text("OK")) + .route("/servers", server); + +export default routers; diff --git a/backend/src/routers/server.router.ts b/backend/src/routers/server.router.ts new file mode 100644 index 0000000..e9de634 --- /dev/null +++ b/backend/src/routers/server.router.ts @@ -0,0 +1,38 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { createServerSchema } from "@/schemas/server.schema"; +import db from "@/db"; +import { asc, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; +import { serverModel } from "@/db/models"; + +const router = new Hono() + + .get("/", async (c) => { + const servers = await db.query.servers.findMany({ + columns: { connection: false, ssh: false }, + orderBy: asc(serverModel.createdAt), + }); + return c.json(servers); + }) + + .post("/", zValidator("json", createServerSchema), async (c) => { + const data = c.req.valid("json"); + const isExist = await db.query.servers.findFirst({ + where: eq(serverModel.name, data.name), + }); + if (isExist) { + throw new HTTPException(400, { message: "Server name already exists" }); + } + + const dataValue = { + ...data, + connection: data.connection ? JSON.stringify(data.connection) : null, + ssh: data.ssh ? JSON.stringify(data.ssh) : null, + }; + const [result] = await db.insert(serverModel).values(dataValue).returning(); + + return c.json(result); + }); + +export default router; diff --git a/backend/src/schemas/server.schema.ts b/backend/src/schemas/server.schema.ts new file mode 100644 index 0000000..e79b077 --- /dev/null +++ b/backend/src/schemas/server.schema.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +export const serverTypeEnum = ["postgres"] as const; + +export const serverSchema = z.object({ + name: z.string().min(1), + ssh: z + .object({ + host: z.string(), + port: z.number().optional(), + user: z.string(), + pass: z.string().optional(), + privateKey: z.string().optional(), + }) + .optional() + .nullable(), + isActive: z.boolean().optional(), +}); + +const postgresSchema = serverSchema.merge( + z.object({ + type: z.literal("postgres"), + connection: z.object({ + host: z.string(), + port: z.number().optional(), + user: z.string(), + pass: z.string().optional(), + }), + }) +); + +export const createServerSchema = z.discriminatedUnion("type", [ + postgresSchema, +]); + +export type CreateServerSchema = z.infer; diff --git a/backend/index.ts b/backend/src/test.ts similarity index 79% rename from backend/index.ts rename to backend/src/test.ts index 31841b5..2fff5dd 100644 --- a/backend/index.ts +++ b/backend/src/test.ts @@ -1,5 +1,5 @@ -import DatabaseUtil from "@/lib/database"; -import { DOCKER_HOST, STORAGE_DIR } from "@/utility/consts"; +import DatabaseUtil from "@/lib/database-util"; +import { DOCKER_HOST, BACKUP_DIR } from "@/consts"; import { mkdir } from "@/utility/utils"; import path from "path"; @@ -19,7 +19,7 @@ const main = async () => { const dbName = "test"; // Create backup - const outDir = path.join(STORAGE_DIR, db.config.host, dbName); + const outDir = path.join(BACKUP_DIR, db.config.host, dbName); mkdir(outDir); const outFile = path.join(outDir, `/${Date.now()}.tar`); console.log(await db.dump(dbName, outFile)); diff --git a/backend/src/utility/consts.ts b/backend/src/utility/consts.ts deleted file mode 100644 index 33d3a26..0000000 --- a/backend/src/utility/consts.ts +++ /dev/null @@ -1,4 +0,0 @@ -import path from "path"; - -export const DOCKER_HOST = "host.docker.internal"; -export const STORAGE_DIR = path.resolve(__dirname, "../../storage"); diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..270fe91 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json index 5448f93..3934e0c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ }, "private": false, "license": "MIT", + "workspaces": [ + "backend", + "frontend" + ], "scripts": { "dev": "concurrently \"cd backend && pnpm dev\" \"cd frontend && pnpm dev\"" }, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 507e28e..0000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,3 +0,0 @@ -packages: - - backend - - frontend