db: add monitoring schema and sample seed data

This commit is contained in:
Syahdan 2026-04-29 17:28:21 +07:00
parent 33e75c056f
commit c7edd90a7e
8 changed files with 1689 additions and 0 deletions

View file

@ -14,12 +14,14 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:seed": "bun --env-file=../../apps/server/.env run ./src/seed.ts",
"db:start": "docker compose up -d", "db:start": "docker compose up -d",
"db:watch": "docker compose up", "db:watch": "docker compose up",
"db:stop": "docker compose stop", "db:stop": "docker compose stop",
"db:down": "docker compose down" "db:down": "docker compose down"
}, },
"dependencies": { "dependencies": {
"better-auth": "catalog:",
"@minmon/env": "workspace:*", "@minmon/env": "workspace:*",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",

View file

@ -3,6 +3,8 @@ import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema"; import * as schema from "./schema";
export * from "./schema";
export function createDb() { export function createDb() {
return drizzle(env.DATABASE_URL, { schema }); return drizzle(env.DATABASE_URL, { schema });
} }

View file

@ -0,0 +1,136 @@
CREATE TYPE "public"."domain_resolution_status" AS ENUM('pending', 'resolved', 'mismatch', 'unreachable');--> statement-breakpoint
CREATE TYPE "public"."note_category" AS ENUM('general', 'maintenance', 'incident', 'todo');--> statement-breakpoint
CREATE TYPE "public"."server_environment" AS ENUM('development', 'staging', 'production');--> statement-breakpoint
CREATE TYPE "public"."server_status" AS ENUM('healthy', 'warning', 'critical', 'offline');--> statement-breakpoint
CREATE TYPE "public"."service_status" AS ENUM('running', 'degraded', 'stopped', 'maintenance');--> statement-breakpoint
CREATE TYPE "public"."service_type" AS ENUM('frontend', 'backend', 'database', 'worker', 'proxy', 'monitoring', 'other');--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "domain" (
"id" text PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"service_id" text,
"name" text NOT NULL,
"expected_server_ip" text NOT NULL,
"last_resolved_ipv4" text,
"last_resolved_ipv6" text,
"resolution_status" "domain_resolution_status" DEFAULT 'pending' NOT NULL,
"last_checked_at" timestamp,
"remarks" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "note" (
"id" text PRIMARY KEY NOT NULL,
"server_id" text,
"service_id" text,
"domain_id" text,
"title" text NOT NULL,
"content" text NOT NULL,
"category" "note_category" DEFAULT 'general' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "server" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"host" text NOT NULL,
"ip_address" text NOT NULL,
"provider" text,
"region" text,
"environment" "server_environment" DEFAULT 'production' NOT NULL,
"status" "server_status" DEFAULT 'healthy' NOT NULL,
"operating_system" text,
"remarks" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "service" (
"id" text PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"name" text NOT NULL,
"type" "service_type" DEFAULT 'other' NOT NULL,
"status" "service_status" DEFAULT 'running' NOT NULL,
"port" text,
"version" text,
"health_check_path" text,
"remarks" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "domain" ADD CONSTRAINT "domain_server_id_server_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."server"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "domain" ADD CONSTRAINT "domain_service_id_service_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."service"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "note" ADD CONSTRAINT "note_server_id_server_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."server"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "note" ADD CONSTRAINT "note_service_id_service_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."service"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "note" ADD CONSTRAINT "note_domain_id_domain_id_fk" FOREIGN KEY ("domain_id") REFERENCES "public"."domain"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "service" ADD CONSTRAINT "service_server_id_server_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."server"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint
CREATE UNIQUE INDEX "domain_name_unique" ON "domain" USING btree ("name");--> statement-breakpoint
CREATE INDEX "domain_serverId_idx" ON "domain" USING btree ("server_id");--> statement-breakpoint
CREATE INDEX "domain_serviceId_idx" ON "domain" USING btree ("service_id");--> statement-breakpoint
CREATE INDEX "domain_resolutionStatus_idx" ON "domain" USING btree ("resolution_status");--> statement-breakpoint
CREATE INDEX "note_serverId_idx" ON "note" USING btree ("server_id");--> statement-breakpoint
CREATE INDEX "note_serviceId_idx" ON "note" USING btree ("service_id");--> statement-breakpoint
CREATE INDEX "note_domainId_idx" ON "note" USING btree ("domain_id");--> statement-breakpoint
CREATE INDEX "note_category_idx" ON "note" USING btree ("category");--> statement-breakpoint
CREATE UNIQUE INDEX "server_host_unique" ON "server" USING btree ("host");--> statement-breakpoint
CREATE UNIQUE INDEX "server_ipAddress_unique" ON "server" USING btree ("ip_address");--> statement-breakpoint
CREATE INDEX "server_environment_idx" ON "server" USING btree ("environment");--> statement-breakpoint
CREATE INDEX "server_status_idx" ON "server" USING btree ("status");--> statement-breakpoint
CREATE INDEX "service_serverId_idx" ON "service" USING btree ("server_id");--> statement-breakpoint
CREATE INDEX "service_status_idx" ON "service" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "service_serverId_name_unique" ON "service" USING btree ("server_id","name");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1777458791180,
"tag": "0000_thick_molecule_man",
"breakpoints": true
}
]
}

View file

@ -1,2 +1,3 @@
export * from "./auth"; export * from "./auth";
export * from "./monitoring";
export {}; export {};

View file

@ -0,0 +1,168 @@
import { relations, sql } from "drizzle-orm";
import {
index,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const serverStatusEnum = pgEnum("server_status", ["active", "maintenance", "inactive"]);
export const serviceTypeEnum = pgEnum("service_type", [
"web_app",
"mail",
"database",
"api",
"proxy",
"other",
]);
export const serviceProtocolEnum = pgEnum("service_protocol", [
"http",
"https",
"tcp",
"udp",
"other",
]);
export const serviceStatusEnum = pgEnum("service_status", ["active", "down", "maintenance"]);
export const domainResolutionStatusEnum = pgEnum("domain_resolution_status", [
"MATCH",
"MISMATCH",
"UNRESOLVED",
]);
export const server = pgTable(
"server",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
primaryIpAddress: text("primary_ip_address").notNull(),
secondaryIpAddress: text("secondary_ip_address"),
operatingSystem: text("operating_system"),
location: text("location"),
provider: text("provider"),
description: text("description"),
status: serverStatusEnum("status").default("active").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [
uniqueIndex("server_name_unique").on(table.name),
index("server_status_idx").on(table.status),
],
);
export const service = pgTable(
"service",
{
id: text("id").primaryKey(),
serverId: text("server_id")
.notNull()
.references(() => server.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: serviceTypeEnum("type").default("other").notNull(),
internalPort: integer("internal_port"),
externalPort: integer("external_port"),
protocol: serviceProtocolEnum("protocol").default("http").notNull(),
status: serviceStatusEnum("status").default("active").notNull(),
description: text("description"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [
index("service_server_id_idx").on(table.serverId),
index("service_status_idx").on(table.status),
uniqueIndex("service_server_name_unique").on(table.serverId, table.name),
],
);
export const domain = pgTable(
"domain",
{
id: text("id").primaryKey(),
serviceId: text("service_id")
.notNull()
.references(() => service.id, { onDelete: "cascade" }),
name: text("name").notNull(),
expectedServerIp: text("expected_server_ip").notNull(),
lastResolvedIp: text("last_resolved_ip"),
lastResolvedIps: jsonb("last_resolved_ips")
.$type<string[]>()
.default(sql`'[]'::jsonb`)
.notNull(),
resolutionStatus: domainResolutionStatusEnum("resolution_status")
.default("UNRESOLVED")
.notNull(),
lastCheckedAt: timestamp("last_checked_at"),
remarks: text("remarks"),
lastCheckMessage: text("last_check_message"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [
uniqueIndex("domain_name_unique").on(table.name),
index("domain_service_id_idx").on(table.serviceId),
index("domain_resolution_status_idx").on(table.resolutionStatus),
],
);
export const note = pgTable(
"note",
{
id: text("id").primaryKey(),
serverId: text("server_id")
.notNull()
.references(() => server.id, { onDelete: "cascade" }),
title: text("title").notNull(),
content: text("content").notNull(),
category: text("category"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("note_server_id_idx").on(table.serverId), index("note_category_idx").on(table.category)],
);
export const serverRelations = relations(server, ({ many }) => ({
services: many(service),
notes: many(note),
}));
export const serviceRelations = relations(service, ({ one, many }) => ({
server: one(server, {
fields: [service.serverId],
references: [server.id],
}),
domains: many(domain),
}));
export const domainRelations = relations(domain, ({ one }) => ({
service: one(service, {
fields: [domain.serviceId],
references: [service.id],
}),
}));
export const noteRelations = relations(note, ({ one }) => ({
server: one(server, {
fields: [note.serverId],
references: [server.id],
}),
}));

278
packages/db/src/seed.ts Normal file
View file

@ -0,0 +1,278 @@
import dotenv from "dotenv";
import { hashPassword } from "better-auth/crypto";
dotenv.config({
path: new URL("../../../apps/server/.env", import.meta.url).pathname,
});
import { db } from "./index";
import { account, user } from "./schema/auth";
import { domain, note, server, service } from "./schema/monitoring";
const now = new Date();
const earlierToday = new Date(now.getTime() - 45 * 60 * 1000);
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const adminUser = {
id: "user_admin_minmon",
name: "Minmon Admin",
email: "admin@minmon.local",
password: "admin12345",
};
const servers = [
{
id: "server_prod_1",
name: "Production VPS",
primaryIpAddress: "203.0.113.10",
secondaryIpAddress: "203.0.113.11",
operatingSystem: "Ubuntu 24.04 LTS",
location: "Singapore",
provider: "DigitalOcean",
description: "Main production server hosting the dashboard web app, API, and reverse proxy.",
status: "active" as const,
},
{
id: "server_mail_1",
name: "Mail Server",
primaryIpAddress: "198.51.100.20",
secondaryIpAddress: null,
operatingSystem: "Debian 12",
location: "Jakarta",
provider: "Hetzner",
description: "Handles the demo mail service and SMTP/IMAP examples.",
status: "maintenance" as const,
},
{
id: "server_lab_1",
name: "Campus Lab VM",
primaryIpAddress: "192.0.2.18",
secondaryIpAddress: null,
operatingSystem: "Ubuntu 22.04 LTS",
location: "Campus Lab",
provider: "University Lab",
description: "Used for student practice and lecturer demonstration in class.",
status: "inactive" as const,
},
];
const services = [
{
id: "service_dashboard_web",
serverId: "server_prod_1",
name: "Dashboard Web",
type: "web_app" as const,
internalPort: 3000,
externalPort: 443,
protocol: "https" as const,
status: "active" as const,
description: "Frontend application for the private server management dashboard.",
},
{
id: "service_dashboard_api",
serverId: "server_prod_1",
name: "Dashboard API",
type: "api" as const,
internalPort: 3001,
externalPort: 443,
protocol: "https" as const,
status: "active" as const,
description: "Handles CRUD data, dashboard summary, and DNS checks.",
},
{
id: "service_mail_gateway",
serverId: "server_mail_1",
name: "Mail Gateway",
type: "mail" as const,
internalPort: 25,
externalPort: 25,
protocol: "tcp" as const,
status: "maintenance" as const,
description: "Mail service used as a sample SMTP gateway for the assignment.",
},
{
id: "service_lab_proxy",
serverId: "server_lab_1",
name: "Lab Reverse Proxy",
type: "proxy" as const,
internalPort: 80,
externalPort: 80,
protocol: "http" as const,
status: "down" as const,
description: "Reverse proxy for local practice deployments in the campus lab.",
},
];
const domains = [
{
id: "domain_dashboard_web",
serviceId: "service_dashboard_web",
name: "dashboard.minmon.dev",
expectedServerIp: "203.0.113.10",
lastResolvedIp: "203.0.113.10",
lastResolvedIps: ["203.0.113.10"],
resolutionStatus: "MATCH" as const,
lastCheckedAt: earlierToday,
remarks: "Primary domain for the dashboard frontend.",
lastCheckMessage: null,
},
{
id: "domain_dashboard_api",
serviceId: "service_dashboard_api",
name: "api.minmon.dev",
expectedServerIp: "203.0.113.10",
lastResolvedIp: "203.0.113.15",
lastResolvedIps: ["203.0.113.15", "203.0.113.10"],
resolutionStatus: "MATCH" as const,
lastCheckedAt: earlierToday,
remarks: "API subdomain. Multiple A records still include the expected IP.",
lastCheckMessage: null,
},
{
id: "domain_mail_gateway",
serviceId: "service_mail_gateway",
name: "mail.minmon.dev",
expectedServerIp: "198.51.100.20",
lastResolvedIp: "198.51.100.77",
lastResolvedIps: ["198.51.100.77"],
resolutionStatus: "MISMATCH" as const,
lastCheckedAt: yesterday,
remarks: "DNS still points to the old mail gateway.",
lastCheckMessage: null,
},
{
id: "domain_lab_demo",
serviceId: "service_lab_proxy",
name: "lab.minmon.dev",
expectedServerIp: "192.0.2.18",
lastResolvedIp: null,
lastResolvedIps: [],
resolutionStatus: "UNRESOLVED" as const,
lastCheckedAt: yesterday,
remarks: "Lab VM is powered off outside teaching hours.",
lastCheckMessage: "DNS lookup failed because the host could not be resolved.",
},
];
const notes = [
{
id: "note_prod_backup",
serverId: "server_prod_1",
title: "Weekly backup reminder",
content: "Verify the PostgreSQL backup every Friday before the lecturer demo session.",
category: "maintenance",
},
{
id: "note_prod_dataset",
serverId: "server_prod_1",
title: "Main sample dataset",
content:
"This production server and its services form the main student-facing sample dataset for CRUD and monitoring demos.",
category: "general",
},
{
id: "note_mail_dns",
serverId: "server_mail_1",
title: "Update stale A record",
content: "Point mail.minmon.dev to the current mail server before final presentation week.",
category: "todo",
},
{
id: "note_lab_status",
serverId: "server_lab_1",
title: "Lab VM inactive",
content: "The campus lab VM is intentionally inactive outside class hours, so related checks may fail.",
category: "incident",
},
];
async function seedAuthAdmin() {
const existingUser = await db.query.user.findFirst({
where: (table, { eq }) => eq(table.email, adminUser.email),
});
if (existingUser) {
return existingUser.id;
}
const passwordHash = await hashPassword(adminUser.password);
await db.insert(user).values({
id: adminUser.id,
name: adminUser.name,
email: adminUser.email,
emailVerified: true,
createdAt: now,
updatedAt: now,
});
await db.insert(account).values({
id: "account_admin_minmon",
accountId: adminUser.id,
providerId: "credential",
userId: adminUser.id,
password: passwordHash,
createdAt: now,
updatedAt: now,
});
return adminUser.id;
}
async function seedMonitoringData() {
await db.delete(note);
await db.delete(domain);
await db.delete(service);
await db.delete(server);
await db.insert(server).values(
servers.map((item) => ({
...item,
secondaryIpAddress: item.secondaryIpAddress ?? undefined,
createdAt: now,
updatedAt: now,
})),
);
await db.insert(service).values(
services.map((item) => ({
...item,
createdAt: now,
updatedAt: now,
})),
);
await db.insert(domain).values(
domains.map((item) => ({
...item,
lastResolvedIp: item.lastResolvedIp ?? undefined,
lastCheckMessage: item.lastCheckMessage ?? undefined,
createdAt: now,
updatedAt: now,
})),
);
await db.insert(note).values(
notes.map((item) => ({
...item,
createdAt: now,
updatedAt: now,
})),
);
}
async function seed() {
await seedAuthAdmin();
await seedMonitoringData();
}
seed()
.then(() => {
console.info("Seeded auth admin and monitoring dashboard sample data.");
console.info("Admin login: admin@minmon.local / admin12345");
process.exit(0);
})
.catch((error) => {
console.error("Failed to seed dashboard sample data.", error);
process.exit(1);
});