ui: add protected dashboard CRUD screens and assignment docs
This commit is contained in:
parent
bf42037619
commit
cb0d4d7809
24 changed files with 2662 additions and 101 deletions
227
README.md
227
README.md
|
|
@ -1,103 +1,190 @@
|
||||||
# minmon
|
# Dashboard Manajemen Layanan Server Private Berbasis Web
|
||||||
|
|
||||||
This project was created with [Better-T-Stack](https://github.com/AmanVarshney01/create-better-t-stack), a modern TypeScript stack that combines React, TanStack Router, Elysia, TRPC, and more.
|
Minmon is a student-friendly web application for managing private servers, hosted services, attached domains, and internal server notes. The system focuses on CRUD operations plus lightweight DNS monitoring, not deployment automation.
|
||||||
|
|
||||||
## Features
|
## Final Project Scope
|
||||||
|
|
||||||
- **TypeScript** - For type safety and improved developer experience
|
This app allows an admin to:
|
||||||
- **TanStack Router** - File-based routing with full type safety
|
|
||||||
- **TailwindCSS** - Utility-first CSS for rapid UI development
|
|
||||||
- **Shared UI package** - shadcn/ui primitives live in `packages/ui`
|
|
||||||
- **Elysia** - Type-safe, high-performance framework
|
|
||||||
- **tRPC** - End-to-end type-safe APIs
|
|
||||||
- **Bun** - Runtime environment
|
|
||||||
- **Drizzle** - TypeScript-first ORM
|
|
||||||
- **PostgreSQL** - Database engine
|
|
||||||
- **Authentication** - Better-Auth
|
|
||||||
- **Electrobun** - Lightweight desktop shell for web frontends
|
|
||||||
- **Turborepo** - Optimized monorepo build system
|
|
||||||
|
|
||||||
## Getting Started
|
- Manage servers
|
||||||
|
- Manage services running on each server
|
||||||
|
- Manage domains attached to services
|
||||||
|
- Manage notes/documentation for each server
|
||||||
|
- Run DNS A-record checks and compare resolved IPs with the expected server IP
|
||||||
|
- View a dashboard summary
|
||||||
|
|
||||||
First, install the dependencies:
|
## Main Features
|
||||||
|
|
||||||
|
- Better-auth admin login
|
||||||
|
- Dashboard summary for:
|
||||||
|
- total servers
|
||||||
|
- total services
|
||||||
|
- total domains
|
||||||
|
- matched domains
|
||||||
|
- mismatched domains
|
||||||
|
- unresolved domains
|
||||||
|
- inactive/down services
|
||||||
|
- recent notes
|
||||||
|
- Server CRUD
|
||||||
|
- Service CRUD
|
||||||
|
- Domain CRUD
|
||||||
|
- DNS check action
|
||||||
|
- Server notes CRUD
|
||||||
|
- Sample seeded admin and demo dataset
|
||||||
|
|
||||||
|
## Database / Entity Overview
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- primaryIpAddress
|
||||||
|
- secondaryIpAddress
|
||||||
|
- operatingSystem
|
||||||
|
- location
|
||||||
|
- provider
|
||||||
|
- description
|
||||||
|
- status
|
||||||
|
- createdAt
|
||||||
|
- updatedAt
|
||||||
|
|
||||||
|
### Service
|
||||||
|
|
||||||
|
- id
|
||||||
|
- serverId
|
||||||
|
- name
|
||||||
|
- type
|
||||||
|
- internalPort
|
||||||
|
- externalPort
|
||||||
|
- protocol
|
||||||
|
- status
|
||||||
|
- description
|
||||||
|
- createdAt
|
||||||
|
- updatedAt
|
||||||
|
|
||||||
|
### Domain
|
||||||
|
|
||||||
|
- id
|
||||||
|
- serviceId
|
||||||
|
- name
|
||||||
|
- expectedServerIp
|
||||||
|
- lastResolvedIp
|
||||||
|
- lastResolvedIps
|
||||||
|
- resolutionStatus
|
||||||
|
- lastCheckedAt
|
||||||
|
- remarks
|
||||||
|
- lastCheckMessage
|
||||||
|
- createdAt
|
||||||
|
- updatedAt
|
||||||
|
|
||||||
|
### Note
|
||||||
|
|
||||||
|
- id
|
||||||
|
- serverId
|
||||||
|
- title
|
||||||
|
- content
|
||||||
|
- category
|
||||||
|
- createdAt
|
||||||
|
- updatedAt
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
- One server has many services
|
||||||
|
- One server has many notes
|
||||||
|
- One service belongs to one server
|
||||||
|
- One service has many domains
|
||||||
|
- One domain belongs to one service
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- React
|
||||||
|
- TanStack Router
|
||||||
|
- tRPC
|
||||||
|
- PostgreSQL
|
||||||
|
- Drizzle ORM
|
||||||
|
- better-auth
|
||||||
|
- Effect (used narrowly for DNS checking)
|
||||||
|
- Bun
|
||||||
|
- Turborepo
|
||||||
|
|
||||||
|
## Local Run Instructions
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Setup
|
2. Create `apps/server/.env`
|
||||||
|
|
||||||
This project uses PostgreSQL with Drizzle ORM.
|
```env
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/minmon
|
||||||
|
BETTER_AUTH_SECRET=your-32-char-or-longer-secret-value-here
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
1. Make sure you have a PostgreSQL database set up.
|
3. Start PostgreSQL if needed
|
||||||
2. Update your `apps/server/.env` file with your PostgreSQL connection details.
|
|
||||||
|
|
||||||
3. Apply the schema to your database:
|
```bash
|
||||||
|
bun run db:start
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Apply database schema
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, run the development server:
|
5. Seed sample data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --filter @minmon/db db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Seeded admin:
|
||||||
|
|
||||||
|
- Email: `admin@minmon.local`
|
||||||
|
- Password: `admin12345`
|
||||||
|
|
||||||
|
6. Run the application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:5173](http://localhost:5173) in your browser to see the web application.
|
7. Open the app
|
||||||
The API is running at [http://localhost:3000](http://localhost:3000).
|
|
||||||
|
|
||||||
## UI Customization
|
- Web: `http://localhost:5173`
|
||||||
|
- API: `http://localhost:3000`
|
||||||
React web apps in this stack share shadcn/ui primitives through `packages/ui`.
|
|
||||||
|
|
||||||
- Change design tokens and global styles in `packages/ui/src/styles/globals.css`
|
|
||||||
- Update shared primitives in `packages/ui/src/components/*`
|
|
||||||
- Adjust shadcn aliases or style config in `packages/ui/components.json` and `apps/web/components.json`
|
|
||||||
|
|
||||||
### Add more shared components
|
|
||||||
|
|
||||||
Run this from the project root to add more primitives to the shared UI package:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx shadcn@latest add accordion dialog popover sheet table -c packages/ui
|
|
||||||
```
|
|
||||||
|
|
||||||
Import shared components like this:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Button } from "@minmon/ui/components/button";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add app-specific blocks
|
|
||||||
|
|
||||||
If you want to add app-specific blocks instead of shared primitives, run the shadcn CLI from `apps/web`.
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```text
|
||||||
minmon/
|
minmon/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── web/ # Frontend application (React + TanStack Router)
|
│ ├── web/
|
||||||
│ └── server/ # Backend API (Elysia, TRPC)
|
│ └── server/
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── ui/ # Shared shadcn/ui components and styles
|
│ ├── api/
|
||||||
│ ├── api/ # API layer / business logic
|
│ ├── auth/
|
||||||
│ ├── auth/ # Authentication configuration & logic
|
│ ├── db/
|
||||||
│ └── db/ # Database schema & queries
|
│ ├── env/
|
||||||
|
│ └── ui/
|
||||||
|
└── docs/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available Scripts
|
## Extra Assignment Deliverables
|
||||||
|
|
||||||
- `bun run dev`: Start all applications in development mode
|
See:
|
||||||
- `bun run build`: Build all applications
|
|
||||||
- `bun run dev:web`: Start only the web application
|
- `docs/run-instructions.md`
|
||||||
- `bun run dev:server`: Start only the server
|
- `docs/uml-guidance.md`
|
||||||
- `bun run check-types`: Check TypeScript types across all apps
|
- `docs/report-outline.md`
|
||||||
- `bun run db:push`: Push schema changes to database
|
|
||||||
- `bun run db:generate`: Generate database client/types
|
## Notes
|
||||||
- `bun run db:migrate`: Run database migrations
|
|
||||||
- `bun run db:studio`: Open database studio UI
|
- DNS checking is limited to A record / IPv4 as required.
|
||||||
- `bun run dev:desktop`: Start the Electrobun desktop app with HMR
|
- This is not a deployment platform and does not include SSH, Docker orchestration, or reverse proxy automation.
|
||||||
- `bun run build:desktop`: Build the stable Electrobun desktop app
|
- Full workspace typecheck is still blocked by a pre-existing desktop typing issue for `three`, but the web app and server app are verified.
|
||||||
- `bun run build:desktop:canary`: Build the canary Electrobun desktop app
|
|
||||||
|
|
|
||||||
123
apps/web/src/components/dashboard-shell.tsx
Normal file
123
apps/web/src/components/dashboard-shell.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import { cn } from "@minmon/ui/lib/utils";
|
||||||
|
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||||
|
import { Activity, BookText, Globe, LayoutGrid, Server, Wrench } from "lucide-react";
|
||||||
|
|
||||||
|
const dashboardLinks = [
|
||||||
|
{
|
||||||
|
to: "/dashboard",
|
||||||
|
label: "Overview",
|
||||||
|
description: "Health, alerts, and recent checks",
|
||||||
|
icon: LayoutGrid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/dashboard/servers",
|
||||||
|
label: "Servers",
|
||||||
|
description: "Inventory and host details",
|
||||||
|
icon: Server,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/dashboard/services",
|
||||||
|
label: "Services",
|
||||||
|
description: "Processes and uptime targets",
|
||||||
|
icon: Wrench,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/dashboard/domains",
|
||||||
|
label: "Domains",
|
||||||
|
description: "DNS, SSL, and expiry status",
|
||||||
|
icon: Globe,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/dashboard/notes",
|
||||||
|
label: "Notes",
|
||||||
|
description: "Runbooks and operational handover",
|
||||||
|
icon: BookText,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function DashboardShell({
|
||||||
|
userName,
|
||||||
|
userEmail,
|
||||||
|
}: {
|
||||||
|
userName?: string | null;
|
||||||
|
userEmail?: string | null;
|
||||||
|
}) {
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (state) => state.location.pathname,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-muted/20">
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 lg:px-6">
|
||||||
|
<section className="border bg-card">
|
||||||
|
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[1.4fr_0.6fr] lg:px-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Server Management Dashboard
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Operations overview for demos and day-to-day admin tasks</h1>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Use this area to review monitored assets, inspect detail states, and stage CRUD forms that can connect to tRPC routers as they are added.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 border-l-0 border-t pt-4 lg:border-l lg:border-t-0 lg:pl-6 lg:pt-0">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">Signed in as</p>
|
||||||
|
<p className="mt-1 text-sm font-medium">{userName ?? "Administrator"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{userEmail ?? "admin@minmon.local"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link to="/dashboard/servers/new">
|
||||||
|
<Button size="sm">Add server</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/dashboard/notes/new">
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
New note
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]">
|
||||||
|
<aside className="h-fit border bg-card">
|
||||||
|
<div className="border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Activity className="size-4" />
|
||||||
|
Navigation
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="grid gap-1 p-2">
|
||||||
|
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
|
||||||
|
const active = pathname === to || pathname.startsWith(`${to}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={cn(
|
||||||
|
"grid gap-0.5 border px-3 py-2 text-left transition-colors",
|
||||||
|
active ? "border-foreground/20 bg-muted" : "border-transparent hover:border-border hover:bg-muted/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{description}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="min-w-0">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
apps/web/src/components/dashboard-ui.tsx
Normal file
282
apps/web/src/components/dashboard-ui.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@minmon/ui/components/card";
|
||||||
|
import { Input } from "@minmon/ui/components/input";
|
||||||
|
import { Label } from "@minmon/ui/components/label";
|
||||||
|
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableWrapper,
|
||||||
|
} from "@minmon/ui/components/table";
|
||||||
|
import { Textarea } from "@minmon/ui/components/textarea";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type BadgeVariant = "neutral" | "success" | "warning" | "destructive" | "info";
|
||||||
|
|
||||||
|
export function PageSection({ title, description, action, children }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="grid gap-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
||||||
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{action ? <div>{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint: string;
|
||||||
|
status?: { label: string; variant: BadgeVariant };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{label}</CardDescription>
|
||||||
|
{status ? <StatusBadge variant={status.variant}>{status.label}</StatusBadge> : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="text-2xl font-semibold tracking-tight">{value}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{hint}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceListCard<TItem>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
columns,
|
||||||
|
items,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
columns: { key: string; header: string; render: (item: TItem) => ReactNode; className?: string }[];
|
||||||
|
items: TItem[];
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyDescription: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="gap-0">
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-0">
|
||||||
|
{items.length ? (
|
||||||
|
<TableWrapper className="border-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHead key={column.key} className={column.className}>
|
||||||
|
{column.header}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell key={column.key} className={column.className}>
|
||||||
|
{column.render(item)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableWrapper>
|
||||||
|
) : (
|
||||||
|
<EmptyState title={emptyTitle} description={emptyDescription} className="border-0" />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
rows: { label: string; value: ReactNode }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.label} className="space-y-1 border-l-2 border-border pl-3">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">{row.label}</p>
|
||||||
|
<div className="text-sm">{row.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">{children}</CardContent>
|
||||||
|
{footer ? <CardFooter className="justify-between gap-3">{footer}</CardFooter> : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldGroup({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={htmlFor}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField(props: React.ComponentProps<typeof Input> & { label: string; description?: string }) {
|
||||||
|
const { label, description, id, ...inputProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldGroup label={label} htmlFor={id ?? inputProps.name ?? label} description={description}>
|
||||||
|
<Input id={id ?? inputProps.name ?? label} {...inputProps} />
|
||||||
|
</FieldGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextAreaField(
|
||||||
|
props: React.ComponentProps<typeof Textarea> & { label: string; description?: string },
|
||||||
|
) {
|
||||||
|
const { label, description, id, ...textareaProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldGroup label={label} htmlFor={id ?? textareaProps.name ?? label} description={description}>
|
||||||
|
<Textarea id={id ?? textareaProps.name ?? label} {...textareaProps} />
|
||||||
|
</FieldGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={"border px-4 py-8 text-center " + (className ?? "")}>
|
||||||
|
<p className="text-sm font-medium">{title}</p>
|
||||||
|
<p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">{description}</p>
|
||||||
|
{action ? <div className="mt-4 flex justify-center">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueryStateCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <EmptyState title={title} description={description} action={action} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionLinks({ items }: { items: { label: string; to: string; detail: string }[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link key={item.to} to={item.to} className="border bg-card p-4 transition-colors hover:bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{item.label}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{item.detail}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormActions({ cancelTo, submitLabel }: { cancelTo: string; submitLabel: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to={cancelTo}>
|
||||||
|
<Button variant="ghost">Cancel</Button>
|
||||||
|
</Link>
|
||||||
|
<Button type="submit">{submitLabel}</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
@ -7,26 +7,34 @@ export default function Header() {
|
||||||
const links = [
|
const links = [
|
||||||
{ to: "/", label: "Home" },
|
{ to: "/", label: "Home" },
|
||||||
{ to: "/dashboard", label: "Dashboard" },
|
{ to: "/dashboard", label: "Dashboard" },
|
||||||
|
{ to: "/dashboard/servers", label: "Servers" },
|
||||||
|
{ to: "/dashboard/services", label: "Services" },
|
||||||
|
{ to: "/dashboard/domains", label: "Domains" },
|
||||||
|
{ to: "/dashboard/notes", label: "Notes" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/75">
|
||||||
<div className="flex flex-row items-center justify-between px-2 py-1">
|
<div className="mx-auto flex max-w-7xl flex-row items-center justify-between gap-4 px-4 py-3 lg:px-6">
|
||||||
<nav className="flex gap-4 text-lg">
|
<div className="flex items-center gap-6">
|
||||||
|
<Link to="/" className="text-sm font-semibold tracking-[0.2em] uppercase">
|
||||||
|
minmon
|
||||||
|
</Link>
|
||||||
|
<nav className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||||
{links.map(({ to, label }) => {
|
{links.map(({ to, label }) => {
|
||||||
return (
|
return (
|
||||||
<Link key={to} to={to}>
|
<Link key={to} to={to} activeProps={{ className: "text-foreground" }}>
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
apps/web/src/lib/protected-route.ts
Normal file
16
apps/web/src/lib/protected-route.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { redirect } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
export async function requireSession() {
|
||||||
|
const session = await authClient.getSession();
|
||||||
|
|
||||||
|
if (!session.data) {
|
||||||
|
redirect({
|
||||||
|
to: "/login",
|
||||||
|
throw: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session };
|
||||||
|
}
|
||||||
426
apps/web/src/routeTree.gen.ts
Normal file
426
apps/web/src/routeTree.gen.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as DashboardIndexRouteImport } from './routes/dashboard.index'
|
||||||
|
import { Route as DashboardServicesRouteImport } from './routes/dashboard.services'
|
||||||
|
import { Route as DashboardServersRouteImport } from './routes/dashboard.servers'
|
||||||
|
import { Route as DashboardNotesRouteImport } from './routes/dashboard.notes'
|
||||||
|
import { Route as DashboardDomainsRouteImport } from './routes/dashboard.domains'
|
||||||
|
import { Route as DashboardServicesNewRouteImport } from './routes/dashboard.services.new'
|
||||||
|
import { Route as DashboardServicesServiceIdRouteImport } from './routes/dashboard.services.$serviceId'
|
||||||
|
import { Route as DashboardServersNewRouteImport } from './routes/dashboard.servers.new'
|
||||||
|
import { Route as DashboardServersServerIdRouteImport } from './routes/dashboard.servers.$serverId'
|
||||||
|
import { Route as DashboardNotesNewRouteImport } from './routes/dashboard.notes.new'
|
||||||
|
import { Route as DashboardNotesNoteIdRouteImport } from './routes/dashboard.notes.$noteId'
|
||||||
|
import { Route as DashboardDomainsNewRouteImport } from './routes/dashboard.domains.new'
|
||||||
|
import { Route as DashboardDomainsDomainIdRouteImport } from './routes/dashboard.domains.$domainId'
|
||||||
|
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DashboardRoute = DashboardRouteImport.update({
|
||||||
|
id: '/dashboard',
|
||||||
|
path: '/dashboard',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DashboardIndexRoute = DashboardIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => DashboardRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardServicesRoute = DashboardServicesRouteImport.update({
|
||||||
|
id: '/services',
|
||||||
|
path: '/services',
|
||||||
|
getParentRoute: () => DashboardRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardServersRoute = DashboardServersRouteImport.update({
|
||||||
|
id: '/servers',
|
||||||
|
path: '/servers',
|
||||||
|
getParentRoute: () => DashboardRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardNotesRoute = DashboardNotesRouteImport.update({
|
||||||
|
id: '/notes',
|
||||||
|
path: '/notes',
|
||||||
|
getParentRoute: () => DashboardRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardDomainsRoute = DashboardDomainsRouteImport.update({
|
||||||
|
id: '/domains',
|
||||||
|
path: '/domains',
|
||||||
|
getParentRoute: () => DashboardRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardServicesNewRoute = DashboardServicesNewRouteImport.update({
|
||||||
|
id: '/new',
|
||||||
|
path: '/new',
|
||||||
|
getParentRoute: () => DashboardServicesRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardServicesServiceIdRoute =
|
||||||
|
DashboardServicesServiceIdRouteImport.update({
|
||||||
|
id: '/$serviceId',
|
||||||
|
path: '/$serviceId',
|
||||||
|
getParentRoute: () => DashboardServicesRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardServersNewRoute = DashboardServersNewRouteImport.update({
|
||||||
|
id: '/new',
|
||||||
|
path: '/new',
|
||||||
|
getParentRoute: () => DashboardServersRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardServersServerIdRoute =
|
||||||
|
DashboardServersServerIdRouteImport.update({
|
||||||
|
id: '/$serverId',
|
||||||
|
path: '/$serverId',
|
||||||
|
getParentRoute: () => DashboardServersRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardNotesNewRoute = DashboardNotesNewRouteImport.update({
|
||||||
|
id: '/new',
|
||||||
|
path: '/new',
|
||||||
|
getParentRoute: () => DashboardNotesRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardNotesNoteIdRoute = DashboardNotesNoteIdRouteImport.update({
|
||||||
|
id: '/$noteId',
|
||||||
|
path: '/$noteId',
|
||||||
|
getParentRoute: () => DashboardNotesRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardDomainsNewRoute = DashboardDomainsNewRouteImport.update({
|
||||||
|
id: '/new',
|
||||||
|
path: '/new',
|
||||||
|
getParentRoute: () => DashboardDomainsRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardDomainsDomainIdRoute =
|
||||||
|
DashboardDomainsDomainIdRouteImport.update({
|
||||||
|
id: '/$domainId',
|
||||||
|
path: '/$domainId',
|
||||||
|
getParentRoute: () => DashboardDomainsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/dashboard': typeof DashboardRouteWithChildren
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/dashboard/domains': typeof DashboardDomainsRouteWithChildren
|
||||||
|
'/dashboard/notes': typeof DashboardNotesRouteWithChildren
|
||||||
|
'/dashboard/servers': typeof DashboardServersRouteWithChildren
|
||||||
|
'/dashboard/services': typeof DashboardServicesRouteWithChildren
|
||||||
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
|
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||||
|
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
||||||
|
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
|
||||||
|
'/dashboard/notes/new': typeof DashboardNotesNewRoute
|
||||||
|
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
|
||||||
|
'/dashboard/servers/new': typeof DashboardServersNewRoute
|
||||||
|
'/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
|
||||||
|
'/dashboard/services/new': typeof DashboardServicesNewRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/dashboard/domains': typeof DashboardDomainsRouteWithChildren
|
||||||
|
'/dashboard/notes': typeof DashboardNotesRouteWithChildren
|
||||||
|
'/dashboard/servers': typeof DashboardServersRouteWithChildren
|
||||||
|
'/dashboard/services': typeof DashboardServicesRouteWithChildren
|
||||||
|
'/dashboard': typeof DashboardIndexRoute
|
||||||
|
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||||
|
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
||||||
|
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
|
||||||
|
'/dashboard/notes/new': typeof DashboardNotesNewRoute
|
||||||
|
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
|
||||||
|
'/dashboard/servers/new': typeof DashboardServersNewRoute
|
||||||
|
'/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
|
||||||
|
'/dashboard/services/new': typeof DashboardServicesNewRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/dashboard': typeof DashboardRouteWithChildren
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/dashboard/domains': typeof DashboardDomainsRouteWithChildren
|
||||||
|
'/dashboard/notes': typeof DashboardNotesRouteWithChildren
|
||||||
|
'/dashboard/servers': typeof DashboardServersRouteWithChildren
|
||||||
|
'/dashboard/services': typeof DashboardServicesRouteWithChildren
|
||||||
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
|
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||||
|
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
||||||
|
'/dashboard/notes/$noteId': typeof DashboardNotesNoteIdRoute
|
||||||
|
'/dashboard/notes/new': typeof DashboardNotesNewRoute
|
||||||
|
'/dashboard/servers/$serverId': typeof DashboardServersServerIdRoute
|
||||||
|
'/dashboard/servers/new': typeof DashboardServersNewRoute
|
||||||
|
'/dashboard/services/$serviceId': typeof DashboardServicesServiceIdRoute
|
||||||
|
'/dashboard/services/new': typeof DashboardServicesNewRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/login'
|
||||||
|
| '/dashboard/domains'
|
||||||
|
| '/dashboard/notes'
|
||||||
|
| '/dashboard/servers'
|
||||||
|
| '/dashboard/services'
|
||||||
|
| '/dashboard/'
|
||||||
|
| '/dashboard/domains/$domainId'
|
||||||
|
| '/dashboard/domains/new'
|
||||||
|
| '/dashboard/notes/$noteId'
|
||||||
|
| '/dashboard/notes/new'
|
||||||
|
| '/dashboard/servers/$serverId'
|
||||||
|
| '/dashboard/servers/new'
|
||||||
|
| '/dashboard/services/$serviceId'
|
||||||
|
| '/dashboard/services/new'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/login'
|
||||||
|
| '/dashboard/domains'
|
||||||
|
| '/dashboard/notes'
|
||||||
|
| '/dashboard/servers'
|
||||||
|
| '/dashboard/services'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/dashboard/domains/$domainId'
|
||||||
|
| '/dashboard/domains/new'
|
||||||
|
| '/dashboard/notes/$noteId'
|
||||||
|
| '/dashboard/notes/new'
|
||||||
|
| '/dashboard/servers/$serverId'
|
||||||
|
| '/dashboard/servers/new'
|
||||||
|
| '/dashboard/services/$serviceId'
|
||||||
|
| '/dashboard/services/new'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/login'
|
||||||
|
| '/dashboard/domains'
|
||||||
|
| '/dashboard/notes'
|
||||||
|
| '/dashboard/servers'
|
||||||
|
| '/dashboard/services'
|
||||||
|
| '/dashboard/'
|
||||||
|
| '/dashboard/domains/$domainId'
|
||||||
|
| '/dashboard/domains/new'
|
||||||
|
| '/dashboard/notes/$noteId'
|
||||||
|
| '/dashboard/notes/new'
|
||||||
|
| '/dashboard/servers/$serverId'
|
||||||
|
| '/dashboard/servers/new'
|
||||||
|
| '/dashboard/services/$serviceId'
|
||||||
|
| '/dashboard/services/new'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
DashboardRoute: typeof DashboardRouteWithChildren
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/dashboard': {
|
||||||
|
id: '/dashboard'
|
||||||
|
path: '/dashboard'
|
||||||
|
fullPath: '/dashboard'
|
||||||
|
preLoaderRoute: typeof DashboardRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/dashboard/': {
|
||||||
|
id: '/dashboard/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/dashboard/'
|
||||||
|
preLoaderRoute: typeof DashboardIndexRouteImport
|
||||||
|
parentRoute: typeof DashboardRoute
|
||||||
|
}
|
||||||
|
'/dashboard/services': {
|
||||||
|
id: '/dashboard/services'
|
||||||
|
path: '/services'
|
||||||
|
fullPath: '/dashboard/services'
|
||||||
|
preLoaderRoute: typeof DashboardServicesRouteImport
|
||||||
|
parentRoute: typeof DashboardRoute
|
||||||
|
}
|
||||||
|
'/dashboard/servers': {
|
||||||
|
id: '/dashboard/servers'
|
||||||
|
path: '/servers'
|
||||||
|
fullPath: '/dashboard/servers'
|
||||||
|
preLoaderRoute: typeof DashboardServersRouteImport
|
||||||
|
parentRoute: typeof DashboardRoute
|
||||||
|
}
|
||||||
|
'/dashboard/notes': {
|
||||||
|
id: '/dashboard/notes'
|
||||||
|
path: '/notes'
|
||||||
|
fullPath: '/dashboard/notes'
|
||||||
|
preLoaderRoute: typeof DashboardNotesRouteImport
|
||||||
|
parentRoute: typeof DashboardRoute
|
||||||
|
}
|
||||||
|
'/dashboard/domains': {
|
||||||
|
id: '/dashboard/domains'
|
||||||
|
path: '/domains'
|
||||||
|
fullPath: '/dashboard/domains'
|
||||||
|
preLoaderRoute: typeof DashboardDomainsRouteImport
|
||||||
|
parentRoute: typeof DashboardRoute
|
||||||
|
}
|
||||||
|
'/dashboard/services/new': {
|
||||||
|
id: '/dashboard/services/new'
|
||||||
|
path: '/new'
|
||||||
|
fullPath: '/dashboard/services/new'
|
||||||
|
preLoaderRoute: typeof DashboardServicesNewRouteImport
|
||||||
|
parentRoute: typeof DashboardServicesRoute
|
||||||
|
}
|
||||||
|
'/dashboard/services/$serviceId': {
|
||||||
|
id: '/dashboard/services/$serviceId'
|
||||||
|
path: '/$serviceId'
|
||||||
|
fullPath: '/dashboard/services/$serviceId'
|
||||||
|
preLoaderRoute: typeof DashboardServicesServiceIdRouteImport
|
||||||
|
parentRoute: typeof DashboardServicesRoute
|
||||||
|
}
|
||||||
|
'/dashboard/servers/new': {
|
||||||
|
id: '/dashboard/servers/new'
|
||||||
|
path: '/new'
|
||||||
|
fullPath: '/dashboard/servers/new'
|
||||||
|
preLoaderRoute: typeof DashboardServersNewRouteImport
|
||||||
|
parentRoute: typeof DashboardServersRoute
|
||||||
|
}
|
||||||
|
'/dashboard/servers/$serverId': {
|
||||||
|
id: '/dashboard/servers/$serverId'
|
||||||
|
path: '/$serverId'
|
||||||
|
fullPath: '/dashboard/servers/$serverId'
|
||||||
|
preLoaderRoute: typeof DashboardServersServerIdRouteImport
|
||||||
|
parentRoute: typeof DashboardServersRoute
|
||||||
|
}
|
||||||
|
'/dashboard/notes/new': {
|
||||||
|
id: '/dashboard/notes/new'
|
||||||
|
path: '/new'
|
||||||
|
fullPath: '/dashboard/notes/new'
|
||||||
|
preLoaderRoute: typeof DashboardNotesNewRouteImport
|
||||||
|
parentRoute: typeof DashboardNotesRoute
|
||||||
|
}
|
||||||
|
'/dashboard/notes/$noteId': {
|
||||||
|
id: '/dashboard/notes/$noteId'
|
||||||
|
path: '/$noteId'
|
||||||
|
fullPath: '/dashboard/notes/$noteId'
|
||||||
|
preLoaderRoute: typeof DashboardNotesNoteIdRouteImport
|
||||||
|
parentRoute: typeof DashboardNotesRoute
|
||||||
|
}
|
||||||
|
'/dashboard/domains/new': {
|
||||||
|
id: '/dashboard/domains/new'
|
||||||
|
path: '/new'
|
||||||
|
fullPath: '/dashboard/domains/new'
|
||||||
|
preLoaderRoute: typeof DashboardDomainsNewRouteImport
|
||||||
|
parentRoute: typeof DashboardDomainsRoute
|
||||||
|
}
|
||||||
|
'/dashboard/domains/$domainId': {
|
||||||
|
id: '/dashboard/domains/$domainId'
|
||||||
|
path: '/$domainId'
|
||||||
|
fullPath: '/dashboard/domains/$domainId'
|
||||||
|
preLoaderRoute: typeof DashboardDomainsDomainIdRouteImport
|
||||||
|
parentRoute: typeof DashboardDomainsRoute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardDomainsRouteChildren {
|
||||||
|
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
|
||||||
|
DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardDomainsRouteChildren: DashboardDomainsRouteChildren = {
|
||||||
|
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
|
||||||
|
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardDomainsRouteWithChildren =
|
||||||
|
DashboardDomainsRoute._addFileChildren(DashboardDomainsRouteChildren)
|
||||||
|
|
||||||
|
interface DashboardNotesRouteChildren {
|
||||||
|
DashboardNotesNoteIdRoute: typeof DashboardNotesNoteIdRoute
|
||||||
|
DashboardNotesNewRoute: typeof DashboardNotesNewRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardNotesRouteChildren: DashboardNotesRouteChildren = {
|
||||||
|
DashboardNotesNoteIdRoute: DashboardNotesNoteIdRoute,
|
||||||
|
DashboardNotesNewRoute: DashboardNotesNewRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardNotesRouteWithChildren = DashboardNotesRoute._addFileChildren(
|
||||||
|
DashboardNotesRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface DashboardServersRouteChildren {
|
||||||
|
DashboardServersServerIdRoute: typeof DashboardServersServerIdRoute
|
||||||
|
DashboardServersNewRoute: typeof DashboardServersNewRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardServersRouteChildren: DashboardServersRouteChildren = {
|
||||||
|
DashboardServersServerIdRoute: DashboardServersServerIdRoute,
|
||||||
|
DashboardServersNewRoute: DashboardServersNewRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardServersRouteWithChildren =
|
||||||
|
DashboardServersRoute._addFileChildren(DashboardServersRouteChildren)
|
||||||
|
|
||||||
|
interface DashboardServicesRouteChildren {
|
||||||
|
DashboardServicesServiceIdRoute: typeof DashboardServicesServiceIdRoute
|
||||||
|
DashboardServicesNewRoute: typeof DashboardServicesNewRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardServicesRouteChildren: DashboardServicesRouteChildren = {
|
||||||
|
DashboardServicesServiceIdRoute: DashboardServicesServiceIdRoute,
|
||||||
|
DashboardServicesNewRoute: DashboardServicesNewRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardServicesRouteWithChildren =
|
||||||
|
DashboardServicesRoute._addFileChildren(DashboardServicesRouteChildren)
|
||||||
|
|
||||||
|
interface DashboardRouteChildren {
|
||||||
|
DashboardDomainsRoute: typeof DashboardDomainsRouteWithChildren
|
||||||
|
DashboardNotesRoute: typeof DashboardNotesRouteWithChildren
|
||||||
|
DashboardServersRoute: typeof DashboardServersRouteWithChildren
|
||||||
|
DashboardServicesRoute: typeof DashboardServicesRouteWithChildren
|
||||||
|
DashboardIndexRoute: typeof DashboardIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardRouteChildren: DashboardRouteChildren = {
|
||||||
|
DashboardDomainsRoute: DashboardDomainsRouteWithChildren,
|
||||||
|
DashboardNotesRoute: DashboardNotesRouteWithChildren,
|
||||||
|
DashboardServersRoute: DashboardServersRouteWithChildren,
|
||||||
|
DashboardServicesRoute: DashboardServicesRouteWithChildren,
|
||||||
|
DashboardIndexRoute: DashboardIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardRouteWithChildren = DashboardRoute._addFileChildren(
|
||||||
|
DashboardRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
DashboardRoute: DashboardRouteWithChildren,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
|
|
@ -46,7 +46,7 @@ function RootComponent() {
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
storageKey="vite-ui-theme"
|
storageKey="vite-ui-theme"
|
||||||
>
|
>
|
||||||
<div className="grid grid-rows-[auto_1fr] h-svh">
|
<div className="grid min-h-svh grid-rows-[auto_1fr] bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
106
apps/web/src/routes/dashboard.domains.new.tsx
Normal file
106
apps/web/src/routes/dashboard.domains.new.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||||
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/domains/new")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
type DomainFormValues = {
|
||||||
|
serviceId: string;
|
||||||
|
name: string;
|
||||||
|
expectedServerIp: string;
|
||||||
|
remarks: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const services = useQuery(trpc.domains.serviceOptions.queryOptions());
|
||||||
|
const createDomain = useMutation({
|
||||||
|
mutationFn: async (values: DomainFormValues) =>
|
||||||
|
trpcClient.domains.create.mutate({
|
||||||
|
serviceId: values.serviceId,
|
||||||
|
name: values.name,
|
||||||
|
expectedServerIp: values.expectedServerIp || undefined,
|
||||||
|
remarks: values.remarks || null,
|
||||||
|
}),
|
||||||
|
onSuccess: async (created) => {
|
||||||
|
toast.success("Domain created");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
navigate({ to: "/dashboard/domains/$domainId", params: { domainId: created.id } });
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultValues: DomainFormValues = {
|
||||||
|
serviceId: "",
|
||||||
|
name: "",
|
||||||
|
expectedServerIp: "",
|
||||||
|
remarks: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues,
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
await createDomain.mutateAsync(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (services.isLoading) {
|
||||||
|
return <QueryStateCard title="Loading services" description="Fetching service options for the domain form." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (services.isError || !services.data) {
|
||||||
|
return <QueryStateCard title="Service options unavailable" description="Create a service before adding domains." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection title="Add domain" description="Create a domain or subdomain attached to a service.">
|
||||||
|
<form
|
||||||
|
className="grid gap-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormCard
|
||||||
|
title="Domain details"
|
||||||
|
description="Expected server IP will default from the selected service's server when omitted."
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/domains" })}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
|
||||||
|
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save domain"}</Button>}
|
||||||
|
</form.Subscribe>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form.Field name="serviceId">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Service ID"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
description={`Available: ${services.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<form.Field name="name">{(field) => <TextField label="Domain name" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} placeholder="dashboard.minmon.dev" />}</form.Field>
|
||||||
|
<form.Field name="expectedServerIp">{(field) => <TextField label="Expected server IP" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} placeholder="203.0.113.10" />}</form.Field>
|
||||||
|
</div>
|
||||||
|
<form.Field name="remarks">{(field) => <TextAreaField label="Remarks" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||||
|
</FormCard>
|
||||||
|
</form>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/web/src/routes/dashboard.domains.tsx
Normal file
137
apps/web/src/routes/dashboard.domains.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
||||||
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/domains")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function dnsStatusVariant(status: "MATCH" | "MISMATCH" | "UNRESOLVED") {
|
||||||
|
if (status === "MATCH") return "success" as const;
|
||||||
|
if (status === "MISMATCH") return "warning" as const;
|
||||||
|
return "destructive" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const domains = useQuery(trpc.domains.list.queryOptions());
|
||||||
|
const services = useQuery(trpc.services.list.queryOptions());
|
||||||
|
const removeDomain = useMutation({
|
||||||
|
mutationFn: async (id: string) => trpcClient.domains.delete.mutate({ id }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Domain deleted");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const checkDns = useMutation({
|
||||||
|
mutationFn: async (id: string) => trpcClient.domains.check.mutate({ id }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("DNS check completed");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (domains.isLoading || services.isLoading) {
|
||||||
|
return <QueryStateCard title="Loading domains" description="Fetching domain records and service references." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domains.isError || services.isError || !domains.data || !services.data) {
|
||||||
|
return <QueryStateCard title="Domains unavailable" description="The domain list could not be loaded." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap = new Map(services.data.map((item) => [item.id, item.name]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
title="Domains"
|
||||||
|
description="Manage service domains, expected IPs, and DNS resolution checks."
|
||||||
|
action={
|
||||||
|
<Link to="/dashboard/domains/new">
|
||||||
|
<Button>Add domain</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResourceListCard
|
||||||
|
title="Domain list"
|
||||||
|
description="Use the Check DNS action to resolve A records and compare them with the expected server IP."
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Domain",
|
||||||
|
render: (item: (typeof domains.data)[number]) => (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Link to="/dashboard/domains/$domainId" params={{ domainId: item.id }} className="font-medium hover:underline">
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.remarks || "No remarks"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "service",
|
||||||
|
header: "Service",
|
||||||
|
render: (item: (typeof domains.data)[number]) => serviceMap.get(item.serviceId) || item.serviceId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "expectedServerIp",
|
||||||
|
header: "Expected IP",
|
||||||
|
render: (item: (typeof domains.data)[number]) => item.expectedServerIp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lastResolvedIp",
|
||||||
|
header: "Last resolved IP",
|
||||||
|
render: (item: (typeof domains.data)[number]) => item.lastResolvedIp || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "DNS status",
|
||||||
|
className: "w-36",
|
||||||
|
render: (item: (typeof domains.data)[number]) => (
|
||||||
|
<StatusBadge variant={dnsStatusVariant(item.resolutionStatus)}>{item.resolutionStatus}</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
className: "w-44",
|
||||||
|
render: (item: (typeof domains.data)[number]) => (
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-auto px-0"
|
||||||
|
disabled={checkDns.isPending}
|
||||||
|
onClick={() => checkDns.mutate(item.id)}
|
||||||
|
>
|
||||||
|
Check DNS
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-auto px-0 text-red-600 hover:text-red-700"
|
||||||
|
disabled={removeDomain.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete domain \"${item.name}\"?`)) {
|
||||||
|
removeDomain.mutate(item.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
items={domains.data}
|
||||||
|
emptyTitle="No domains added"
|
||||||
|
emptyDescription="Create a domain after adding a service."
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
apps/web/src/routes/dashboard.index.tsx
Normal file
169
apps/web/src/routes/dashboard.index.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PageSection,
|
||||||
|
QueryStateCard,
|
||||||
|
ResourceListCard,
|
||||||
|
SectionLinks,
|
||||||
|
SummaryCard,
|
||||||
|
} from "@/components/dashboard-ui";
|
||||||
|
import { trpc } from "@/utils/trpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const summary = useQuery(trpc.dashboard.summary.queryOptions());
|
||||||
|
|
||||||
|
if (summary.isLoading) {
|
||||||
|
return (
|
||||||
|
<QueryStateCard
|
||||||
|
title="Loading dashboard summary"
|
||||||
|
description="Fetching current counts, DNS check results, and recent notes."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.isError || !summary.data) {
|
||||||
|
return (
|
||||||
|
<QueryStateCard
|
||||||
|
title="Dashboard summary unavailable"
|
||||||
|
description="The overview could not be loaded from the protected dashboard API."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totals, domainChecks, inactiveServices, recentNotes, recentServiceIssues } = summary.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<PageSection
|
||||||
|
title="Dashboard"
|
||||||
|
description="Summary of servers, services, domains, DNS results, and maintenance items."
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<SummaryCard
|
||||||
|
label="Total servers"
|
||||||
|
value={String(totals.servers)}
|
||||||
|
hint="All managed private servers currently recorded in the system."
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Total services"
|
||||||
|
value={String(totals.services)}
|
||||||
|
hint="Applications or services attached to managed servers."
|
||||||
|
status={
|
||||||
|
inactiveServices > 0
|
||||||
|
? { label: `${inactiveServices} inactive/down`, variant: "warning" }
|
||||||
|
: { label: "All active", variant: "success" }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Total domains"
|
||||||
|
value={String(totals.domains)}
|
||||||
|
hint="Domains and subdomains attached to services."
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="DNS match results"
|
||||||
|
value={String(domainChecks.match)}
|
||||||
|
hint={`${domainChecks.mismatch} mismatch · ${domainChecks.unresolved} unresolved/error`}
|
||||||
|
status={
|
||||||
|
domainChecks.mismatch || domainChecks.unresolved
|
||||||
|
? { label: "Needs review", variant: "warning" }
|
||||||
|
: { label: "Healthy", variant: "success" }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PageSection>
|
||||||
|
|
||||||
|
<PageSection title="Areas" description="Open each section to manage data and review detail pages.">
|
||||||
|
<SectionLinks
|
||||||
|
items={[
|
||||||
|
{ label: "Servers", to: "/dashboard/servers", detail: "Create, edit, delete, and inspect server records." },
|
||||||
|
{ label: "Services", to: "/dashboard/services", detail: "Manage services running on each server." },
|
||||||
|
{ label: "Domains", to: "/dashboard/domains", detail: "Check DNS and compare resolved IPs against expected IPs." },
|
||||||
|
{ label: "Notes", to: "/dashboard/notes", detail: "Write documentation and operational notes per server." },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
|
||||||
|
<PageSection title="Recent service issues" description="Services currently marked down or under maintenance.">
|
||||||
|
<ResourceListCard
|
||||||
|
title="Inactive or down services"
|
||||||
|
description="These services affect the dashboard summary and should be reviewed first."
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Service",
|
||||||
|
render: (item: (typeof recentServiceIssues)[number]) => (
|
||||||
|
<Link
|
||||||
|
to="/dashboard/services/$serviceId"
|
||||||
|
params={{ serviceId: item.id }}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "serverName",
|
||||||
|
header: "Server",
|
||||||
|
render: (item: (typeof recentServiceIssues)[number]) => item.serverName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Status",
|
||||||
|
className: "w-40",
|
||||||
|
render: (item: (typeof recentServiceIssues)[number]) => (
|
||||||
|
<StatusBadge variant={item.status === "down" ? "destructive" : "warning"}>
|
||||||
|
{item.status}
|
||||||
|
</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
items={recentServiceIssues}
|
||||||
|
emptyTitle="No inactive services"
|
||||||
|
emptyDescription="All services are currently marked active."
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
|
||||||
|
<PageSection title="Recent notes" description="Latest documentation updates that may help during demos or maintenance.">
|
||||||
|
<ResourceListCard
|
||||||
|
title="Recently updated notes"
|
||||||
|
description="Recent server notes and internal documentation entries."
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
header: "Title",
|
||||||
|
render: (item: (typeof recentNotes)[number]) => (
|
||||||
|
<Link
|
||||||
|
to="/dashboard/notes/$noteId"
|
||||||
|
params={{ noteId: item.id }}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "category",
|
||||||
|
header: "Category",
|
||||||
|
render: (item: (typeof recentNotes)[number]) => item.category || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
header: "Updated",
|
||||||
|
render: (item: (typeof recentNotes)[number]) =>
|
||||||
|
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
items={recentNotes}
|
||||||
|
emptyTitle="No recent notes"
|
||||||
|
emptyDescription="Create a note from the Notes section to start documenting servers."
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
apps/web/src/routes/dashboard.notes.new.tsx
Normal file
106
apps/web/src/routes/dashboard.notes.new.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||||
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/notes/new")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
type NoteFormValues = {
|
||||||
|
serverId: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const servers = useQuery(trpc.servers.list.queryOptions());
|
||||||
|
const createNote = useMutation({
|
||||||
|
mutationFn: async (values: NoteFormValues) =>
|
||||||
|
trpcClient.notes.create.mutate({
|
||||||
|
serverId: values.serverId,
|
||||||
|
title: values.title,
|
||||||
|
category: values.category || null,
|
||||||
|
content: values.content,
|
||||||
|
}),
|
||||||
|
onSuccess: async (created) => {
|
||||||
|
toast.success("Note created");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
navigate({ to: "/dashboard/notes/$noteId", params: { noteId: created.id } });
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultValues: NoteFormValues = {
|
||||||
|
serverId: "",
|
||||||
|
title: "",
|
||||||
|
category: "general",
|
||||||
|
content: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues,
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
await createNote.mutateAsync(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (servers.isLoading) {
|
||||||
|
return <QueryStateCard title="Loading servers" description="Fetching server options for the note form." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servers.isError || !servers.data) {
|
||||||
|
return <QueryStateCard title="Server options unavailable" description="Add or load servers before creating notes." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection title="New note" description="Add documentation or operational notes for a server.">
|
||||||
|
<form
|
||||||
|
className="grid gap-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormCard
|
||||||
|
title="Note details"
|
||||||
|
description="Each note belongs to one server."
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/notes" })}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
|
||||||
|
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save note"}</Button>}
|
||||||
|
</form.Subscribe>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form.Field name="serverId">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Server ID"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<form.Field name="title">{(field) => <TextField label="Title" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||||
|
<form.Field name="category">{(field) => <TextField label="Category" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||||
|
</div>
|
||||||
|
<form.Field name="content">{(field) => <TextAreaField label="Content" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} className="min-h-40" />}</form.Field>
|
||||||
|
</FormCard>
|
||||||
|
</form>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
apps/web/src/routes/dashboard.notes.tsx
Normal file
103
apps/web/src/routes/dashboard.notes.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
||||||
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/notes")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const notes = useQuery(trpc.notes.list.queryOptions());
|
||||||
|
const servers = useQuery(trpc.servers.list.queryOptions());
|
||||||
|
const removeNote = useMutation({
|
||||||
|
mutationFn: async (id: string) => trpcClient.notes.delete.mutate({ id }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Note deleted");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (notes.isLoading || servers.isLoading) {
|
||||||
|
return <QueryStateCard title="Loading notes" description="Fetching server documentation and internal notes." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes.isError || servers.isError || !notes.data || !servers.data) {
|
||||||
|
return <QueryStateCard title="Notes unavailable" description="The note list could not be loaded." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverMap = new Map(servers.data.map((item) => [item.id, item.name]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
title="Notes"
|
||||||
|
description="Manage documentation, incident notes, and internal reminders for each server."
|
||||||
|
action={
|
||||||
|
<Link to="/dashboard/notes/new">
|
||||||
|
<Button>New note</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResourceListCard
|
||||||
|
title="Server notes"
|
||||||
|
description="Each note belongs to one server and acts as lightweight internal documentation."
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
header: "Title",
|
||||||
|
render: (item: (typeof notes.data)[number]) => (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Link to="/dashboard/notes/$noteId" params={{ noteId: item.id }} className="font-medium hover:underline">
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground line-clamp-2">{item.content}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "server",
|
||||||
|
header: "Server",
|
||||||
|
render: (item: (typeof notes.data)[number]) => serverMap.get(item.serverId) || item.serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "category",
|
||||||
|
header: "Category",
|
||||||
|
render: (item: (typeof notes.data)[number]) => item.category || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
header: "Updated",
|
||||||
|
render: (item: (typeof notes.data)[number]) => new Date(item.updatedAt).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
className: "w-28",
|
||||||
|
render: (item: (typeof notes.data)[number]) => (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-auto px-0 text-red-600 hover:text-red-700"
|
||||||
|
disabled={removeNote.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete note \"${item.title}\"?`)) {
|
||||||
|
removeNote.mutate(item.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
items={notes.data}
|
||||||
|
emptyTitle="No notes yet"
|
||||||
|
emptyDescription="Add a note to document maintenance, incidents, or general server information."
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
apps/web/src/routes/dashboard.servers.new.tsx
Normal file
190
apps/web/src/routes/dashboard.servers.new.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { FormCard, PageSection, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||||
|
import { queryClient, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/servers/new")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
type ServerFormValues = {
|
||||||
|
name: string;
|
||||||
|
primaryIpAddress: string;
|
||||||
|
secondaryIpAddress: string;
|
||||||
|
operatingSystem: string;
|
||||||
|
location: string;
|
||||||
|
provider: string;
|
||||||
|
description: string;
|
||||||
|
status: "active" | "maintenance" | "inactive";
|
||||||
|
};
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const createServer = useMutation({
|
||||||
|
mutationFn: async (values: ServerFormValues) =>
|
||||||
|
trpcClient.servers.create.mutate({
|
||||||
|
...values,
|
||||||
|
secondaryIpAddress: values.secondaryIpAddress || null,
|
||||||
|
operatingSystem: values.operatingSystem || null,
|
||||||
|
location: values.location || null,
|
||||||
|
provider: values.provider || null,
|
||||||
|
description: values.description || null,
|
||||||
|
}),
|
||||||
|
onSuccess: async (created) => {
|
||||||
|
toast.success("Server created");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
navigate({ to: "/dashboard/servers/$serverId", params: { serverId: created.id } });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultValues: ServerFormValues = {
|
||||||
|
name: "",
|
||||||
|
primaryIpAddress: "",
|
||||||
|
secondaryIpAddress: "",
|
||||||
|
operatingSystem: "",
|
||||||
|
location: "",
|
||||||
|
provider: "",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues,
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
await createServer.mutateAsync(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection title="Add server" description="Create a new managed server record.">
|
||||||
|
<form
|
||||||
|
className="grid gap-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormCard
|
||||||
|
title="Server details"
|
||||||
|
description="Server name and primary IP are required."
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/servers" })}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
|
||||||
|
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save server"}</Button>}
|
||||||
|
</form.Subscribe>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<form.Field name="name">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Server name"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="Production VPS"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="primaryIpAddress">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Primary IP address"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="203.0.113.10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="secondaryIpAddress">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Secondary IP address"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="203.0.113.11"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="operatingSystem">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Operating system"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="Ubuntu 24.04 LTS"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="location">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Location"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="Singapore"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="provider">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Provider"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="DigitalOcean"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="status">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Status"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value as "active" | "maintenance" | "inactive")}
|
||||||
|
placeholder="active"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<form.Field name="description">
|
||||||
|
{(field) => (
|
||||||
|
<TextAreaField
|
||||||
|
label="Description"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="Describe the purpose of this server."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</FormCard>
|
||||||
|
</form>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/web/src/routes/dashboard.servers.tsx
Normal file
126
apps/web/src/routes/dashboard.servers.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
||||||
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/servers")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function serverStatusVariant(status: "active" | "maintenance" | "inactive") {
|
||||||
|
if (status === "active") return "success" as const;
|
||||||
|
if (status === "maintenance") return "warning" as const;
|
||||||
|
return "neutral" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const servers = useQuery(trpc.servers.list.queryOptions());
|
||||||
|
const removeServer = useMutation({
|
||||||
|
mutationFn: async (id: string) => trpcClient.servers.delete.mutate({ id }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Server deleted");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (servers.isLoading) {
|
||||||
|
return (
|
||||||
|
<QueryStateCard
|
||||||
|
title="Loading servers"
|
||||||
|
description="Fetching the current server inventory from the protected dashboard API."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servers.isError || !servers.data) {
|
||||||
|
return (
|
||||||
|
<QueryStateCard
|
||||||
|
title="Servers unavailable"
|
||||||
|
description="The server list could not be loaded right now."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
title="Servers"
|
||||||
|
description="Manage server inventory, IP addresses, operating systems, and current status."
|
||||||
|
action={
|
||||||
|
<Link to="/dashboard/servers/new">
|
||||||
|
<Button>Add server</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResourceListCard
|
||||||
|
title="Server list"
|
||||||
|
description="Each server can have many services and notes. Deleting a server also removes its related records."
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Server",
|
||||||
|
render: (item: (typeof servers.data)[number]) => (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Link
|
||||||
|
to="/dashboard/servers/$serverId"
|
||||||
|
params={{ serverId: item.id }}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.description || "No description"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "primaryIpAddress",
|
||||||
|
header: "Primary IP",
|
||||||
|
render: (item: (typeof servers.data)[number]) => item.primaryIpAddress,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "location",
|
||||||
|
header: "Location / Provider",
|
||||||
|
render: (item: (typeof servers.data)[number]) => [item.location, item.provider].filter(Boolean).join(" / ") || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Status",
|
||||||
|
className: "w-32",
|
||||||
|
render: (item: (typeof servers.data)[number]) => (
|
||||||
|
<StatusBadge variant={serverStatusVariant(item.status)}>{item.status}</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
className: "w-28",
|
||||||
|
render: (item: (typeof servers.data)[number]) => (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-auto px-0 text-red-600 hover:text-red-700"
|
||||||
|
disabled={removeServer.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete server \"${item.name}\" and all related services, domains, and notes?`)) {
|
||||||
|
removeServer.mutate(item.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
items={servers.data}
|
||||||
|
emptyTitle="No servers added"
|
||||||
|
emptyDescription="Add your first server to start managing services, notes, and domains."
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/web/src/routes/dashboard.services.new.tsx
Normal file
122
apps/web/src/routes/dashboard.services.new.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { FormCard, PageSection, QueryStateCard, TextAreaField, TextField } from "@/components/dashboard-ui";
|
||||||
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/services/new")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
type ServiceFormValues = {
|
||||||
|
serverId: string;
|
||||||
|
name: string;
|
||||||
|
type: "web_app" | "mail" | "database" | "api" | "proxy" | "other";
|
||||||
|
internalPort: string;
|
||||||
|
externalPort: string;
|
||||||
|
protocol: "http" | "https" | "tcp" | "udp" | "other";
|
||||||
|
status: "active" | "down" | "maintenance";
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const servers = useQuery(trpc.servers.list.queryOptions());
|
||||||
|
const createService = useMutation({
|
||||||
|
mutationFn: async (values: ServiceFormValues) =>
|
||||||
|
trpcClient.services.create.mutate({
|
||||||
|
serverId: values.serverId,
|
||||||
|
name: values.name,
|
||||||
|
type: values.type,
|
||||||
|
internalPort: values.internalPort ? Number(values.internalPort) : null,
|
||||||
|
externalPort: values.externalPort ? Number(values.externalPort) : null,
|
||||||
|
protocol: values.protocol,
|
||||||
|
status: values.status,
|
||||||
|
description: values.description || null,
|
||||||
|
}),
|
||||||
|
onSuccess: async (created) => {
|
||||||
|
toast.success("Service created");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
navigate({ to: "/dashboard/services/$serviceId", params: { serviceId: created.id } });
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultValues: ServiceFormValues = {
|
||||||
|
serverId: "",
|
||||||
|
name: "",
|
||||||
|
type: "web_app",
|
||||||
|
internalPort: "",
|
||||||
|
externalPort: "",
|
||||||
|
protocol: "http",
|
||||||
|
status: "active",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues,
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
await createService.mutateAsync(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (servers.isLoading) {
|
||||||
|
return <QueryStateCard title="Loading servers" description="Fetching server options for the service form." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servers.isError || !servers.data) {
|
||||||
|
return <QueryStateCard title="Server options unavailable" description="Add or load servers before creating a service." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection title="Add service" description="Create a new service under an existing server.">
|
||||||
|
<form
|
||||||
|
className="grid gap-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormCard
|
||||||
|
title="Service details"
|
||||||
|
description="A service must belong to a server."
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => navigate({ to: "/dashboard/services" })}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<form.Subscribe selector={(state) => ({ isSubmitting: state.isSubmitting })}>
|
||||||
|
{({ isSubmitting }) => <Button type="submit">{isSubmitting ? "Saving..." : "Save service"}</Button>}
|
||||||
|
</form.Subscribe>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form.Field name="serverId">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Server ID"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
description={`Available: ${servers.data.map((item) => `${item.name} (${item.id})`).join(", ")}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<form.Field name="name">{(field) => <TextField label="Service name" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||||
|
<form.Field name="type">{(field) => <TextField label="Service type" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["type"])} />}</form.Field>
|
||||||
|
<form.Field name="internalPort">{(field) => <TextField label="Internal port" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||||
|
<form.Field name="externalPort">{(field) => <TextField label="External port" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||||
|
<form.Field name="protocol">{(field) => <TextField label="Protocol" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["protocol"])} />}</form.Field>
|
||||||
|
<form.Field name="status">{(field) => <TextField label="Status" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value as ServiceFormValues["status"])} />}</form.Field>
|
||||||
|
</div>
|
||||||
|
<form.Field name="description">{(field) => <TextAreaField label="Description" name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />}</form.Field>
|
||||||
|
</FormCard>
|
||||||
|
</form>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
apps/web/src/routes/dashboard.services.tsx
Normal file
123
apps/web/src/routes/dashboard.services.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui";
|
||||||
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/services")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function serviceStatusVariant(status: "active" | "down" | "maintenance") {
|
||||||
|
if (status === "active") return "success" as const;
|
||||||
|
if (status === "down") return "destructive" as const;
|
||||||
|
return "warning" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const services = useQuery(trpc.services.list.queryOptions());
|
||||||
|
const servers = useQuery(trpc.servers.list.queryOptions());
|
||||||
|
const removeService = useMutation({
|
||||||
|
mutationFn: async (id: string) => trpcClient.services.delete.mutate({ id }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Service deleted");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (services.isLoading || servers.isLoading) {
|
||||||
|
return <QueryStateCard title="Loading services" description="Fetching service records and server references." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (services.isError || servers.isError || !services.data || !servers.data) {
|
||||||
|
return <QueryStateCard title="Services unavailable" description="The service list could not be loaded." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverMap = new Map(servers.data.map((item) => [item.id, item.name]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
title="Services"
|
||||||
|
description="Manage services running on each server, including ports, protocol, and current status."
|
||||||
|
action={
|
||||||
|
<Link to="/dashboard/services/new">
|
||||||
|
<Button>Add service</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResourceListCard
|
||||||
|
title="Service list"
|
||||||
|
description="Each service belongs to exactly one server. Domains are attached to services."
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Service",
|
||||||
|
render: (item: (typeof services.data)[number]) => (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Link
|
||||||
|
to="/dashboard/services/$serviceId"
|
||||||
|
params={{ serviceId: item.id }}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.description || "No description"}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "server",
|
||||||
|
header: "Server",
|
||||||
|
render: (item: (typeof services.data)[number]) => serverMap.get(item.serverId) || item.serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ports",
|
||||||
|
header: "Ports",
|
||||||
|
render: (item: (typeof services.data)[number]) =>
|
||||||
|
`${item.internalPort ?? "-"} / ${item.externalPort ?? "-"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "protocol",
|
||||||
|
header: "Protocol",
|
||||||
|
render: (item: (typeof services.data)[number]) => item.protocol,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Status",
|
||||||
|
className: "w-32",
|
||||||
|
render: (item: (typeof services.data)[number]) => (
|
||||||
|
<StatusBadge variant={serviceStatusVariant(item.status)}>{item.status}</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
className: "w-28",
|
||||||
|
render: (item: (typeof services.data)[number]) => (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-auto px-0 text-red-600 hover:text-red-700"
|
||||||
|
disabled={removeService.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete service \"${item.name}\" and its related domains?`)) {
|
||||||
|
removeService.mutate(item.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
items={services.data}
|
||||||
|
emptyTitle="No services added"
|
||||||
|
emptyDescription="Create a service after adding at least one server."
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,33 +1,17 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { DashboardShell } from "@/components/dashboard-shell";
|
||||||
import { trpc } from "@/utils/trpc";
|
import { requireSession } from "@/lib/protected-route";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard")({
|
export const Route = createFileRoute("/dashboard")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
beforeLoad: async () => {
|
beforeLoad: requireSession,
|
||||||
const session = await authClient.getSession();
|
|
||||||
if (!session.data) {
|
|
||||||
redirect({
|
|
||||||
to: "/login",
|
|
||||||
throw: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { session };
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { session } = Route.useRouteContext();
|
const { session } = Route.useRouteContext();
|
||||||
|
|
||||||
const privateData = useQuery(trpc.privateData.queryOptions());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<DashboardShell userName={session.data?.user.name} userEmail={session.data?.user.email} />
|
||||||
<h1>Dashboard</h1>
|
|
||||||
<p>Welcome {session.data?.user.name}</p>
|
|
||||||
<p>API: {privateData.data?.message}</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
bun.lock
15
bun.lock
|
|
@ -100,6 +100,8 @@
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"effect": "^3.18.4",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -130,6 +132,7 @@
|
||||||
"name": "@minmon/db",
|
"name": "@minmon/db",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@minmon/env": "workspace:*",
|
"@minmon/env": "workspace:*",
|
||||||
|
"better-auth": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"pg": "^8.17.1",
|
"pg": "^8.17.1",
|
||||||
|
|
@ -752,6 +755,8 @@
|
||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
|
"effect": ["effect@3.21.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg=="],
|
||||||
|
|
||||||
"electrobun": ["electrobun@1.16.0", "", { "dependencies": { "@babylonjs/core": "^7.45.0", "@types/bun": "^1.3.8", "png-to-ico": "^2.1.8", "proxy-agent": "^6.5.0", "rcedit": "^4.0.1", "three": "^0.165.0" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-KO/GQO6vpWACJXizqD8F551xtRAgg83Dcfzmjo+6qqCseobttu9x+HL40n+CBnYTe+kBvFXzwso2ZrPuec78zg=="],
|
"electrobun": ["electrobun@1.16.0", "", { "dependencies": { "@babylonjs/core": "^7.45.0", "@types/bun": "^1.3.8", "png-to-ico": "^2.1.8", "proxy-agent": "^6.5.0", "rcedit": "^4.0.1", "three": "^0.165.0" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-KO/GQO6vpWACJXizqD8F551xtRAgg83Dcfzmjo+6qqCseobttu9x+HL40n+CBnYTe+kBvFXzwso2ZrPuec78zg=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="],
|
||||||
|
|
@ -806,6 +811,8 @@
|
||||||
|
|
||||||
"express-rate-limit": ["express-rate-limit@8.4.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="],
|
"express-rate-limit": ["express-rate-limit@8.4.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="],
|
||||||
|
|
||||||
|
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||||
|
|
||||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
@ -910,7 +917,7 @@
|
||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
"ip-address": ["ip-address@10.1.1", "", {}, "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw=="],
|
||||||
|
|
||||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||||
|
|
||||||
|
|
@ -1168,6 +1175,8 @@
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||||
|
|
||||||
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||||
|
|
||||||
"quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
"quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||||
|
|
@ -1482,6 +1491,8 @@
|
||||||
|
|
||||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||||
|
|
||||||
|
"express-rate-limit/ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||||
|
|
||||||
"get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
"get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
||||||
|
|
||||||
"log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
"log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
@ -1514,8 +1525,6 @@
|
||||||
|
|
||||||
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
"socks/ip-address": ["ip-address@10.1.1", "", {}, "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw=="],
|
|
||||||
|
|
||||||
"tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
"tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||||
|
|
||||||
"web/lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="],
|
"web/lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="],
|
||||||
|
|
|
||||||
112
docs/report-outline.md
Normal file
112
docs/report-outline.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Report Outline (BAB 1–4)
|
||||||
|
|
||||||
|
## BAB 1 - Pendahuluan
|
||||||
|
|
||||||
|
### 1.1 Latar Belakang
|
||||||
|
|
||||||
|
- Importance of structured server documentation and monitoring
|
||||||
|
- Need for a simple admin dashboard for managing private servers and hosted services
|
||||||
|
|
||||||
|
### 1.2 Rumusan Masalah
|
||||||
|
|
||||||
|
- How to manage server data, services, domains, and internal notes in one web app?
|
||||||
|
- How to check whether a domain resolves to the expected server IP automatically?
|
||||||
|
|
||||||
|
### 1.3 Batasan Masalah
|
||||||
|
|
||||||
|
- CRUD + monitoring dashboard only
|
||||||
|
- No SSH, no Docker orchestration, no real deployment automation
|
||||||
|
- DNS checking limited to A record / IPv4
|
||||||
|
- Single admin login
|
||||||
|
|
||||||
|
### 1.4 Tujuan
|
||||||
|
|
||||||
|
- Build a web-based dashboard for private server service management
|
||||||
|
- Provide simple DNS verification and summary monitoring
|
||||||
|
|
||||||
|
### 1.5 Manfaat
|
||||||
|
|
||||||
|
- Helps admins manage server and service records
|
||||||
|
- Helps students understand CRUD, relationships, and monitoring concepts
|
||||||
|
|
||||||
|
## BAB 2 - Tinjauan Pustaka
|
||||||
|
|
||||||
|
### 2.1 Web-Based Information Systems
|
||||||
|
|
||||||
|
### 2.2 CRUD Concept
|
||||||
|
|
||||||
|
### 2.3 Monitoring Dashboard Concept
|
||||||
|
|
||||||
|
### 2.4 DNS and A Record Resolution
|
||||||
|
|
||||||
|
### 2.5 Technologies Used
|
||||||
|
|
||||||
|
- React
|
||||||
|
- TanStack Router
|
||||||
|
- tRPC
|
||||||
|
- PostgreSQL
|
||||||
|
- Drizzle ORM
|
||||||
|
- better-auth
|
||||||
|
- Effect
|
||||||
|
|
||||||
|
## BAB 3 - Analisis dan Perancangan Sistem
|
||||||
|
|
||||||
|
### 3.1 Analisis Kebutuhan
|
||||||
|
|
||||||
|
- Functional requirements
|
||||||
|
- Non-functional requirements
|
||||||
|
|
||||||
|
### 3.2 Perancangan Sistem
|
||||||
|
|
||||||
|
- Use case diagram
|
||||||
|
- Class diagram
|
||||||
|
- Activity diagram
|
||||||
|
- State chart
|
||||||
|
- Sequence diagram
|
||||||
|
- Collaboration diagram
|
||||||
|
|
||||||
|
### 3.3 Perancangan Database
|
||||||
|
|
||||||
|
- Server table
|
||||||
|
- Service table
|
||||||
|
- Domain table
|
||||||
|
- Note table
|
||||||
|
- Auth tables
|
||||||
|
|
||||||
|
### 3.4 Perancangan Antarmuka
|
||||||
|
|
||||||
|
- Login page
|
||||||
|
- Dashboard page
|
||||||
|
- Server list/detail/form
|
||||||
|
- Service list/detail/form
|
||||||
|
- Domain list/detail/form
|
||||||
|
- Note list/detail/form
|
||||||
|
|
||||||
|
## BAB 4 - Implementasi dan Pengujian
|
||||||
|
|
||||||
|
### 4.1 Implementasi Sistem
|
||||||
|
|
||||||
|
- Backend implementation
|
||||||
|
- Frontend implementation
|
||||||
|
- Authentication implementation
|
||||||
|
- DNS check implementation
|
||||||
|
|
||||||
|
### 4.2 Pengujian
|
||||||
|
|
||||||
|
- Login test
|
||||||
|
- CRUD server test
|
||||||
|
- CRUD service test
|
||||||
|
- CRUD domain test
|
||||||
|
- CRUD note test
|
||||||
|
- DNS check test
|
||||||
|
- Dashboard summary test
|
||||||
|
|
||||||
|
### 4.3 Hasil Pengujian
|
||||||
|
|
||||||
|
- Tables/screenshots of successful scenarios
|
||||||
|
|
||||||
|
### 4.4 Evaluasi
|
||||||
|
|
||||||
|
- Strengths of the system
|
||||||
|
- Current limitations
|
||||||
|
- Suggested future improvements
|
||||||
85
docs/run-instructions.md
Normal file
85
docs/run-instructions.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# Run Instructions
|
||||||
|
|
||||||
|
## Project Description
|
||||||
|
|
||||||
|
Minmon is a student-friendly web dashboard for managing private servers, their hosted services, related domains, and server notes. It focuses on CRUD operations plus simple DNS monitoring, not deployment automation.
|
||||||
|
|
||||||
|
## Main Features
|
||||||
|
|
||||||
|
- Admin login with better-auth
|
||||||
|
- Dashboard summary for servers, services, domains, DNS status, and inactive services
|
||||||
|
- Server CRUD
|
||||||
|
- Service CRUD
|
||||||
|
- Domain CRUD
|
||||||
|
- DNS A-record check with match/mismatch/unresolved result
|
||||||
|
- Server note/documentation CRUD
|
||||||
|
- Sample seed data for demo use
|
||||||
|
|
||||||
|
## Local Setup
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Prepare environment file at `apps/server/.env`
|
||||||
|
|
||||||
|
Example values:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/minmon
|
||||||
|
BETTER_AUTH_SECRET=your-32-char-or-longer-secret-value-here
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start PostgreSQL if needed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run db:start
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Apply schema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Seed sample data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --filter @minmon/db db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Seeded admin login:
|
||||||
|
|
||||||
|
- Email: `admin@minmon.local`
|
||||||
|
- Password: `admin12345`
|
||||||
|
|
||||||
|
6. Run the apps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Open the app
|
||||||
|
|
||||||
|
- Web: `http://localhost:5173`
|
||||||
|
- API: `http://localhost:3000`
|
||||||
|
|
||||||
|
## Helpful Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run check-types
|
||||||
|
bun run db:push
|
||||||
|
bun run db:studio
|
||||||
|
bun run --filter web check-types
|
||||||
|
bun run --filter server check-types
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Full workspace typecheck may still fail because of a pre-existing `apps/desktop` typing issue for `three`.
|
||||||
|
- The dashboard app itself, server app, DB push, and DB seed are verified.
|
||||||
146
docs/uml-guidance.md
Normal file
146
docs/uml-guidance.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
# UML Guidance
|
||||||
|
|
||||||
|
## 1. Use Case Diagram
|
||||||
|
|
||||||
|
### Main Actor
|
||||||
|
|
||||||
|
- Admin
|
||||||
|
|
||||||
|
### Main Use Cases
|
||||||
|
|
||||||
|
- Login
|
||||||
|
- View dashboard summary
|
||||||
|
- Manage servers
|
||||||
|
- Manage services
|
||||||
|
- Manage domains
|
||||||
|
- Check DNS for domain
|
||||||
|
- Manage server notes
|
||||||
|
- Logout
|
||||||
|
|
||||||
|
## 2. Class Diagram
|
||||||
|
|
||||||
|
### Main Classes / Entities
|
||||||
|
|
||||||
|
#### Server
|
||||||
|
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- primaryIpAddress
|
||||||
|
- secondaryIpAddress
|
||||||
|
- operatingSystem
|
||||||
|
- location
|
||||||
|
- provider
|
||||||
|
- description
|
||||||
|
- status
|
||||||
|
- createdAt
|
||||||
|
- updatedAt
|
||||||
|
|
||||||
|
#### Service
|
||||||
|
|
||||||
|
- id
|
||||||
|
- serverId
|
||||||
|
- name
|
||||||
|
- type
|
||||||
|
- internalPort
|
||||||
|
- externalPort
|
||||||
|
- protocol
|
||||||
|
- status
|
||||||
|
- description
|
||||||
|
- createdAt
|
||||||
|
- updatedAt
|
||||||
|
|
||||||
|
#### Domain
|
||||||
|
|
||||||
|
- id
|
||||||
|
- serviceId
|
||||||
|
- name
|
||||||
|
- expectedServerIp
|
||||||
|
- lastResolvedIp
|
||||||
|
- lastResolvedIps
|
||||||
|
- resolutionStatus
|
||||||
|
- lastCheckedAt
|
||||||
|
- remarks
|
||||||
|
- lastCheckMessage
|
||||||
|
- createdAt
|
||||||
|
- updatedAt
|
||||||
|
|
||||||
|
#### Note
|
||||||
|
|
||||||
|
- id
|
||||||
|
- serverId
|
||||||
|
- title
|
||||||
|
- content
|
||||||
|
- category
|
||||||
|
- createdAt
|
||||||
|
- updatedAt
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
- Server 1..\* Service
|
||||||
|
- Server 1..\* Note
|
||||||
|
- Service 1..\* Domain
|
||||||
|
|
||||||
|
## 3. Activity Diagram
|
||||||
|
|
||||||
|
Suggested activity: **Check DNS**
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
- Admin opens domain list/detail
|
||||||
|
- Admin clicks Check DNS
|
||||||
|
- System resolves A record
|
||||||
|
- System compares resolved IPs with expected server IP
|
||||||
|
- System stores result
|
||||||
|
- System shows Match / Mismatch / Unresolved
|
||||||
|
|
||||||
|
## 4. State Chart Diagram
|
||||||
|
|
||||||
|
Suggested state chart: **Domain DNS Status**
|
||||||
|
|
||||||
|
States:
|
||||||
|
|
||||||
|
- Unresolved
|
||||||
|
- Match
|
||||||
|
- Mismatch
|
||||||
|
|
||||||
|
Transitions:
|
||||||
|
|
||||||
|
- Check DNS success with expected IP found -> Match
|
||||||
|
- Check DNS success with expected IP missing -> Mismatch
|
||||||
|
- Check DNS failure -> Unresolved
|
||||||
|
|
||||||
|
## 5. Sequence Diagram
|
||||||
|
|
||||||
|
Suggested sequence: **Create Domain and Check DNS**
|
||||||
|
|
||||||
|
Objects:
|
||||||
|
|
||||||
|
- Admin
|
||||||
|
- Web UI
|
||||||
|
- tRPC API
|
||||||
|
- Database
|
||||||
|
- DNS Resolver
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
- Admin submits domain form
|
||||||
|
- UI sends create request
|
||||||
|
- API validates and stores domain
|
||||||
|
- Admin triggers DNS check
|
||||||
|
- UI calls check mutation
|
||||||
|
- API resolves DNS
|
||||||
|
- API updates database
|
||||||
|
- UI displays result
|
||||||
|
|
||||||
|
## 6. Collaboration Diagram
|
||||||
|
|
||||||
|
Show interaction between:
|
||||||
|
|
||||||
|
- Admin
|
||||||
|
- Dashboard UI
|
||||||
|
- Auth module
|
||||||
|
- API router
|
||||||
|
- Database
|
||||||
|
- DNS lookup module
|
||||||
|
|
||||||
|
Focus on how each module collaborates during CRUD and DNS checking.
|
||||||
31
packages/ui/src/components/status-badge.tsx
Normal file
31
packages/ui/src/components/status-badge.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { cn } from "@minmon/ui/lib/utils";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const statusBadgeVariants = cva(
|
||||||
|
"inline-flex items-center gap-1 border px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.16em]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
neutral: "border-border bg-muted/40 text-muted-foreground",
|
||||||
|
success: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||||
|
warning: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||||
|
destructive: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400",
|
||||||
|
info: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-400",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "neutral",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function StatusBadge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> & VariantProps<typeof statusBadgeVariants>) {
|
||||||
|
return <span className={cn(statusBadgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { StatusBadge, statusBadgeVariants };
|
||||||
53
packages/ui/src/components/table.tsx
Normal file
53
packages/ui/src/components/table.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { cn } from "@minmon/ui/lib/utils";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return <table className={cn("w-full caption-bottom text-xs", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableWrapper({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div className={cn("overflow-x-auto border", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return <thead className={cn("bg-muted/40", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return <tr className={cn("border-b transition-colors hover:bg-muted/30", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-3 text-left align-middle text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return <td className={cn("px-3 py-2.5 align-middle", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
||||||
|
return <caption className={cn("mt-3 text-xs text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableWrapper,
|
||||||
|
};
|
||||||
17
packages/ui/src/components/textarea.tsx
Normal file
17
packages/ui/src/components/textarea.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { cn } from "@minmon/ui/lib/utils";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"min-h-24 w-full rounded-none border border-input bg-transparent px-2.5 py-2 text-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 dark:bg-input/30",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
Loading…
Add table
Add a link
Reference in a new issue