first commit
No files matched your search
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
.git/
|
||||
.gitignore
|
||||
.env
|
||||
npm-debug.log
|
||||
docker-compose*.yml
|
||||
coverage/
|
||||
scripts/debug-auth.mts
|
||||
@@ -0,0 +1,83 @@
|
||||
# ==========================================
|
||||
# Doktainer Docker Runtime Environment
|
||||
# ==========================================
|
||||
|
||||
/* -------------------------------- Runtime -------------------------------- */
|
||||
NEXT_PUBLIC_PANEL_NAME=DOKTAINER
|
||||
NEXT_PUBLIC_VERSION=v0.1.0
|
||||
NEXT_PUBLIC_BATCH=Batch-20260515
|
||||
NEXT_PUBLIC_API_PORT=4000
|
||||
|
||||
# Leave NEXT_PUBLIC_API_URL empty/unset in monolith Docker deployment so the
|
||||
# browser uses same-origin /api/v1 and Next proxies it to the backend.
|
||||
# NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
|
||||
# Optional frontend-only explicit override. If set, it wins over FRONTEND_URL
|
||||
# for app-side public URL generation.
|
||||
# NEXT_PUBLIC_PANEL_URL=https://panel.example.com
|
||||
|
||||
/* --------------- Connection settings for PostgreSQL database -------------- */
|
||||
POSTGRES_DB=doktainer
|
||||
POSTGRES_USER=doktainer
|
||||
POSTGRES_PASSWORD=doktainerdb
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
DATABASE_URL=postgresql://doktainer:doktainerdb@postgres:5432/doktainer?schema=public
|
||||
|
||||
/* ------------------------------- App server ------------------------------ */
|
||||
NODE_ENV=production
|
||||
|
||||
# Bind address for the backend process inside the container.
|
||||
# This is NOT a public URL and must not be reused as FRONTEND_URL or NEXT_PUBLIC_PANEL_URL.
|
||||
HOST=0.0.0.0
|
||||
PORT=4000
|
||||
|
||||
JWT_SECRET=dev-super-secret-jwt-key-change-in-production-32chars
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Encryption key for SSH private keys (AES-256)
|
||||
ENCRYPTION_KEY=dev-32-char-encryption-key-here!
|
||||
|
||||
/* -------------------------- Public URL / callbacks ------------------------ */
|
||||
# Public URL priority for absolute links and OAuth callbacks:
|
||||
# 1. NEXT_PUBLIC_PANEL_URL
|
||||
# 2. FRONTEND_URL
|
||||
# 3. Request origin from X-Forwarded-Host / X-Forwarded-Proto (or Host / protocol)
|
||||
# 4. Browser origin for client-side usage
|
||||
# 5. Fallback http://localhost:3000 only when no runtime origin is available
|
||||
#
|
||||
# In Docker deployments behind a reverse proxy, you can leave both values unset and let Doktainer
|
||||
# derive the public origin dynamically from the current request.
|
||||
#
|
||||
# Set FRONTEND_URL only when you want to force one canonical public URL for generated absolute links
|
||||
# such as invite URLs, Git provider callbacks, and settings reset defaults.
|
||||
# Use a real public IP/domain only. Never set FRONTEND_URL to 0.0.0.0, ::, localhost, or a Docker-only host.
|
||||
|
||||
# FRONTEND_URL=https://panel.example.com
|
||||
|
||||
/* -------------------------- Reverse proxy / CORS -------------------------- */
|
||||
# When Doktainer sits behind Nginx, Traefik, Caddy, Cloudflare Tunnel, or another reverse proxy,
|
||||
# set TRUST_PROXY=true so Fastify trusts forwarded host/protocol headers.
|
||||
|
||||
# TRUST_PROXY=true
|
||||
|
||||
# Optional comma-separated extra origins when the backend is exposed directly and CORS must allow them.
|
||||
# Keep this explicit for security; this is not auto-derived from request origin.
|
||||
|
||||
# CORS_ORIGINS=https://panel.example.com,http://your-vps-ip:3128
|
||||
|
||||
# Optional internal backend URL used by Next rewrites in monolith mode.
|
||||
# This is internal-only and separate from the public panel URL above.
|
||||
|
||||
INTERNAL_API_URL=http://127.0.0.1:4000
|
||||
|
||||
/* ------------------------- Docker image / compose ------------------------- */
|
||||
# Optional image and compose naming overrides
|
||||
|
||||
COMPOSE_PROJECT_NAME=Doktainer
|
||||
# DOKTAINER_IMAGE=doktainer:local
|
||||
# DOKTAINER_IMAGE=ghcr.io/doktainer/doktainer:latest
|
||||
# DOKTAINER_LOCAL_IMAGE=doktainer:local
|
||||
# POSTGRES_IMAGE=postgres:16-alpine
|
||||
# FRONTEND_PORT=3000
|
||||
# BACKEND_PORT=4000
|
||||
@@ -0,0 +1,54 @@
|
||||
# ==========================================
|
||||
# Doktainer — Environment Variables
|
||||
# ==========================================
|
||||
|
||||
/* -------------------------------- Runtime -------------------------------- */
|
||||
# Frontend runtime
|
||||
NEXT_PUBLIC_PANEL_NAME=DOKTAINER
|
||||
NEXT_PUBLIC_VERSION=v0.1.0
|
||||
NEXT_PUBLIC_BATCH=Batch-20260515
|
||||
|
||||
# For Docker deployment, leave NEXT_PUBLIC_API_URL unset so the
|
||||
# browser uses same-origin /api/v1 and Next proxies it to the backend.
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost:4000
|
||||
|
||||
|
||||
/* --------------- Connection settings for PostgreSQL database -------------- */
|
||||
# Frontend server
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# Optional canonical/backend URL if ever needed
|
||||
BACKEND_URL=http://127.0.0.1:4000
|
||||
|
||||
# Backend listen address
|
||||
PORT=4000
|
||||
HOST=0.0.0.0
|
||||
NODE_ENV=development
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_URL="postgresql://doktainer:doktainerdb@localhost:5432/doktainer?schema=public"
|
||||
|
||||
# JWT settings
|
||||
JWT_SECRET=dev-super-secret-jwt-key-change-in-production-32chars
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
|
||||
/* -------------------------- Reverse proxy / rate limiting ------------------------- */
|
||||
TRUST_PROXY=false
|
||||
RATE_LIMIT_MAX=300
|
||||
RATE_LIMIT_TIME_WINDOW=1 minute
|
||||
|
||||
# Optional comma-separated extra frontend origins for direct backend access.
|
||||
# CORS_ORIGINS=http://localhost:3000,https://panel.example.com
|
||||
|
||||
# Optional internal backend URL.
|
||||
# INTERNAL_API_URL=http://127.0.0.1:4000
|
||||
|
||||
# Encryption key for SSH private keys (AES-256)
|
||||
ENCRYPTION_KEY=dev-32-char-encryption-key-here!
|
||||
|
||||
/* -------------------------- Optional integrations ------------------------- */
|
||||
# Optional integrations (deprecated, may be removed in future)
|
||||
# CLOUDFLARE_API_TOKEN=
|
||||
# CLOUDFLARE_ZONE_ID=
|
||||
@@ -0,0 +1,51 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Next.js / build output
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
.turbo/
|
||||
.vercel/
|
||||
.swc/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env*.example
|
||||
!.env.docker
|
||||
|
||||
# Tests and coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
/logs/
|
||||
|
||||
# Local caches and temporary files
|
||||
.cache/
|
||||
tmp/
|
||||
temp/
|
||||
.tmp/
|
||||
|
||||
# OS and editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
@@ -0,0 +1,36 @@
|
||||
# CONTRIBUTING.md
|
||||
|
||||
Thank you for contributing to Doktainer!
|
||||
We’re will a big, exciting open source project and we’d love to have you contribute! Here’s some resources and guidance to help you get started:
|
||||
|
||||
[1. Getting Started](#getting-started)<br>
|
||||
[2. Issues](#issues)<br>
|
||||
[3. Pull Requests](#pull-requests)
|
||||
|
||||
## Getting Started
|
||||
|
||||
To ensure a positive and inclusive environment, please read our [code of conduct](https://github.com/DoktainerApp/.github/blob/main/CODE_OF_CONDUCT.md) before contributing. For help setting up the code in this repo, please follow our [DEVELOPERS.md](https://github.com/DoktainerApp/Doktainer/blob/master/DEVELOPERS.md) file. For the [docs](https://docs.doktainer.com) site, follow this [contributing.md](https://github.com/DoktainerApp/doktainer/blob/master/apps/docs/contributing.md) guide.
|
||||
|
||||
## Issues
|
||||
|
||||
If you find a bug, please create an Issue and we’ll triage it.
|
||||
|
||||
- Please search [existing Issues](https://github.com/DoktainerApp/doktainer/issues) before creating a new one.
|
||||
- Please include a clear description of the problem along with steps to reproduce it. Exact steps with screenshots and urls really help here.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
We actively welcome your Pull Requests! A couple of things to keep in mind before you submit:
|
||||
|
||||
- If you’re fixing an Issue, make sure someone else hasn’t already created a PR fixing the same issue. Likewise, make sure to link your PR to the related Issue(s).
|
||||
- We will always try to accept the first viable PR that resolves the Issue.
|
||||
- If you're new, we encourage you to take a look at issues tagged with [good first issue](https://github.com/DoktainerApp/doktainer/labels/good%20first%20issue).
|
||||
- If you’re submitting a new feature, make sure you have opened a [Discussion](https://github.com/orgs/DoktainerApp/discussions/new/choose) to discuss the new feature before opening a PR. We’d love to accept your hard work, but unfortunately if a feature hasn’t gone through a proper design process, your PR will be closed.
|
||||
- Please use the PR message template and provide detailed context for quicker review. PRs without clear problem statements will be closed.
|
||||
|
||||
Prior to submitting your PR, please conduct the following pre-flight checks:
|
||||
|
||||
- Run `npm run build` locally to ensure that your code builds successfully without having to wait on us to approve Vercel Preview deploys.
|
||||
- Ensure that the Prettier tests run successfully on your PR.
|
||||
|
||||
Running these before you create the PR will help reduce back and forth with the team.
|
||||
@@ -0,0 +1,61 @@
|
||||
FROM node:22-bookworm-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL=
|
||||
ARG NEXT_PUBLIC_WS_URL=
|
||||
ARG NEXT_PUBLIC_API_PORT=4000
|
||||
ARG NEXT_PUBLIC_PANEL_NAME=DOKTAINER
|
||||
ARG NEXT_PUBLIC_VERSION=v0.1.0
|
||||
ARG NEXT_PUBLIC_BATCH=Batch-20260515
|
||||
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV NEXT_PUBLIC_API_PORT=$NEXT_PUBLIC_API_PORT
|
||||
ENV NEXT_PUBLIC_PANEL_NAME=$NEXT_PUBLIC_PANEL_NAME
|
||||
ENV NEXT_PUBLIC_VERSION=$NEXT_PUBLIC_VERSION
|
||||
ENV NEXT_PUBLIC_BATCH=$NEXT_PUBLIC_BATCH
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4000
|
||||
ENV POSTGRES_HOST=postgres
|
||||
ENV POSTGRES_DB_PORT=5432
|
||||
ENV POSTGRES_DB=doktainer
|
||||
ENV POSTGRES_USER=doktainer
|
||||
ENV POSTGRES_PASSWORD=doktainerdb
|
||||
ENV DATABASE_URL=postgresql://doktainer:doktainerdb@postgres:5432/doktainer?schema=public
|
||||
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
COPY .env.docker .env
|
||||
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3000 4000
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Doktainer
|
||||
Copyright (c) 2026 KodekaTeam
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<div align="center">
|
||||
<img src="./images/previews/banner-doktainer-feature.png" alt="Maskot Doktainer (DOKTA)" width="780"/>
|
||||
|
||||
# Doktainer - Kelola Docker, Sederhanakan Semuanya
|
||||
|
||||

|
||||
<br>
|
||||
[](https://discord.gg/tDUBDCYw9Q)
|
||||
[](https://hub.docker.com/r/DoktainerApp/doktainer)<br>
|
||||
[](https://github.com/DoktainerApp/doktainer/pkgs/container/doktainer)
|
||||
[](https://github.com/DoktainerApp/doktainer/blob/main/LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
Doktainer adalah platform open-source dan self-hosted untuk mengelola server, aplikasi, container, domain, SSL, backup, dan operasi deployment dari satu panel web.
|
||||
|
||||
Proyek ini menggunakan arsitektur monolith modular modern:
|
||||
|
||||
- frontend dengan Next.js App Router
|
||||
- API backend dengan Fastify
|
||||
- database PostgreSQL melalui Prisma ORM
|
||||
- deployment lokal dan server menggunakan Docker Compose
|
||||
|
||||
Doktainer ditujukan untuk tim atau individu yang ingin memiliki panel deployment sendiri tanpa bergantung pada vendor cloud tertentu, sekaligus tetap memegang kendali penuh atas server dan data mereka.
|
||||
|
||||
## Fitur Utama
|
||||
|
||||
- Manajemen server dan akses pengguna dalam satu panel.
|
||||
- Manajemen aplikasi dan container berbasis Docker.
|
||||
- Dukungan untuk domain, SSL, jaringan, keamanan, log, metrik, dan akses terminal.
|
||||
- Organisasi, akses berbasis peran, API key, dan konfigurasi pengaturan pengguna.
|
||||
- Integrasi Git provider, tujuan penyimpanan, notifikasi, dan backup.
|
||||
- UI panel modern dengan Next.js dan API backend yang berjalan terpisah saat runtime.
|
||||
- Dukungan untuk pengembangan lokal dan deployment Docker ke VPS atau server online.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Next.js 16
|
||||
- React 19
|
||||
- Fastify 5
|
||||
- Prisma 6
|
||||
- PostgreSQL 16
|
||||
- TypeScript 5
|
||||
- Docker Compose
|
||||
|
||||
## Struktur Proyek
|
||||
|
||||
- `src/app` untuk route dan halaman frontend berbasis Next.js App Router.
|
||||
- `src/components` untuk komponen UI bersama.
|
||||
- `src/lib` untuk utilitas frontend dan helper runtime bersama.
|
||||
- `src/server` untuk API backend Fastify, service, middleware, dan library server.
|
||||
- `prisma` untuk skema database dan migrasi Prisma.
|
||||
- `scripts` untuk script helper internal seperti pengecekan Prisma client.
|
||||
- `tests` untuk test yang relevan dengan codebase ini.
|
||||
|
||||
## Kebutuhan Sistem
|
||||
|
||||
### Minimum untuk pengembangan
|
||||
|
||||
- Node.js 22 atau versi yang kompatibel dengan dependensi proyek ini.
|
||||
- npm 10 atau versi yang disertakan pada rilis Node.js modern.
|
||||
- PostgreSQL 16 atau service PostgreSQL yang kompatibel.
|
||||
- Docker Desktop atau Docker Engine + Docker Compose v2 jika ingin melakukan deployment dengan container.
|
||||
|
||||
### Port default
|
||||
|
||||
- `3000` untuk frontend Next.js
|
||||
- `4000` untuk backend Fastify
|
||||
- `5432` untuk PostgreSQL
|
||||
|
||||
## Quick Start Lokal
|
||||
|
||||
### 1. Clone repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DoktainerApp/doktainer
|
||||
cd doktainer
|
||||
```
|
||||
|
||||
### 2. Install dependensi
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Siapkan environment
|
||||
|
||||
Salin file environment contoh dan sesuaikan nilainya.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Untuk Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
Nilai minimum yang perlu diperhatikan:
|
||||
|
||||
```env
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
HOST=0.0.0.0
|
||||
DATABASE_URL=postgresql://doktainer:doktainerdb@postgres:5432/doktainer?schema=public
|
||||
JWT_SECRET=change-this-to-a-secure-secret-with-32-chars-min
|
||||
ENCRYPTION_KEY=change-this-to-a-secure-32-char-key
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost:4000
|
||||
```
|
||||
|
||||
Catatan penting:
|
||||
|
||||
- `JWT_SECRET` harus memiliki panjang minimal 32 karakter, jika tidak backend akan gagal berjalan.
|
||||
- `DATABASE_URL` harus valid. Jika salah, autentikasi dan startup backend akan gagal lebih awal.
|
||||
- `ENCRYPTION_KEY` sebaiknya diganti sebelum digunakan di environment nyata.
|
||||
|
||||
### 4. Siapkan database
|
||||
|
||||
Pastikan PostgreSQL berjalan, lalu generate Prisma client dan sinkronkan skema:
|
||||
|
||||
```bash
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
Jika menggunakan workflow migrasi pengembangan, Anda juga dapat menjalankan:
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
### 5. Jalankan server pengembangan
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Setelah berhasil:
|
||||
|
||||
- frontend tersedia di `http://localhost:3000`
|
||||
- backend tersedia di `http://localhost:4000`
|
||||
- health check backend tersedia di `http://localhost:4000/health`
|
||||
|
||||
## Menjalankan Secara Lokal dalam Mode Production Tanpa Docker
|
||||
|
||||
Jika ingin menjalankan mode production di mesin lokal:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
Perintah ini akan:
|
||||
|
||||
- membangun frontend Next.js
|
||||
- mengompilasi backend TypeScript ke folder `dist`
|
||||
- menjalankan frontend production dan backend production secara bersamaan
|
||||
|
||||
## Instalasi Online / VPS dengan Docker
|
||||
|
||||
Pendekatan ini paling cocok untuk server online, instance VPS, atau environment self-hosted. Proyek ini sudah menyediakan:
|
||||
|
||||
- `Dockerfile`
|
||||
- `docker-compose.yml`
|
||||
- `docker-compose.build.yml`
|
||||
- `docker-entrypoint.sh`
|
||||
- `.env.docker`
|
||||
|
||||
### Deployment yang direkomendasikan
|
||||
|
||||
Gunakan alur ini jika Anda membangun image langsung dari source proyek di server.
|
||||
|
||||
1. Salin proyek ke server.
|
||||
2. Edit `.env.docker` sesuai kebutuhan.
|
||||
3. Jalankan:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
Jika ingin menggunakan image dari registry, sesuaikan `DOKTAINER_IMAGE` di `.env.docker` dan pastikan strategi deployment tetap konsisten dengan definisi Compose yang aktif.
|
||||
|
||||
### Service yang dijalankan
|
||||
|
||||
Setup Docker Compose default akan menjalankan:
|
||||
|
||||
- `postgres` untuk database PostgreSQL
|
||||
- `app` untuk frontend Next.js dan backend Fastify dalam satu container aplikasi
|
||||
|
||||
Mapping port default:
|
||||
|
||||
- `3000:3000` untuk frontend
|
||||
- `4000:4000` untuk backend
|
||||
- `5432:5432` untuk PostgreSQL
|
||||
|
||||
### Langkah deployment Docker yang direkomendasikan
|
||||
|
||||
1. Salin proyek ke server/VPS Linux.
|
||||
2. Pastikan Docker dan Docker Compose v2 sudah terpasang.
|
||||
3. Edit `.env.docker` dan ganti secret default.
|
||||
4. Jalankan perintah build/deploy Compose.
|
||||
5. Periksa log container.
|
||||
6. Verifikasi `http://SERVER-IP:3000` dan `http://SERVER-IP:4000/health`.
|
||||
7. Siapkan reverse proxy dan HTTPS untuk penggunaan production publik.
|
||||
|
||||
Contoh untuk melihat log:
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
docker compose logs -f postgres
|
||||
```
|
||||
|
||||
Menghentikan service:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Menghentikan service dan menghapus volume database:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Konfigurasi Runtime Docker
|
||||
|
||||
File `.env.docker` adalah sumber environment utama untuk deployment container. Variabel paling penting adalah:
|
||||
|
||||
```env
|
||||
POSTGRES_DB=doktainer
|
||||
POSTGRES_USER=doktainer
|
||||
POSTGRES_PASSWORD=replace-this-password
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=4000
|
||||
DATABASE_URL=postgresql://doktainer:replace-this-password@postgres:5432/doktainer?schema=public
|
||||
|
||||
JWT_SECRET=replace-this-with-a-secure-32-char-secret
|
||||
ENCRYPTION_KEY=replace-this-with-a-secure-32-char-key
|
||||
|
||||
NEXT_PUBLIC_PANEL_NAME=DOKTAINER
|
||||
NEXT_PUBLIC_VERSION=v0.1.0
|
||||
NEXT_PUBLIC_BATCH=Batch-20260515
|
||||
NEXT_PUBLIC_API_PORT=4000
|
||||
```
|
||||
|
||||
Catatan penting deployment:
|
||||
|
||||
- Untuk deployment production, `NEXT_PUBLIC_API_URL` sebaiknya dibiarkan kosong agar browser menggunakan same-origin `/api/v1`.
|
||||
- Jika Doktainer berjalan di belakang Nginx, Traefik, Caddy, atau Cloudflare Tunnel, aktifkan `TRUST_PROXY=true`.
|
||||
- Atur `FRONTEND_URL` atau `NEXT_PUBLIC_PANEL_URL` hanya jika Anda benar-benar ingin memaksa satu URL publik kanonis.
|
||||
- Jangan gunakan `localhost`, `0.0.0.0`, atau host Docker internal sebagai URL production publik.
|
||||
|
||||
## Reverse Proxy dan HTTPS
|
||||
|
||||
Untuk penggunaan online, sangat disarankan menempatkan Doktainer di belakang reverse proxy seperti:
|
||||
|
||||
- Nginx
|
||||
- Traefik
|
||||
- Caddy
|
||||
- Cloudflare Tunnel
|
||||
|
||||
Tujuannya adalah untuk:
|
||||
|
||||
- menyediakan HTTPS
|
||||
- meneruskan header host dan protokol dengan benar
|
||||
- menyederhanakan akses publik dengan domain Anda sendiri
|
||||
- mengurangi paparan backend langsung ke internet
|
||||
|
||||
Jika menggunakan reverse proxy, periksa dua hal berikut:
|
||||
|
||||
- set `TRUST_PROXY=true`
|
||||
- set `FRONTEND_URL=https://your-domain.com` jika Anda membutuhkan URL absolut yang konsisten
|
||||
|
||||
## Workflow Pengembangan yang Tersedia
|
||||
|
||||
Script utama proyek:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run start
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
npm run db:migrate
|
||||
npm run db:studio
|
||||
```
|
||||
|
||||
Ringkasan fungsi:
|
||||
|
||||
- `npm run dev` menjalankan frontend dan backend dalam mode development.
|
||||
- `npm run build` membangun frontend dan mengompilasi backend.
|
||||
- `npm run start` menjalankan output build production.
|
||||
- `npm run lint` menjalankan ESLint.
|
||||
- `npm run test` menjalankan test suite TypeScript.
|
||||
- `npm run db:generate` membuat Prisma client.
|
||||
- `npm run db:push` menyinkronkan skema ke database.
|
||||
- `npm run db:migrate` membuat dan menerapkan migrasi development.
|
||||
- `npm run db:studio` membuka Prisma Studio.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Login atau register gagal
|
||||
|
||||
Jika login atau registrasi mengembalikan `Internal Server Error`, hampir selalu mulai dari pengecekan berikut:
|
||||
|
||||
1. pastikan `DATABASE_URL` benar
|
||||
2. pastikan PostgreSQL benar-benar berjalan
|
||||
3. pastikan `JWT_SECRET` valid dan memiliki panjang minimal 32 karakter
|
||||
4. jalankan ulang `npm run db:generate`, lalu `npm run db:push`
|
||||
|
||||
### Backend gagal berjalan
|
||||
|
||||
Kemungkinan penyebab umum:
|
||||
|
||||
- port `4000` sudah digunakan oleh proses lain
|
||||
- koneksi PostgreSQL gagal
|
||||
- `JWT_SECRET` kosong atau terlalu pendek
|
||||
|
||||
### Container app langsung berhenti
|
||||
|
||||
Periksa:
|
||||
|
||||
- `DATABASE_URL` di `.env.docker`
|
||||
- password Postgres di `POSTGRES_PASSWORD`
|
||||
- log container dengan `docker compose logs -f app`
|
||||
|
||||
Docker entrypoint proyek ini akan:
|
||||
|
||||
- menunggu sampai database dapat diakses
|
||||
- menjalankan `prisma migrate deploy` jika migrasi tersedia
|
||||
- fallback ke `prisma db push` jika belum ada migrasi
|
||||
- menjalankan backend dan frontend dalam satu container
|
||||
|
||||
## Catatan Arsitektur
|
||||
|
||||
Doktainer di folder ini menggunakan model repository monolith modular dengan dua runtime utama:
|
||||
|
||||
- Next.js untuk antarmuka web pada port `3000`
|
||||
- API Fastify pada port `4000`
|
||||
|
||||
Keduanya digabungkan dalam satu proyek untuk menyederhanakan pengembangan dan deployment, sekaligus menjaga pemisahan frontend dan backend tetap jelas pada level runtime.
|
||||
|
||||
## Lisensi
|
||||
|
||||
Doktainer menggunakan lisensi MIT. Lihat file `LICENSE` di root repository untuk detail lisensi.
|
||||
|
||||
#### Penggunaan Non-Komersial Saja
|
||||
|
||||
Software ini bebas digunakan, dimodifikasi, dan didistribusikan hanya untuk **tujuan non-komersial**. Penggunaan untuk aktivitas yang menghasilkan pendapatan atau di dalam organisasi profit dilarang keras berdasarkan ketentuan ini.
|
||||
|
||||
#### Lisensi Komersial
|
||||
|
||||
Jika ingin menggunakan Doktainer untuk tujuan komersial, operasional bisnis, atau sebagai bagian dari layanan berbayar, Anda harus memperoleh lisensi komersial terpisah. Silakan hubungi author untuk informasi lebih lanjut.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">Dibuat dengan cinta oleh KodekaTeam</p>
|
||||
@@ -1,2 +1,365 @@
|
||||
# doktainer
|
||||
Open Source Alternative to Vercel, Netlify, and Heroku.
|
||||
<div align="center">
|
||||
<img src="./images/previews/banner-doktainer-feature.png" alt="Doktainer Mascot (DOKTA)" width="780"/>
|
||||
|
||||
# Doktainer - Manage Docker, Simplify Everything
|
||||
|
||||

|
||||
<br>
|
||||
[](https://discord.gg/tDUBDCYw9Q)
|
||||
[](https://hub.docker.com/r/DoktainerApp/doktainer)<br>
|
||||
[](https://github.com/DoktainerApp/doktainer/pkgs/container/doktainer)
|
||||
[](https://github.com/DoktainerApp/doktainer/blob/main/LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
Doktainer is an open-source, self-hosted platform for managing servers, applications, containers, domains, SSL, backups, and deployment operations from a single web panel.
|
||||
|
||||
This project uses a modern modular monolith architecture:
|
||||
|
||||
- frontend with Next.js App Router
|
||||
- backend API with Fastify
|
||||
- PostgreSQL database via Prisma ORM
|
||||
- local and server deployment using Docker Compose
|
||||
|
||||
Doktainer is intended for teams or individuals who want to own their own deployment panel without depending on a specific cloud vendor, while still retaining full control over their servers and data.
|
||||
|
||||
## Key Features
|
||||
|
||||
- Server management and user access in one panel.
|
||||
- Docker-based application and container management.
|
||||
- Support for domains, SSL, networks, security, logs, metrics, and terminal access.
|
||||
- Organizations, role-based access, API keys, and user settings configuration.
|
||||
- Git provider, storage destination, and notification/backup integrations.
|
||||
- Modern panel UI with Next.js and a backend API that runs separately at runtime.
|
||||
- Support for local development and Docker deployment to a VPS or online server.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Next.js 16
|
||||
- React 19
|
||||
- Fastify 5
|
||||
- Prisma 6
|
||||
- PostgreSQL 16
|
||||
- TypeScript 5
|
||||
- Docker Compose
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/app` for routes and frontend pages based on the Next.js App Router.
|
||||
- `src/components` for shared UI components.
|
||||
- `src/lib` for frontend utilities and shared runtime helpers.
|
||||
- `src/server` for the Fastify backend API, services, middleware, and server libraries.
|
||||
- `prisma` for the database schema and Prisma migrations.
|
||||
- `scripts` for internal helper scripts such as Prisma client checks.
|
||||
- `tests` for tests relevant to this codebase.
|
||||
|
||||
## System Requirements
|
||||
|
||||
### Minimum for development
|
||||
|
||||
- Node.js 22 or a version compatible with this project's dependencies.
|
||||
- npm 10 or the version bundled with modern Node.js releases.
|
||||
- PostgreSQL 16 or a compatible PostgreSQL service.
|
||||
- Docker Desktop or Docker Engine + Docker Compose v2 if you want to deploy with containers.
|
||||
|
||||
### Default ports
|
||||
|
||||
- `3000` for the Next.js frontend
|
||||
- `4000` for the Fastify backend
|
||||
- `5432` for PostgreSQL
|
||||
|
||||
## Local Quick Start
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DoktainerApp/doktainer
|
||||
cd doktainer
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Prepare the environment
|
||||
|
||||
Copy the example environment file and adjust the values.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
For Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
Minimum values that need attention:
|
||||
|
||||
```env
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
HOST=0.0.0.0
|
||||
DATABASE_URL=postgresql://doktainer:doktainerdb@postgres:5432/doktainer?schema=public
|
||||
JWT_SECRET=change-this-to-a-secure-secret-with-32-chars-min
|
||||
ENCRYPTION_KEY=change-this-to-a-secure-32-char-key
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost:4000
|
||||
```
|
||||
|
||||
Important notes:
|
||||
|
||||
- `JWT_SECRET` must be at least 32 characters long, otherwise the backend will fail to start.
|
||||
- `DATABASE_URL` must be valid. If it is incorrect, authentication and backend startup will fail early.
|
||||
- `ENCRYPTION_KEY` should be changed before being used in a real environment.
|
||||
|
||||
### 4. Prepare the database
|
||||
|
||||
Make sure PostgreSQL is running, then generate the Prisma client and synchronize the schema:
|
||||
|
||||
```bash
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
If you use the development migration workflow, you can also run:
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
### 5. Run the development server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Once it succeeds:
|
||||
|
||||
- the frontend is available at `http://localhost:3000`
|
||||
- the backend is available at `http://localhost:4000`
|
||||
- the backend health check is available at `http://localhost:4000/health`
|
||||
|
||||
## Running Locally in Production Mode Without Docker
|
||||
|
||||
If you want to run production mode on a local machine:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
||||
- build the Next.js frontend
|
||||
- compile the TypeScript backend into the `dist` folder
|
||||
- run the production frontend and production backend together
|
||||
|
||||
## Online / VPS Installation With Docker
|
||||
|
||||
This approach is best suited for online servers, VPS instances, or self-hosted environments. This project already provides:
|
||||
|
||||
- `Dockerfile`
|
||||
- `docker-compose.yml`
|
||||
- `docker-compose.build.yml`
|
||||
- `docker-entrypoint.sh`
|
||||
- `.env.docker`
|
||||
|
||||
### Recommended deployment
|
||||
|
||||
Use this flow if you build the image directly from the project source on the server.
|
||||
|
||||
1. Copy the project to the server.
|
||||
2. Edit `.env.docker` as needed.
|
||||
3. Run:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
If you want to use an image from a registry, adjust `DOKTAINER_IMAGE` in `.env.docker` and make sure your deployment strategy remains consistent with the active Compose definitions.
|
||||
|
||||
### Services that are started
|
||||
|
||||
The default Docker Compose setup will run:
|
||||
|
||||
- `postgres` for the PostgreSQL database
|
||||
- `app` for the Next.js frontend and Fastify backend in a single application container
|
||||
|
||||
Default port mappings:
|
||||
|
||||
- `3000:3000` for the frontend
|
||||
- `4000:4000` for the backend
|
||||
- `5432:5432` for PostgreSQL
|
||||
|
||||
### Recommended Docker deployment steps
|
||||
|
||||
1. Copy the project to a Linux server/VPS.
|
||||
2. Make sure Docker and Docker Compose v2 are installed.
|
||||
3. Edit `.env.docker` and replace the default secrets.
|
||||
4. Run the Compose build/deploy command.
|
||||
5. Check the container logs.
|
||||
6. Verify `http://SERVER-IP:3000` and `http://SERVER-IP:4000/health`.
|
||||
7. Set up a reverse proxy and HTTPS for public production use.
|
||||
|
||||
Example for viewing logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
docker compose logs -f postgres
|
||||
```
|
||||
|
||||
Stopping the services:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Stopping the services and removing the database volume:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Docker Runtime Configuration
|
||||
|
||||
The `.env.docker` file is the main environment source for container deployment. The most important variables are:
|
||||
|
||||
```env
|
||||
POSTGRES_DB=doktainer
|
||||
POSTGRES_USER=doktainer
|
||||
POSTGRES_PASSWORD=replace-this-password
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=4000
|
||||
DATABASE_URL=postgresql://doktainer:replace-this-password@postgres:5432/doktainer?schema=public
|
||||
|
||||
JWT_SECRET=replace-this-with-a-secure-32-char-secret
|
||||
ENCRYPTION_KEY=replace-this-with-a-secure-32-char-key
|
||||
|
||||
NEXT_PUBLIC_PANEL_NAME=DOKTAINER
|
||||
NEXT_PUBLIC_VERSION=v0.1.0
|
||||
NEXT_PUBLIC_BATCH=Batch-20260515
|
||||
NEXT_PUBLIC_API_PORT=4000
|
||||
```
|
||||
|
||||
Important deployment notes:
|
||||
|
||||
- For purpose deployment, `NEXT_PUBLIC_API_URL` should be left empty so the browser uses the same-origin `/api/v1`.
|
||||
- If Doktainer runs behind Nginx, Traefik, Caddy, or Cloudflare Tunnel, enable `TRUST_PROXY=true`.
|
||||
- Set `FRONTEND_URL` or `NEXT_PUBLIC_PANEL_URL` only if you truly want to force a single canonical public URL.
|
||||
- Do not use `localhost`, `0.0.0.0`, or an internal Docker host as the public production URL.
|
||||
|
||||
## Reverse Proxy and HTTPS
|
||||
|
||||
For online use, it is highly recommended to place Doktainer behind a reverse proxy such as:
|
||||
|
||||
- Nginx
|
||||
- Traefik
|
||||
- Caddy
|
||||
- Cloudflare Tunnel
|
||||
|
||||
The goals are to:
|
||||
|
||||
- provide HTTPS
|
||||
- forward host and protocol headers correctly
|
||||
- simplify public access with your own domain
|
||||
- reduce direct backend exposure to the internet
|
||||
|
||||
If you use a reverse proxy, check the following two things:
|
||||
|
||||
- set `TRUST_PROXY=true`
|
||||
- set `FRONTEND_URL=https://your-domain.com` if you need consistent absolute URLs
|
||||
|
||||
## Available Development Workflow
|
||||
|
||||
Main project scripts:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run start
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
npm run db:migrate
|
||||
npm run db:studio
|
||||
```
|
||||
|
||||
Function summary:
|
||||
|
||||
- `npm run dev` runs the frontend and backend in development mode.
|
||||
- `npm run build` builds the frontend and compiles the backend.
|
||||
- `npm run start` runs the production build output.
|
||||
- `npm run lint` runs ESLint.
|
||||
- `npm run test` runs the TypeScript test suite.
|
||||
- `npm run db:generate` generates the Prisma client.
|
||||
- `npm run db:push` synchronizes the schema to the database.
|
||||
- `npm run db:migrate` creates and applies development migrations.
|
||||
- `npm run db:studio` opens Prisma Studio.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Login or register fails
|
||||
|
||||
If login or registration returns `Internal Server Error`, almost always start with the following checks:
|
||||
|
||||
1. make sure `DATABASE_URL` is correct
|
||||
2. make sure PostgreSQL is actually running
|
||||
3. make sure `JWT_SECRET` is valid and at least 32 characters long
|
||||
4. rerun `npm run db:generate`, then `npm run db:push`
|
||||
|
||||
### Backend fails to start
|
||||
|
||||
Common possible causes:
|
||||
|
||||
- port `4000` is already used by another process
|
||||
- PostgreSQL connection fails
|
||||
- `JWT_SECRET` is empty or too short
|
||||
|
||||
### App container exits immediately
|
||||
|
||||
Check:
|
||||
|
||||
- `DATABASE_URL` in `.env.docker`
|
||||
- the Postgres password in `POSTGRES_PASSWORD`
|
||||
- container logs with `docker compose logs -f app`
|
||||
|
||||
This project's Docker entrypoint will:
|
||||
|
||||
- wait until the database can be accessed
|
||||
- run `prisma migrate deploy` if migrations are available
|
||||
- fall back to `prisma db push` if no migrations exist yet
|
||||
- start the backend and frontend in a single container
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
Doktainer in this folder uses a modular monolith repository model with two main runtimes:
|
||||
|
||||
- Next.js for the web interface on port `3000`
|
||||
- Fastify API on port `4000`
|
||||
|
||||
Both are combined in a single project to simplify development and deployment, while keeping the frontend and backend separation clear at the runtime level.
|
||||
|
||||
## License
|
||||
|
||||
Doktainer uses the MIT license. See the `LICENSE` file in the repository root for license details.
|
||||
|
||||
#### 🚫 Non-Commercial Use Only
|
||||
|
||||
The software is free to use, modify, and distribute for **non-commercial purposes only**. Any use for revenue-generating activities or within for-profit organizations is strictly prohibited under these terms.
|
||||
|
||||
#### 💼 Commercial Licensing
|
||||
|
||||
If you wish to use Doktainer for commercial purposes, business operations, or as part of a paid service, you must obtain a separate commercial license. Please contact the author for further information.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">Built with ❤️ by the KodekaTeam</p>
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
NEXT_PUBLIC_API_PORT: ${NEXT_PUBLIC_API_PORT:-4000}
|
||||
NEXT_PUBLIC_PANEL_NAME: ${NEXT_PUBLIC_PANEL_NAME:-DOKTAINER}
|
||||
NEXT_PUBLIC_VERSION: ${NEXT_PUBLIC_VERSION:-unknown}
|
||||
NEXT_PUBLIC_BATCH: ${NEXT_PUBLIC_BATCH:-dev}
|
||||
image: ${DOKTAINER_LOCAL_IMAGE:-doktainer:local}
|
||||
@@ -0,0 +1,60 @@
|
||||
name: ${COMPOSE_PROJECT_NAME:-doktainer}
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
|
||||
container_name: ${COMPOSE_PROJECT_NAME:-doktainer}-postgres
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.docker
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- doktainer_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
doktainer_internal:
|
||||
aliases:
|
||||
- postgres
|
||||
- postgres-app
|
||||
- doktainer-postgres
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
app:
|
||||
image: ${DOKTAINER_IMAGE:-doktainer:local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${COMPOSE_PROJECT_NAME:-doktainer}-app
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.docker
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- doktainer_internal
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
- "${BACKEND_PORT:-4000}:4000"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'node -e "Promise.all([fetch(''http://127.0.0.1:3000''), fetch(''http://127.0.0.1:4000/health'')]).then(([frontend, backend]) => process.exit(frontend.ok && backend.ok ? 0 : 1)).catch(() => process.exit(1))"',
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 45s
|
||||
|
||||
volumes:
|
||||
doktainer_postgres_data:
|
||||
|
||||
networks:
|
||||
doktainer_internal:
|
||||
name: ${COMPOSE_PROJECT_NAME:-doktainer}-internal
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
node <<'NODE'
|
||||
const net = require("net");
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error("DATABASE_URL is not set.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const url = new URL(databaseUrl);
|
||||
const host = url.hostname;
|
||||
const port = Number(url.port || 5432);
|
||||
const timeoutMs = 90_000;
|
||||
const intervalMs = 2_000;
|
||||
const startedAt = Date.now();
|
||||
|
||||
function check() {
|
||||
const socket = net.createConnection({ host, port });
|
||||
|
||||
socket.once("connect", () => {
|
||||
socket.end();
|
||||
console.log(`Database is reachable at ${host}:${port}.`);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
socket.once("error", (error) => {
|
||||
socket.destroy();
|
||||
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
console.error(`Database is not reachable at ${host}:${port}: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Waiting for database at ${host}:${port}...`);
|
||||
setTimeout(check, intervalMs);
|
||||
});
|
||||
}
|
||||
|
||||
check();
|
||||
NODE
|
||||
|
||||
if [ -d /app/prisma/migrations ] && [ "$(find /app/prisma/migrations -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then
|
||||
npx prisma migrate deploy
|
||||
else
|
||||
npx prisma db push
|
||||
fi
|
||||
|
||||
node dist/server/index.js &
|
||||
api_pid=$!
|
||||
|
||||
cleanup() {
|
||||
kill "$api_pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap cleanup INT TERM EXIT
|
||||
|
||||
exec node_modules/.bin/next start -p 3000 -H 0.0.0.0
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
globalIgnores([
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"dist/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const contentSecurityPolicyDirectives = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self' https://github.com",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"connect-src 'self' http: https: ws: wss:",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
] as const;
|
||||
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: contentSecurityPolicyDirectives.join("; "),
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), geolocation=(), microphone=()",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["localhost", "127.0.0.1", "192.168.12.11"],
|
||||
devIndicators: false,
|
||||
async rewrites() {
|
||||
const backendUrl =
|
||||
process.env.INTERNAL_API_URL ||
|
||||
process.env.BACKEND_URL ||
|
||||
`http://127.0.0.1:4000`;
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/api/v1/:path*",
|
||||
destination: `${backendUrl}/api/v1/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/health",
|
||||
destination: `${backendUrl}/health`,
|
||||
},
|
||||
];
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [...securityHeaders],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "doktainer",
|
||||
"description": "Doktainer is a platform for managing containerized applications.",
|
||||
"version": "v0.1.0",
|
||||
"author": "Doktainer Team",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "node scripts/ensure-prisma-client.cjs",
|
||||
"dev": "concurrently -k -n web,api -c cyan,green \"npm:dev:frontend\" \"npm:dev:server\"",
|
||||
"dev:frontend": "next dev",
|
||||
"dev:server": "tsx watch src/server/index.ts",
|
||||
"prebuild": "node scripts/ensure-prisma-client.cjs",
|
||||
"build": "npm run build:frontend && npm run build:server",
|
||||
"build:frontend": "next build",
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
"pretest": "node scripts/ensure-prisma-client.cjs",
|
||||
"test": "tsx --test tests/**/*.test.ts",
|
||||
"lint": "eslint .",
|
||||
"start": "concurrently -k -n web,api -c cyan,green \"npm:start:frontend\" \"npm:start:server\"",
|
||||
"start:frontend": "next start",
|
||||
"start:server": "node dist/server/index.js",
|
||||
"db:generate": "npx prisma generate",
|
||||
"db:push": "npx prisma db push",
|
||||
"db:migrate": "npx prisma migrate dev",
|
||||
"db:studio": "npx prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1031.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@prisma/client": "6.19.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dockerode": "^5.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.3.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "^16.2.6",
|
||||
"node-ssh": "^13.2.0",
|
||||
"node-xlsx": "^0.24.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
"otplib": "^13.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.14.1",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "6.19.3",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,83 @@
|
||||
-- Project-centric deployment foundation
|
||||
-- Adds projects and environments under organizations, while keeping containers backward-compatible.
|
||||
|
||||
CREATE TYPE "EnvironmentKind" AS ENUM (
|
||||
'PRODUCTION',
|
||||
'STAGING',
|
||||
'DEVELOPMENT',
|
||||
'PREVIEW',
|
||||
'CUSTOM'
|
||||
);
|
||||
|
||||
CREATE TABLE "projects" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "environments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"kind" "EnvironmentKind" NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "environments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER TABLE "containers"
|
||||
ADD COLUMN "environmentId" TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX "projects_organizationId_slug_key"
|
||||
ON "projects"("organizationId", "slug");
|
||||
|
||||
CREATE INDEX "projects_organizationId_idx"
|
||||
ON "projects"("organizationId");
|
||||
|
||||
CREATE UNIQUE INDEX "environments_projectId_slug_key"
|
||||
ON "environments"("projectId", "slug");
|
||||
|
||||
CREATE INDEX "environments_projectId_idx"
|
||||
ON "environments"("projectId");
|
||||
|
||||
CREATE INDEX "environments_projectId_kind_idx"
|
||||
ON "environments"("projectId", "kind");
|
||||
|
||||
CREATE INDEX "environments_serverId_idx"
|
||||
ON "environments"("serverId");
|
||||
|
||||
CREATE INDEX "containers_environmentId_idx"
|
||||
ON "containers"("environmentId");
|
||||
|
||||
CREATE INDEX "containers_serverId_idx"
|
||||
ON "containers"("serverId");
|
||||
|
||||
ALTER TABLE "projects"
|
||||
ADD CONSTRAINT "projects_organizationId_fkey"
|
||||
FOREIGN KEY ("organizationId") REFERENCES "organizations"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "environments"
|
||||
ADD CONSTRAINT "environments_projectId_fkey"
|
||||
FOREIGN KEY ("projectId") REFERENCES "projects"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "environments"
|
||||
ADD CONSTRAINT "environments_serverId_fkey"
|
||||
FOREIGN KEY ("serverId") REFERENCES "servers"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "containers"
|
||||
ADD CONSTRAINT "containers_environmentId_fkey"
|
||||
FOREIGN KEY ("environmentId") REFERENCES "environments"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TYPE "DomainNameType" AS ENUM ('EXACT', 'WILDCARD');
|
||||
CREATE TYPE "DomainConfigMode" AS ENUM ('SHARED', 'ISOLATED');
|
||||
|
||||
ALTER TABLE "domains"
|
||||
ADD COLUMN "domainNameType" "DomainNameType" NOT NULL DEFAULT 'EXACT',
|
||||
ADD COLUMN "configMode" "DomainConfigMode" NOT NULL DEFAULT 'SHARED',
|
||||
ADD COLUMN "isPrimary" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
UPDATE "domains"
|
||||
SET "domainNameType" = 'WILDCARD'
|
||||
WHERE "name" LIKE '*.%';
|
||||
|
||||
UPDATE "domains"
|
||||
SET "configMode" = 'ISOLATED'
|
||||
WHERE "proxy" <> 'NGINX';
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TYPE "DomainReviewStatus" AS ENUM ('CONFIRMED', 'NEEDS_REVIEW');
|
||||
|
||||
ALTER TABLE "domains"
|
||||
ADD COLUMN "reviewStatus" "DomainReviewStatus" NOT NULL DEFAULT 'CONFIRMED';
|
||||
@@ -0,0 +1,776 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Auth
|
||||
// ========================
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
passwordHash String
|
||||
role UserRole @default(VIEWER)
|
||||
isActive Boolean @default(true)
|
||||
allServersAccess Boolean @default(true)
|
||||
activeOrganizationId String?
|
||||
lastLogin DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
activeOrganization Organization? @relation("UserActiveOrganization", fields: [activeOrganizationId], references: [id], onDelete: SetNull)
|
||||
organizationMemberships OrganizationMembership[]
|
||||
createdOrganizations Organization[] @relation("OrganizationCreator")
|
||||
apiKeys ApiKey[]
|
||||
auditLogs AuditLog[]
|
||||
settings UserSettings?
|
||||
notificationProviders UserNotificationProvider[]
|
||||
integrations UserIntegration[]
|
||||
gitProviders UserGitProvider[]
|
||||
storageDestinations UserStorageDestination[]
|
||||
serverAssignments UserServerAccess[]
|
||||
sentInvitations UserInvitation[] @relation("InvitationCreator")
|
||||
|
||||
@@index([activeOrganizationId])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
logoUrl String?
|
||||
createdById String?
|
||||
createdBy User? @relation("OrganizationCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
members OrganizationMembership[]
|
||||
activeUsers User[] @relation("UserActiveOrganization")
|
||||
servers Server[]
|
||||
projects Project[]
|
||||
domains Domain[]
|
||||
apiKeys ApiKey[]
|
||||
invitations UserInvitation[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@index([createdById])
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
model OrganizationMembership {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
userId String
|
||||
isDefault Boolean @default(false)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([organizationId, userId])
|
||||
@@index([userId])
|
||||
@@map("organization_memberships")
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Projects
|
||||
// ========================
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
name String
|
||||
slug String
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
environments Environment[]
|
||||
|
||||
@@unique([organizationId, slug])
|
||||
@@index([organizationId])
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
model Environment {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
serverId String
|
||||
name String
|
||||
slug String
|
||||
kind EnvironmentKind
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Restrict)
|
||||
containers Container[]
|
||||
|
||||
@@unique([projectId, slug])
|
||||
@@index([projectId])
|
||||
@@index([projectId, kind])
|
||||
@@index([serverId])
|
||||
@@map("environments")
|
||||
}
|
||||
|
||||
enum EnvironmentKind {
|
||||
PRODUCTION
|
||||
STAGING
|
||||
DEVELOPMENT
|
||||
PREVIEW
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
model UserSettings {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
panelName String @default("DOKTAINER")
|
||||
panelUrl String @default("http://localhost:3000")
|
||||
timezone String @default("Asia/Jakarta")
|
||||
theme String @default("dark")
|
||||
sessionTimeoutMinutes Int @default(30)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorSecretEnc String?
|
||||
twoFactorPendingSecretEnc String?
|
||||
ipWhitelistEnabled Boolean @default(false)
|
||||
ipWhitelist String[] @default([])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("user_settings")
|
||||
}
|
||||
|
||||
model UserNotificationProvider {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
organizationId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
type NotificationProviderType
|
||||
name String
|
||||
enabled Boolean @default(true)
|
||||
actions String[] @default([])
|
||||
channel String?
|
||||
webhookUrlEnc String?
|
||||
smtpHost String?
|
||||
smtpPort Int @default(587)
|
||||
smtpSecure Boolean @default(false)
|
||||
smtpUsername String?
|
||||
smtpPasswordEnc String?
|
||||
smtpFromEmail String?
|
||||
smtpFromName String?
|
||||
telegramChatId String?
|
||||
telegramBotTokenEnc String?
|
||||
serverUrl String?
|
||||
topic String?
|
||||
userKey String?
|
||||
apiKeyEnc String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, organizationId, type])
|
||||
@@index([organizationId])
|
||||
@@map("user_notification_providers")
|
||||
}
|
||||
|
||||
model UserIntegration {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
provider IntegrationProviderType
|
||||
connected Boolean @default(false)
|
||||
zoneId String?
|
||||
apiTokenEnc String?
|
||||
accessKeyId String?
|
||||
secretAccessKeyEnc String?
|
||||
region String?
|
||||
bucket String?
|
||||
tokenEnc String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, provider])
|
||||
@@index([userId])
|
||||
@@map("user_integrations")
|
||||
}
|
||||
|
||||
model UserStorageDestination {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
organizationId String
|
||||
serverId String?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
server Server? @relation(fields: [serverId], references: [id], onDelete: SetNull)
|
||||
name String
|
||||
provider S3StorageProvider
|
||||
enabled Boolean @default(true)
|
||||
accessKeyId String
|
||||
secretAccessKeyEnc String?
|
||||
region String?
|
||||
bucket String
|
||||
endpoint String?
|
||||
additionalFlags String[] @default([])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, organizationId])
|
||||
@@index([organizationId, serverId])
|
||||
@@map("user_storage_destinations")
|
||||
}
|
||||
|
||||
model UserGitProvider {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
organizationId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
containerDeploymentSources ContainerDeploymentSource[]
|
||||
provider GitProviderType
|
||||
name String
|
||||
enabled Boolean @default(true)
|
||||
appName String
|
||||
appId String?
|
||||
clientId String?
|
||||
clientSecretEnc String?
|
||||
webhookSecretEnc String?
|
||||
appUrl String?
|
||||
installationUrl String?
|
||||
providerUrl String?
|
||||
internalUrl String?
|
||||
accountUsername String?
|
||||
accountEmail String?
|
||||
namespace String?
|
||||
organizationScoped Boolean @default(false)
|
||||
organizationName String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, organizationId, provider])
|
||||
@@index([organizationId])
|
||||
@@map("user_git_providers")
|
||||
}
|
||||
|
||||
model UserServerAccess {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
serverId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, serverId])
|
||||
@@index([serverId])
|
||||
@@map("user_server_access")
|
||||
}
|
||||
|
||||
model UserInvitation {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
email String
|
||||
name String
|
||||
role UserRole
|
||||
tokenHash String @unique
|
||||
allServersAccess Boolean @default(true)
|
||||
serverIds String[]
|
||||
invitedById String?
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
invitedBy User? @relation("InvitationCreator", fields: [invitedById], references: [id], onDelete: SetNull)
|
||||
expiresAt DateTime
|
||||
acceptedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([email])
|
||||
@@index([expiresAt])
|
||||
@@index([organizationId])
|
||||
@@map("user_invitations")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
SUPER_ADMIN
|
||||
OPERATOR
|
||||
DEVELOPER
|
||||
VIEWER
|
||||
}
|
||||
|
||||
enum NotificationProviderType {
|
||||
SLACK
|
||||
TELEGRAM
|
||||
DISCORD
|
||||
LARK
|
||||
TEAMS
|
||||
EMAIL
|
||||
RESEND
|
||||
GOTIFY
|
||||
NTFY
|
||||
MATTERMOST
|
||||
PUSHOVER
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
enum IntegrationProviderType {
|
||||
CLOUDFLARE
|
||||
AWS_S3
|
||||
GITHUB
|
||||
GITLAB
|
||||
}
|
||||
|
||||
enum GitProviderType {
|
||||
GITHUB
|
||||
GITLAB
|
||||
BITBUCKET
|
||||
GITEA
|
||||
}
|
||||
|
||||
enum S3StorageProvider {
|
||||
AWS_S3
|
||||
ALIBABA_OSS
|
||||
ARVAN_AOS
|
||||
CEPH
|
||||
CHINA_MOBILE_EOS
|
||||
CLOUDFLARE_R2
|
||||
DIGITALOCEAN_SPACES
|
||||
DREAMOBJECTS
|
||||
GOOGLE_CLOUD_STORAGE
|
||||
HUAWEI_OBS
|
||||
IBM_COS
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
name String
|
||||
keyHash String @unique
|
||||
keyPrefix String
|
||||
permissions String[]
|
||||
userId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
lastUsed DateTime?
|
||||
expiresAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
requestCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([organizationId])
|
||||
@@map("api_keys")
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Servers
|
||||
// ========================
|
||||
model Server {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
name String
|
||||
ip String
|
||||
sshPort Int @default(22)
|
||||
username String @default("root")
|
||||
authType SshAuthType @default(PASSWORD)
|
||||
sshKeyEnc String? // encrypted private key
|
||||
passwordEnc String? // encrypted password
|
||||
status ServerStatus @default(UNKNOWN)
|
||||
os String?
|
||||
location String?
|
||||
tags String[]
|
||||
lastHealthAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
containers Container[]
|
||||
environments Environment[]
|
||||
networks Network[]
|
||||
storageDestinations UserStorageDestination[]
|
||||
domains Domain[]
|
||||
auditLogs AuditLog[]
|
||||
metrics ServerMetric[]
|
||||
appInstalls AppInstall[]
|
||||
backups Backup[]
|
||||
firewallRulePresets FirewallRulePreset[]
|
||||
userAssignments UserServerAccess[]
|
||||
|
||||
@@index([organizationId])
|
||||
@@map("servers")
|
||||
}
|
||||
|
||||
enum SshAuthType {
|
||||
PASSWORD
|
||||
SSH_KEY
|
||||
}
|
||||
|
||||
enum ServerStatus {
|
||||
ONLINE
|
||||
OFFLINE
|
||||
WARNING
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
model ServerMetric {
|
||||
id String @id @default(cuid())
|
||||
serverId String
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
cpuPct Float
|
||||
ramPct Float
|
||||
diskPct Float
|
||||
ramUsed BigInt
|
||||
ramTotal BigInt
|
||||
diskUsed BigInt
|
||||
diskTotal BigInt
|
||||
networkRxBps BigInt?
|
||||
networkTxBps BigInt?
|
||||
uptimeSec BigInt
|
||||
recordedAt DateTime @default(now())
|
||||
|
||||
@@index([serverId, recordedAt])
|
||||
@@map("server_metrics")
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Containers
|
||||
// ========================
|
||||
model Container {
|
||||
id String @id @default(cuid())
|
||||
dockerId String?
|
||||
name String
|
||||
image String
|
||||
status ContainerStatus @default(STOPPED)
|
||||
sourceType ContainerSourceType @default(MANUAL)
|
||||
deployMode ContainerDeployMode?
|
||||
serverId String
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
environmentId String?
|
||||
environment Environment? @relation(fields: [environmentId], references: [id], onDelete: SetNull)
|
||||
domains Domain[]
|
||||
deploymentSource ContainerDeploymentSource?
|
||||
ports Json @default("[]") // [{host: 80, container: 80, protocol: "tcp"}]
|
||||
envVars Json @default("[]") // [{key: "NODE_ENV", value: "production"}]
|
||||
volumes Json @default("[]")
|
||||
labels Json @default("{}")
|
||||
restartPolicy String @default("unless-stopped")
|
||||
cpuUsage String?
|
||||
ramUsage String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([environmentId])
|
||||
@@index([serverId])
|
||||
@@map("containers")
|
||||
}
|
||||
|
||||
model ContainerDeploymentSource {
|
||||
id String @id @default(cuid())
|
||||
containerId String @unique
|
||||
container Container @relation(fields: [containerId], references: [id], onDelete: Cascade)
|
||||
projectName String?
|
||||
gitProviderId String?
|
||||
gitProvider UserGitProvider? @relation(fields: [gitProviderId], references: [id], onDelete: SetNull)
|
||||
repoUrl String?
|
||||
repoBranch String?
|
||||
repoVisibility RepositoryVisibility?
|
||||
buildType String?
|
||||
buildPath String?
|
||||
startCommand String?
|
||||
portOverride String?
|
||||
publishDirectory String?
|
||||
imageTag String?
|
||||
accessTokenEnc String?
|
||||
composeEnvOverrides Json?
|
||||
projectPath String?
|
||||
composeFilePath String?
|
||||
dockerfilePath String?
|
||||
dockerContextPath String?
|
||||
deploymentPath String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([gitProviderId])
|
||||
@@map("container_deployment_sources")
|
||||
}
|
||||
|
||||
model Network {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
driver String @default("bridge")
|
||||
scope String @default("local")
|
||||
subnet String?
|
||||
gateway String?
|
||||
containers Int @default(0)
|
||||
serverId String
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([serverId, name])
|
||||
@@index([serverId])
|
||||
@@map("networks")
|
||||
}
|
||||
|
||||
model FirewallRulePreset {
|
||||
id String @id @default(cuid())
|
||||
serverId String
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
rule String
|
||||
action String
|
||||
direction String @default("IN")
|
||||
source String @default("Anywhere")
|
||||
description String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([serverId])
|
||||
@@unique([serverId, rule, action, direction, source])
|
||||
@@map("firewall_rule_presets")
|
||||
}
|
||||
|
||||
enum ContainerStatus {
|
||||
RUNNING
|
||||
STOPPED
|
||||
STARTING
|
||||
STOPPING
|
||||
ERROR
|
||||
PAUSED
|
||||
}
|
||||
|
||||
enum ContainerSourceType {
|
||||
APP_INSTALLER
|
||||
MANUAL
|
||||
GIT_CLONE
|
||||
GIT_PROVIDER
|
||||
}
|
||||
|
||||
enum ContainerDeployMode {
|
||||
IMAGE
|
||||
DOCKERFILE
|
||||
COMPOSE
|
||||
}
|
||||
|
||||
enum RepositoryVisibility {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Domains & SSL
|
||||
// ========================
|
||||
model Domain {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
organizationId String
|
||||
domainNameType DomainNameType @default(EXACT)
|
||||
configMode DomainConfigMode @default(ISOLATED)
|
||||
reviewStatus DomainReviewStatus @default(CONFIRMED)
|
||||
isPrimary Boolean @default(false)
|
||||
type DnsType @default(A)
|
||||
value String // IP or CNAME target
|
||||
serverId String?
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
server Server? @relation(fields: [serverId], references: [id])
|
||||
targetContainerId String?
|
||||
targetContainer Container? @relation(fields: [targetContainerId], references: [id], onDelete: SetNull)
|
||||
targetPort Int?
|
||||
proxy ProxyType @default(NONE)
|
||||
discoverySource DomainDiscoverySource @default(MANUAL)
|
||||
sslEnabled Boolean @default(false)
|
||||
autoRenew Boolean @default(true)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sslCert SslCert?
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([targetContainerId])
|
||||
@@map("domains")
|
||||
}
|
||||
|
||||
enum DnsType {
|
||||
A
|
||||
AAAA
|
||||
CNAME
|
||||
MX
|
||||
TXT
|
||||
}
|
||||
|
||||
enum ProxyType {
|
||||
TRAEFIK
|
||||
NGINX
|
||||
CADDY
|
||||
NONE
|
||||
}
|
||||
|
||||
enum DomainDiscoverySource {
|
||||
MANUAL
|
||||
NGINX
|
||||
TRAEFIK
|
||||
CADDY
|
||||
CADDY_ADMIN
|
||||
CERTBOT
|
||||
}
|
||||
|
||||
enum DomainNameType {
|
||||
EXACT
|
||||
WILDCARD
|
||||
}
|
||||
|
||||
enum DomainConfigMode {
|
||||
SHARED
|
||||
ISOLATED
|
||||
}
|
||||
|
||||
enum DomainReviewStatus {
|
||||
CONFIRMED
|
||||
NEEDS_REVIEW
|
||||
}
|
||||
|
||||
model SslCert {
|
||||
id String @id @default(cuid())
|
||||
domainId String @unique
|
||||
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
|
||||
issuer String @default("Let's Encrypt")
|
||||
certPem String? // certificate PEM
|
||||
keyPem String? // private key PEM (encrypted)
|
||||
issuedAt DateTime?
|
||||
expiresAt DateTime?
|
||||
status SslStatus @default(PENDING)
|
||||
autoRenew Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("ssl_certs")
|
||||
}
|
||||
|
||||
enum SslStatus {
|
||||
VALID
|
||||
EXPIRING
|
||||
EXPIRED
|
||||
PENDING
|
||||
FAILED
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Audit Log
|
||||
// ========================
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
organizationId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||
serverId String?
|
||||
server Server? @relation(fields: [serverId], references: [id])
|
||||
action String
|
||||
category LogCategory
|
||||
level LogLevel @default(INFO)
|
||||
message String
|
||||
meta Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([organizationId])
|
||||
@@index([serverId])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
enum LogCategory {
|
||||
AUTH
|
||||
SERVER
|
||||
CONTAINER
|
||||
DOMAIN
|
||||
SSL
|
||||
SECURITY
|
||||
SYSTEM
|
||||
TERMINAL
|
||||
}
|
||||
|
||||
enum LogLevel {
|
||||
DEBUG
|
||||
INFO
|
||||
WARNING
|
||||
ERROR
|
||||
SUCCESS
|
||||
}
|
||||
|
||||
// ========================
|
||||
// App Installer
|
||||
// ========================
|
||||
model AppInstall {
|
||||
id String @id @default(cuid())
|
||||
appId String // e.g. "wordpress", "nginx"
|
||||
appName String
|
||||
serverId String
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
containerName String? // docker container name
|
||||
port String? // host port used
|
||||
status AppInstallStatus @default(PENDING)
|
||||
error String?
|
||||
installedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("app_installs")
|
||||
}
|
||||
|
||||
enum AppInstallStatus {
|
||||
PENDING
|
||||
INSTALLING
|
||||
RUNNING
|
||||
STARTING
|
||||
STOPPING
|
||||
STOPPED
|
||||
PAUSED
|
||||
UNKNOWN
|
||||
FAILED
|
||||
REMOVED
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Backups
|
||||
// ========================
|
||||
model Backup {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
type BackupType
|
||||
databaseEngine DatabaseEngine?
|
||||
serverId String
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
target String @default("Local")
|
||||
sizeMb Float?
|
||||
filePath String?
|
||||
status BackupStatus @default(PENDING)
|
||||
error String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("backups")
|
||||
}
|
||||
|
||||
enum BackupType {
|
||||
DATABASE
|
||||
VOLUME
|
||||
FULL
|
||||
}
|
||||
|
||||
enum DatabaseEngine {
|
||||
POSTGRESQL
|
||||
MYSQL
|
||||
MARIADB
|
||||
MONGODB
|
||||
REDIS
|
||||
}
|
||||
|
||||
enum BackupStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><filter x="0%" y="0%" width="100%" height="100%" id="cb143b3321"><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0" color-interpolation-filters="sRGB"/></filter><filter x="0%" y="0%" width="100%" height="100%" id="f8f97ded04"><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0.2126 0.7152 0.0722 0 0" color-interpolation-filters="sRGB"/></filter><mask id="d508e5c76e"><g filter="url(#cb143b3321)"><g filter="url(#f8f97ded04)" transform="matrix(1.519737, 0, 0, 1.519737, -232.720538, -232.720536)"><image x="0" y="0" width="1254" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABOYAAATmCAAAAACgl/H2AAAAAmJLR0QA/4ePzL8AACAASURBVHic7d13nBT1/cfx7+41ODocvYNIEaSIYENBLBG7YrBHTX5RY0lMYokmGmuMURM1QWOPotgSUBQVGyI2FFARkCK9H+04rt/u/v6gHbezZXZn5jPf+b6ej8fvl2N3Z+Z97u77vtOVAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICEQtIB4Deh3kf3b5qfHwqFQyGlYioUUmGlQkqFQrs/LmEVUyEVVmEViqloNBqLxVQsFN41gYqpkIoppWIhFVMqFAup0K6Jd4vFlIrFQiocCoVCex6PxZSKqWhMKRVSIaWisWgkGo3GYkqpUDgUUjEVVkqpWCiswiqsYrFoLBqLxmIqFArlhEIxFVOxmNr9WCwajUQiy99fuM3r/3jwJWoOdeX86qKB+dIhHFO18b+PLJcOAcBPmj6yPRYwtZN7S/9XBeAfV5RIl5Ibqv8t/d8V0lhpxW6hV8ZKR3DJ0vO/ko4AUdQcdmny9pHSEVxTeuEb0hEgiZqDUkqp5h8Olo7goppTpklHgCBqDkopFZp+tHQEV204bKV0BMgJSweAL9wX7JZT7f4hnQCCcqQDwA8Oe1o6gdv6bJ0lHQFiGM1BKfVr6QDuu1Y6AOQwmoNSQx+WTuC+lsu/lY4AKYzmoNR50gG8cIp0AIhhTyuUWtpTOoEHqvpyequpGM1BDTGh5VTBIOkEkELNQfWVDuANas5Y1BzUUdIBvHGgdABIoeag+kkH8EZr6QCQQs1BNZIO4A0OnjIWNQdTvv+G/JqIR83BlO8/B08Zi5qDypMO4A1D2hzxqDmY8v3ns24s3nqYgs+6sXjrYcqHgG1zxjLkE45kDFlphbGoOZgyzKHNjUXNwZTvf0w6AKRQczBlNMdn3Vi89TDlQ2BImyOeIZ9wJGPISiufdWPx1oNhDgKOmoMpHwJDfk3E460HEHDUHPgQIOD4hMOUD4Ehvybi8dbDFOxpMRY1B1O+/4b8mohHzcEU1JyxqDmYcrInNWcsag6mfP8N+TURj5qDKag5Y1FzUFHpAICrqDmYgtGcsag5mLILAsai5mDKMMeQXxPxqDmYgkGrsag5mILRnLGoOfD9R8BRc2BtDgFHzcGU0RxtbixqDqbUHEdBG4uagykMaXPEo+ZgytocNWcsag6moOaMRc0BCDhqDqZgNGcsag58/xFw1BxMQZsbi5oD338EHDUHUw4oMeTXRDxqDqag5oxFzcEUnOxlLGoODHMQcNQcTNkFwWjOWNQcGM0h4Kg5mILRnLGoOQABR83BlGGOIZsgEY+aA9vmEHDUHKg5BBw1ByDgqDkAAUfNwZSVVkP2tCAeNQdTas6QXxPxqDmYcqRFRDoApFBzMGVtjtGcsag5mPL9N+TXRDxqDqag5oxFzYGVVgQcNQcg4Kg5AAFHzcEUfNaNxVsPPgQIOD7hAAKOmoMpDDnZA/GoOZjyIeCAEmMZ8glHMoZ8//msG4u3HqZ8CAxpc8Qz5BMOmHKyB+JRczBl2zyjOWNRc+BDgIDjEw5TMJozFjUHVloRcNQcTPn+G/JrIh41B1NGczAWNQeGOQg4ag7UHAKOmgMQcNQcGM0h4Kg5mHIWFG1uLGoOfP8RcNQcTBnNwVjUHFREOgDgKmoOpuAoaGNRcwACjpoDuyAQcNQcWJtDwFFzMAV7WoxFzcGUlVaOmzEWNQdTas6QXxPxqDmYgpVWY1FzYJiDgKPmYErNGfJrIh41B1O+/xw3YyxqDtQcAo6agyk1lyMdAFKoOZhSc4zmjEXNAQg4ag6mjOb4rBuLtx6m1ByMRc2BmkPAUXMwZds8bW6sXOkAOgk3atSjVywcq9m4uLhSOoyD+P4j2Ki5dHUZcNqpbfYeerV166dvfr5WMg+ANFFzaelxzs/67vdAy5YH/EzNfnfyV0KBHGXISivXmwMSG/5ELJHnj5AO54AlCX+9QPlc+r8zpLALIqWhz37xi4RPXvjpKyM8zOIOQ0ZzbII0FjWXQou/fPWzpC84Z8aj7T3K4hZDPgTUnLEM+YRn7ITZN6V8zRWLz/UgiYvYaIVgo+aSuund7nX/WbZ+g9WrGk/8izdxXMIwB8HGntYk8h79+b5//Dj1o6VrK1sfMGDkmLy4V9406PxtHgZzmCGjOdociNPo3X176d64eF+3HfWqxW68Vd2TzMnnFnq2s1PUTOn/zoDvFEzb+wV5Z+T+T91r8SXa2EskpRPme1c1kj6V/u8M+E3Oy3u/HzfFHXExyeJbtK6jREwnzPOuaiRx3Jyx2AWRyD9/uvuH2tPvjduqc6vFdp7277VwOZJbDNk2B2NRcwn84YrdP8TOnxb/7Lw5FpP0nehmIBcZsm2ee0EYi5qzNuqePT/9fk4Di+dnWE104t2u5XGVIWdBxO8ghyGoOUu5/9zz06LOba3W6RZaTnbzaW4FcpUhozkOnjIWNWfprn67f9jcvF24ocX3Y4dSymKj1v1aDoxqpQN4w5A2RzxqzsqQ6/b8VNSyy5CunVvEFV0bpZTFf71et7mayyWl0gG8US4dAFIYyFu5I3/vj3lHdJ73zbyFxZv2H/KMTDDlTS/94F4st5RIB/DGTukAkELNWTj55Lr/6tz5hEU/zP1oSXGdxw46K8GkBdf/PMEzPrZSOoA3yqQDQAo72S08VO+EhnCbfkcPaldbsvf+D/lPJTzlYfDHK1zL5ZZOeu45sWvhy9IJIETLTeYuG/q55Rh31vtvf1WllFIq/y+/TTz1a+e4Eiqh9n0P6dA4HA4ppVQ0pmIhpVQsGovFdu8gCamoCiulQqGwCseUisVUbNf7nqNycsORqNo6aJS3kaVMjOSGckLhUEiFlYqFYrFQVEVVTMXUrv+ndv8Ui8ViIaWUCoVUKEft/ZbEQrt+DIdCKhqLRmORSG1NdUV56aJ5q6R+JyAz/0p0ttB3N/dUSuUPmZ70nKITPIza+6GNjp4PhQytujcIV8uHOVpsSPhhrn1+WM+TPknxifdu1ejoVFHgobnnefbGwyZWWuNc8kySJ2tSH0of673EuTBJ5D96mSfLQbrev3KpdARY4ri5OEcnezKNE4ZC5zuVJKl+y2g5nzlu0eXSEWCJmquv2chs53CSAylSOmquttd9Cq7wY/dJR4AVDiip7+Ss/yJ3+sD9/W4jZnDEox8dWfi+dATEYzRXnwN7zE7MfhYpdH6Ljar+dMPV0gkQj5qrr0/2szgj+1mk8FYT1xeBzPxD34vlBxc1V1/n7GfR/8Ds55HUdQNcXgAylvOsdALEoebqaeRAzanhDswjmd+7PH9k4Ygx0glQHzVXz5FO3NChvwPzSOLSDu7OH1m5SToA6qPm6nHkMI2+TswksStSvwRyRvSQToB6qLl6HLnLlbujuQHDXJ09snWBdADUQ83V09iJmXR3dTg3ws2ZI3v8GfIbaq6eQkfmcogjc5GYObLn9o522EXN1dPMkbm0dWQuCRhydTh9dWsonQD7o+bqceYO9I0cmUsCzjQxXJPPO+Qz1JwrXD1JgbNZ/Y5bJfoMNVdPtSNzaenIXBLgPQNs4StTjzO3f+rpyFwS4Koyfse3ymd4Q+px5mZ+7R2ZSwJcnMTv+Fb5DG9IPYscmUt+6pdkjvfM7xhv+wxfmXrW1DoxF1cHXIzm/I53yGeouXrKS52YC/9ZjLine truncated
|
||||
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,43 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const backendRoot = path.resolve(__dirname, "..");
|
||||
const generatedClientTypesPath = path.join(
|
||||
backendRoot,
|
||||
"node_modules",
|
||||
".prisma",
|
||||
"client",
|
||||
"index.d.ts",
|
||||
);
|
||||
|
||||
function hasGeneratedPrismaClient() {
|
||||
if (!fs.existsSync(generatedClientTypesPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(generatedClientTypesPath, "utf8");
|
||||
return (
|
||||
content.includes("export type Server =") &&
|
||||
!content.includes("export declare const PrismaClient: any")
|
||||
);
|
||||
}
|
||||
|
||||
if (hasGeneratedPrismaClient()) {
|
||||
console.log("Prisma client already generated.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("Prisma client missing or placeholder detected. Running prisma generate...");
|
||||
|
||||
const command = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
const result = spawnSync(command, ["prisma", "generate"], {
|
||||
cwd: backendRoot,
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
"use client";
|
||||
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import SearchField from "@/components/SearchField";
|
||||
import TablePagination from "@/components/TablePagination";
|
||||
import { logs as logsApi, type AuditLog } from "@/lib/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
Download,
|
||||
Server,
|
||||
Container,
|
||||
Globe,
|
||||
Shield,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const categoryIcons: Record<string, React.ElementType> = {
|
||||
container: Container,
|
||||
server: Server,
|
||||
ssl: Shield,
|
||||
security: Shield,
|
||||
domain: Globe,
|
||||
terminal: Terminal,
|
||||
auth: Shield,
|
||||
system: Activity,
|
||||
};
|
||||
|
||||
const typeColors: Record<string, { bg: string; color: string; label: string }> =
|
||||
{
|
||||
success: { bg: "rgba(16,185,129,0.1)", color: "#10b981", label: "Success" },
|
||||
info: { bg: "rgba(59,130,246,0.1)", color: "#3b82f6", label: "Info" },
|
||||
warning: { bg: "rgba(245,158,11,0.1)", color: "#f59e0b", label: "Warning" },
|
||||
error: { bg: "rgba(239,68,68,0.1)", color: "#ef4444", label: "Error" },
|
||||
};
|
||||
|
||||
const levelMap: Record<string, string | undefined> = {
|
||||
all: undefined,
|
||||
success: "SUCCESS",
|
||||
info: "INFO",
|
||||
warning: "WARNING",
|
||||
error: "ERROR",
|
||||
};
|
||||
|
||||
const categoryMap: Record<string, string | undefined> = {
|
||||
all: undefined,
|
||||
auth: "AUTH",
|
||||
server: "SERVER",
|
||||
container: "CONTAINER",
|
||||
domain: "DOMAIN",
|
||||
ssl: "SSL",
|
||||
security: "SECURITY",
|
||||
system: "SYSTEM",
|
||||
terminal: "TERMINAL",
|
||||
};
|
||||
|
||||
function formatTimestamp(value: string) {
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "Unknown time";
|
||||
}
|
||||
|
||||
return date.toLocaleString("id-ID", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLevel(level: string) {
|
||||
return level.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeCategory(category: string) {
|
||||
return category.toLowerCase();
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [search, typeFilter, categoryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadLogs() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await logsApi.list({
|
||||
search: search.trim() || undefined,
|
||||
level: levelMap[typeFilter],
|
||||
category: categoryMap[categoryFilter],
|
||||
limit: PAGE_SIZE,
|
||||
offset: (currentPage - 1) * PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setLogs(response.data ?? []);
|
||||
setTotalItems(response.total ?? 0);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
setLogs([]);
|
||||
setTotalItems(0);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load activity logs",
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadLogs();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [search, typeFilter, categoryFilter, currentPage]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
|
||||
const startItem = totalItems === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1;
|
||||
const endItem =
|
||||
totalItems === 0 ? 0 : Math.min(currentPage * PAGE_SIZE, totalItems);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Activity Log"
|
||||
subtitle="Complete audit trail of system events and actions"
|
||||
>
|
||||
<div
|
||||
className="animate-slide-in"
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<SearchField
|
||||
placeholder="Search activity..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
containerStyle={{ flex: "1 1 280px", minWidth: 220, maxWidth: 420 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
background: "var(--bg-input)",
|
||||
borderRadius: 8,
|
||||
padding: 3,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{["all", "success", "info", "warning", "error"].map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setTypeFilter(f)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background:
|
||||
typeFilter === f ? "var(--bg-card)" : "transparent",
|
||||
color:
|
||||
typeFilter === f
|
||||
? typeColors[f]?.color || "var(--text-primary)"
|
||||
: "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
background: "var(--bg-input)",
|
||||
borderRadius: 8,
|
||||
padding: 3,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{[
|
||||
"all",
|
||||
"auth",
|
||||
"container",
|
||||
"server",
|
||||
"domain",
|
||||
"ssl",
|
||||
"security",
|
||||
"system",
|
||||
"terminal",
|
||||
].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCategoryFilter(c)}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background:
|
||||
categoryFilter === c ? "var(--bg-card)" : "transparent",
|
||||
color:
|
||||
categoryFilter === c
|
||||
? "var(--text-primary)"
|
||||
: "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginLeft: "auto", fontSize: 12 }}
|
||||
>
|
||||
<Download size={12} /> Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Log table */}
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 160 }}>Time</th>
|
||||
<th style={{ width: 90 }}>Status</th>
|
||||
<th style={{ width: 100 }}>Category</th>
|
||||
<th style={{ width: 150 }}>Server</th>
|
||||
<th>Message</th>
|
||||
<th style={{ width: 80 }}>User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
style={{
|
||||
padding: "24px 16px",
|
||||
textAlign: "center",
|
||||
fontSize: 13,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
Loading activity logs...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
style={{
|
||||
padding: "24px 16px",
|
||||
textAlign: "center",
|
||||
fontSize: 13,
|
||||
color: "#ef4444",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && !error && logs.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
style={{
|
||||
padding: "24px 16px",
|
||||
textAlign: "center",
|
||||
fontSize: 13,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
No activity logs found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
logs.map((log) => {
|
||||
const normalizedType = normalizeLevel(log.level);
|
||||
const normalizedCategory = normalizeCategory(log.category);
|
||||
const type = typeColors[normalizedType] ?? typeColors.info;
|
||||
const CatIcon =
|
||||
categoryIcons[normalizedCategory] || Activity;
|
||||
return (
|
||||
<tr key={log.id}>
|
||||
<td
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
color: "var(--text-muted)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Clock
|
||||
size={10}
|
||||
style={{
|
||||
display: "inline",
|
||||
marginRight: 5,
|
||||
verticalAlign: "middle",
|
||||
}}
|
||||
/>
|
||||
{formatTimestamp(log.createdAt)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
background: type.bg,
|
||||
color: type.color,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 5,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{type.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<CatIcon
|
||||
size={12}
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-secondary)",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{normalizedCategory}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
background: "rgba(59,130,246,0.08)",
|
||||
color: "#3b82f6",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 5,
|
||||
border: "1px solid rgba(59,130,246,0.2)",
|
||||
}}
|
||||
>
|
||||
{log.server?.name ?? "System"}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color:
|
||||
normalizedType === "error"
|
||||
? "#ef4444"
|
||||
: normalizedType === "warning"
|
||||
? "#f59e0b"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{log.message}
|
||||
</td>
|
||||
<td
|
||||
style={{ fontSize: 11, color: "var(--text-muted)" }}
|
||||
>
|
||||
{log.user?.name ?? "system"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startItem={startItem}
|
||||
endItem={endItem}
|
||||
itemLabel="events"
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{/* <span style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{totalItems === 0
|
||||
? "Showing 0 of 0 events"
|
||||
: `Showing ${startItem}-${endItem} of ${totalItems} events`}
|
||||
</span> */}
|
||||
<div
|
||||
className="animate-pulse-dot"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 11,
|
||||
color: "#10b981",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: "#10b981",
|
||||
}}
|
||||
/>
|
||||
Live monitoring active
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getGitProviderCallbackIntentCookieName } from "@/lib/git-provider-callback-intent";
|
||||
import { resolvePublicAppUrl } from "@/server/lib/public-url";
|
||||
|
||||
type GitCallbackProvider = "github" | "gitlab" | "gitea";
|
||||
|
||||
export function buildCallbackRedirect(
|
||||
request: NextRequest,
|
||||
provider: GitCallbackProvider,
|
||||
) {
|
||||
const redirectUrl = new URL(
|
||||
resolvePublicAppUrl("/git", {
|
||||
headers: request.headers,
|
||||
protocol: request.nextUrl.protocol,
|
||||
}),
|
||||
);
|
||||
const error = request.nextUrl.searchParams.get("error");
|
||||
const errorDescription =
|
||||
request.nextUrl.searchParams.get("error_description") ||
|
||||
request.nextUrl.searchParams.get("error_reason");
|
||||
const code = request.nextUrl.searchParams.get("code");
|
||||
const returnedState = request.nextUrl.searchParams.get("state");
|
||||
const cookieName = getGitProviderCallbackIntentCookieName(provider);
|
||||
const pendingIntent = request.cookies.get(cookieName)?.value ?? null;
|
||||
|
||||
redirectUrl.searchParams.set("callbackProvider", provider);
|
||||
|
||||
const response = (status: "success" | "error" | "info", message: string) => {
|
||||
redirectUrl.searchParams.set("callbackStatus", status);
|
||||
redirectUrl.searchParams.set("callbackMessage", message);
|
||||
|
||||
const nextResponse = NextResponse.redirect(redirectUrl);
|
||||
nextResponse.cookies.set(cookieName, "", {
|
||||
path: `/api/providers/${provider}/callback`,
|
||||
maxAge: 0,
|
||||
sameSite: "lax",
|
||||
secure: request.nextUrl.protocol === "https:",
|
||||
});
|
||||
return nextResponse;
|
||||
};
|
||||
|
||||
if (error || code) {
|
||||
if (!pendingIntent) {
|
||||
return response(
|
||||
"error",
|
||||
`The ${provider} callback was not initiated from the current Doktainer session. Start the provider flow again before trusting this response.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (returnedState && returnedState !== pendingIntent) {
|
||||
return response(
|
||||
"error",
|
||||
`The ${provider} callback state is invalid or has expired. Start the provider flow again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return response(
|
||||
"error",
|
||||
errorDescription || `The ${provider} callback returned ${error}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (code) {
|
||||
return response(
|
||||
"success",
|
||||
`The ${provider} callback URL is active and authorization returned successfully. You can continue saving the provider configuration in Doktainer.`,
|
||||
);
|
||||
}
|
||||
|
||||
return response(
|
||||
"info",
|
||||
`The ${provider} callback URL is reachable. Complete the provider setup, then return to Doktainer and save the generated credentials.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { buildCallbackRedirect } from "../../callback-intent";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return buildCallbackRedirect(request, "gitea");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { buildCallbackRedirect } from "../../callback-intent";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return buildCallbackRedirect(request, "github");
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const GITHUB_ACCEPT = "application/vnd.github+json";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
code?: string;
|
||||
} | null;
|
||||
const code = body?.code?.trim();
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "GitHub manifest code is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: GITHUB_ACCEPT,
|
||||
"User-Agent": "Doktainer",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
if (!response.ok || !payload) {
|
||||
const message =
|
||||
typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: "Failed to convert GitHub App manifest";
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: response.status || 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = {
|
||||
id: payload.id,
|
||||
slug: payload.slug,
|
||||
clientId: payload.client_id,
|
||||
clientSecret: payload.client_secret,
|
||||
webhookSecret: payload.webhook_secret,
|
||||
pem: payload.pem,
|
||||
htmlUrl: payload.html_url,
|
||||
name: payload.name,
|
||||
};
|
||||
|
||||
return NextResponse.json({ success: true, data });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "GitHub webhook endpoint is reachable.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const event = request.headers.get("x-github-event") || "unknown";
|
||||
return NextResponse.json({ success: true, received: true, event });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { buildCallbackRedirect } from "../../callback-intent";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return buildCallbackRedirect(request, "gitlab");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ContainerStatCardProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
subvalue?: string;
|
||||
}
|
||||
|
||||
export default function ContainerStatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
subvalue,
|
||||
}: ContainerStatCardProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-input)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ color: "var(--accent)" }}>{icon}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{ color: "var(--text-primary)", fontSize: 18, fontWeight: 700 }}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{subvalue ? (
|
||||
<div style={{ color: "var(--text-muted)", fontSize: 11, marginTop: 4 }}>
|
||||
{subvalue}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { HardDrive } from "lucide-react";
|
||||
import type { Server } from "@/lib/api";
|
||||
|
||||
interface ContainersServerFilterProps {
|
||||
serverList: Server[];
|
||||
selectedServerId: string;
|
||||
onChange: (serverId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function getButtonStyle(isSelected: boolean) {
|
||||
return {
|
||||
padding: "5px 14px",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: isSelected ? "rgba(59,130,246,0.15)" : "var(--bg-input)",
|
||||
color: isSelected ? "#3b82f6" : "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
outline: isSelected
|
||||
? "1px solid rgba(59,130,246,0.3)"
|
||||
: "1px solid var(--border)",
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default function ContainersServerFilter({
|
||||
serverList,
|
||||
selectedServerId,
|
||||
onChange,
|
||||
}: ContainersServerFilterProps) {
|
||||
return (
|
||||
<div
|
||||
className="card ui-responsive-toolbar"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
}}
|
||||
>
|
||||
<div className="ui-inline-cluster">
|
||||
<HardDrive size={14} style={{ color: "var(--text-muted)" }} />
|
||||
<span
|
||||
className="ui-inline-label"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Server:
|
||||
</span>
|
||||
<div className="ui-chip-scroll no-scrollbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onChange("")}
|
||||
style={getButtonStyle(selectedServerId === "")}
|
||||
>
|
||||
All Servers
|
||||
</button>
|
||||
{serverList.map((server) => (
|
||||
<button
|
||||
type="button"
|
||||
key={server.id}
|
||||
onClick={() => void onChange(server.id)}
|
||||
style={getButtonStyle(selectedServerId === server.id)}
|
||||
>
|
||||
{server.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
interface ContainersSummaryProps {
|
||||
total: number;
|
||||
running: number;
|
||||
stopped: number;
|
||||
pausedAndError: number;
|
||||
}
|
||||
|
||||
export default function ContainersSummary({
|
||||
total,
|
||||
running,
|
||||
stopped,
|
||||
pausedAndError,
|
||||
}: ContainersSummaryProps) {
|
||||
const stats = [
|
||||
{ label: "Total", value: total, color: "#3b82f6" },
|
||||
{ label: "Running", value: running, color: "#10b981" },
|
||||
{ label: "Stopped", value: stopped, color: "#64748b" },
|
||||
{ label: "Paused / Error", value: pausedAndError, color: "#ef4444" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="card"
|
||||
style={{
|
||||
padding: "14px 18px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 36,
|
||||
borderRadius: 3,
|
||||
background: stat.color,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{stat.value}
|
||||
</p>
|
||||
<p
|
||||
style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 2 }}
|
||||
>
|
||||
{stat.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import SearchField from "@/components/SearchField";
|
||||
import { Loader2, Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
interface ContainersToolbarProps {
|
||||
search: string;
|
||||
statusFilter: string;
|
||||
syncing: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusFilterChange: (value: string) => void;
|
||||
onSync: () => void | Promise<void>;
|
||||
onDeploy: () => void;
|
||||
}
|
||||
|
||||
export default function ContainersToolbar({
|
||||
search,
|
||||
statusFilter,
|
||||
syncing,
|
||||
onSearchChange,
|
||||
onStatusFilterChange,
|
||||
onSync,
|
||||
onDeploy,
|
||||
}: ContainersToolbarProps) {
|
||||
return (
|
||||
<div
|
||||
className="card ui-responsive-toolbar"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
}}
|
||||
>
|
||||
<SearchField
|
||||
placeholder="Search containers..."
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
containerStyle={{ flex: "1 1 320px", minWidth: 220 }}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={statusFilter}
|
||||
onChange={(event) => onStatusFilterChange(event.target.value)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
width: 130,
|
||||
maxWidth: "100%",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<option value="ALL">All Status</option>
|
||||
{["RUNNING", "STOPPED", "STARTING", "ERROR"].map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status.charAt(0) + status.slice(1).toLowerCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="ui-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => void onSync()}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
Sync from Docker
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={onDeploy}
|
||||
>
|
||||
<Plus size={12} /> Deploy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { AlertTriangle, Clock, Square, XCircle } from "lucide-react";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }: StatusBadgeProps) {
|
||||
const cfg: Record<
|
||||
string,
|
||||
{ bg: string; color: string; border: string; label: string }
|
||||
> = {
|
||||
RUNNING: {
|
||||
bg: "rgba(16,185,129,0.1)",
|
||||
color: "#10b981",
|
||||
border: "rgba(16,185,129,0.3)",
|
||||
label: "Running",
|
||||
},
|
||||
STOPPED: {
|
||||
bg: "rgba(100,116,139,0.1)",
|
||||
color: "#64748b",
|
||||
border: "rgba(100,116,139,0.3)",
|
||||
label: "Stopped",
|
||||
},
|
||||
STARTING: {
|
||||
bg: "rgba(245,158,11,0.1)",
|
||||
color: "#f59e0b",
|
||||
border: "rgba(245,158,11,0.3)",
|
||||
label: "Starting",
|
||||
},
|
||||
STOPPING: {
|
||||
bg: "rgba(249,115,22,0.1)",
|
||||
color: "#f97316",
|
||||
border: "rgba(249,115,22,0.3)",
|
||||
label: "Stopping",
|
||||
},
|
||||
PAUSED: {
|
||||
bg: "rgba(168,85,247,0.1)",
|
||||
color: "#a855f7",
|
||||
border: "rgba(168,85,247,0.3)",
|
||||
label: "Paused",
|
||||
},
|
||||
ERROR: {
|
||||
bg: "rgba(239,68,68,0.1)",
|
||||
color: "#ef4444",
|
||||
border: "rgba(239,68,68,0.3)",
|
||||
label: "Error",
|
||||
},
|
||||
};
|
||||
|
||||
const current = cfg[status] || cfg.STOPPED;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
background: current.bg,
|
||||
color: current.color,
|
||||
border: `1px solid ${current.border}`,
|
||||
padding: "3px 10px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
{status === "RUNNING" && (
|
||||
<span
|
||||
style={{
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: "50%",
|
||||
background: "currentColor",
|
||||
display: "inline-block",
|
||||
}}
|
||||
className="animate-pulse-dot"
|
||||
/>
|
||||
)}
|
||||
{status === "STOPPED" && <XCircle size={10} />}
|
||||
{status === "STARTING" && <Clock size={10} />}
|
||||
{status === "STOPPING" && <Clock size={10} />}
|
||||
{status === "PAUSED" && <Square size={10} />}
|
||||
{status === "ERROR" && <AlertTriangle size={10} />}
|
||||
{current.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Cpu, Database, HardDrive, Loader2, Network, X } from "lucide-react";
|
||||
import {
|
||||
containers as containersApi,
|
||||
type Container,
|
||||
type ContainerDetails,
|
||||
} from "@/lib/api";
|
||||
import ContainerStatCard from "../ContainerStatCard";
|
||||
import StatusBadge from "../StatusBadge";
|
||||
import { formatBytes } from "../../utils/container-utils";
|
||||
|
||||
interface ContainerDetailsModalProps {
|
||||
container: Container;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DetailTab = "overview" | "logs" | "inspect" | "processes";
|
||||
|
||||
export default function ContainerDetailsModal({
|
||||
container,
|
||||
onClose,
|
||||
}: ContainerDetailsModalProps) {
|
||||
const [detail, setDetail] = useState<ContainerDetails | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("overview");
|
||||
const contentViewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
containersApi
|
||||
.details(container.id, 300)
|
||||
.then((response) => setDetail(response.data ?? null))
|
||||
.catch((err: unknown) => {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load container details",
|
||||
);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [container.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || activeTab !== "logs") return;
|
||||
const viewport = contentViewportRef.current;
|
||||
if (!viewport) return;
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
}, [activeTab, detail?.logs, loading]);
|
||||
|
||||
const tabs: Array<{ id: DetailTab; label: string }> = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "logs", label: "Logs" },
|
||||
{ id: "inspect", label: "Inspect" },
|
||||
{ id: "processes", label: "Processes" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div
|
||||
className="modal animate-slide-in"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 1080,
|
||||
maxHeight: "90vh",
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "18px 22px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<h3
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{container.name}
|
||||
</h3>
|
||||
<StatusBadge
|
||||
status={detail?.container.status ?? container.status}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
style={{ color: "var(--text-muted)", fontSize: 12, marginTop: 6 }}
|
||||
>
|
||||
{container.image}
|
||||
{detail?.server
|
||||
? ` • ${detail.server.name} (${detail.server.ip})`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
padding: "14px 22px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
border:
|
||||
activeTab === tab.id
|
||||
? "1px solid rgba(59,130,246,0.35)"
|
||||
: "1px solid transparent",
|
||||
background:
|
||||
activeTab === tab.id ? "rgba(59,130,246,0.12)" : undefined,
|
||||
color: activeTab === tab.id ? "#3b82f6" : undefined,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={contentViewportRef}
|
||||
style={{ flex: 1, overflow: "auto", padding: 22 }}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ padding: 48, textAlign: "center" }}>
|
||||
<Loader2
|
||||
size={28}
|
||||
className="animate-spin"
|
||||
style={{ color: "var(--accent)", margin: "0 auto 12px" }}
|
||||
/>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
|
||||
Loading container details...
|
||||
</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
color: "#ef4444",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : detail ? (
|
||||
<>
|
||||
{activeTab === "overview" ? (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns:
|
||||
"repeat(auto-fill, minmax(min(220px, 100%), 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<ContainerStatCard
|
||||
icon={<Cpu size={15} />}
|
||||
label="CPU Usage"
|
||||
value={`${detail.stats.cpuPercent.toFixed(2)}%`}
|
||||
subvalue={`Container processes: ${detail.stats.pids}`}
|
||||
/>
|
||||
<ContainerStatCard
|
||||
icon={<HardDrive size={15} />}
|
||||
label="Memory Usage"
|
||||
value={detail.stats.memory.used || "—"}
|
||||
subvalue={
|
||||
detail.stats.memory.limit
|
||||
? `of ${detail.stats.memory.limit} • ${detail.stats.memoryPercent.toFixed(2)}%`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ContainerStatCard
|
||||
icon={<Network size={15} />}
|
||||
label="Network I/O"
|
||||
value={detail.stats.network.raw || "—"}
|
||||
subvalue={
|
||||
detail.stats.network.totalBytes !== null &&
|
||||
detail.stats.network.totalBytes !== undefined
|
||||
? `Aggregate ${formatBytes(detail.stats.network.totalBytes)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ContainerStatCard
|
||||
icon={<Database size={15} />}
|
||||
label="Block I/O"
|
||||
value={detail.stats.io.raw || "—"}
|
||||
subvalue={
|
||||
detail.stats.io.totalBytes !== null &&
|
||||
detail.stats.io.totalBytes !== undefined
|
||||
? `Aggregate ${formatBytes(detail.stats.io.totalBytes)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns:
|
||||
"repeat(auto-fill, minmax(min(380px, 100%), 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{ padding: 16, background: "var(--bg-input)" }}
|
||||
>
|
||||
<h4
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Memory
|
||||
</h4>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Used</span>
|
||||
<span>{detail.stats.memory.used || "—"}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Limit</span>
|
||||
<span>{detail.stats.memory.limit || "—"}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Usage Percent</span>
|
||||
<span>{detail.stats.memoryPercent.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="card"
|
||||
style={{ padding: 16, background: "var(--bg-input)" }}
|
||||
>
|
||||
<h4
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Runtime Identity
|
||||
</h4>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Docker ID</span>
|
||||
<span style={{ fontFamily: "monospace" }}>
|
||||
{detail.container.dockerId || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Server</span>
|
||||
<span>{detail.server.name}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>IP</span>
|
||||
<span style={{ fontFamily: "monospace" }}>
|
||||
{detail.server.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Processes</span>
|
||||
<span>{detail.processes.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns:
|
||||
"repeat(auto-fill, minmax(min(380px, 100%), 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{ padding: 16, background: "var(--bg-input)" }}
|
||||
>
|
||||
<h4
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Network Usage
|
||||
</h4>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Received</span>
|
||||
<span>{detail.stats.network.read || "—"}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Transmitted</span>
|
||||
<span>{detail.stats.network.write || "—"}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Aggregate</span>
|
||||
<span>
|
||||
{formatBytes(detail.stats.network.totalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="card"
|
||||
style={{ padding: 16, background: "var(--bg-input)" }}
|
||||
>
|
||||
<h4
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Block I/O
|
||||
</h4>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Read</span>
|
||||
<span>{detail.stats.io.read || "—"}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Write</span>
|
||||
<span>{detail.stats.io.write || "—"}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>Aggregate</span>
|
||||
<span>{formatBytes(detail.stats.io.totalBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === "logs" ? (
|
||||
<div
|
||||
style={{
|
||||
background: "#0d1117",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
padding: 16,
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: "#e6edf3",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
minHeight: 420,
|
||||
}}
|
||||
>
|
||||
{detail.logs || "(no output)"}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === "inspect" ? (
|
||||
<div
|
||||
style={{
|
||||
background: "#0d1117",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
padding: 16,
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: "#e6edf3",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
minHeight: 420,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(detail.inspect, null, 2)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === "processes" ? (
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PID</th>
|
||||
<th>PPID</th>
|
||||
<th>User</th>
|
||||
<th>CPU</th>
|
||||
<th>MEM</th>
|
||||
<th>Elapsed</th>
|
||||
<th>Command</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.processes.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
No process data available.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
detail.processes.map((process, index) => (
|
||||
<tr key={`${process.pid}-${index}`}>
|
||||
<td style={{ fontFamily: "monospace" }}>
|
||||
{process.pid}
|
||||
</td>
|
||||
<td style={{ fontFamily: "monospace" }}>
|
||||
{process.ppid}
|
||||
</td>
|
||||
<td>{process.user}</td>
|
||||
<td>{process.cpu}</td>
|
||||
<td>{process.memory}</td>
|
||||
<td>{process.elapsed}</td>
|
||||
<td
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{process.command}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ScrollText, X } from "lucide-react";
|
||||
import { containers as containersApi, type Container } from "@/lib/api";
|
||||
import { sanitizeLogText } from "@/lib/terminal-output";
|
||||
|
||||
interface ContainerLogsModalProps {
|
||||
container: Container;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContainerLogsModal({
|
||||
container,
|
||||
onClose,
|
||||
}: ContainerLogsModalProps) {
|
||||
const [logs, setLogs] = useState("Loading...");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
containersApi
|
||||
.logs(container.id, 200)
|
||||
.then((response) =>
|
||||
setLogs(sanitizeLogText(response.data?.logs || "(no output)")),
|
||||
)
|
||||
.catch((err) =>
|
||||
setLogs(
|
||||
sanitizeLogText(
|
||||
"Error: " + (err instanceof Error ? err.message : "Failed"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, [container.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
const scrollFrame = window.requestAnimationFrame(() => {
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(scrollFrame);
|
||||
}, [logs, loading]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div
|
||||
className="modal animate-slide-in"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 760,
|
||||
padding: 0,
|
||||
maxHeight: "85vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "16px 20px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<ScrollText size={16} style={{ color: "var(--accent)" }} />
|
||||
<h3
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontWeight: 700,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
Logs - {container.name}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "16px 20px",
|
||||
background: "#0d1117",
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: "#e6edf3",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span style={{ color: "#8b949e" }}>Loading...</span>
|
||||
) : (
|
||||
logs
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
ProcessLogStep,
|
||||
ProcessLogsModalState,
|
||||
} from "@/components/ProcessLogsModal";
|
||||
|
||||
type ProcessLogOpenHandler = (state: ProcessLogsModalState) => void;
|
||||
type ProcessLogUpdateHandler = (state: Partial<ProcessLogsModalState>) => void;
|
||||
|
||||
const deployTimelineSteps = [
|
||||
{ id: "validate", label: "Validating deployment input", progress: 10 },
|
||||
{ id: "prepare", label: "Preparing deployment payload", progress: 25 },
|
||||
{
|
||||
id: "deploy",
|
||||
label: "Waiting for deployment process",
|
||||
progress: "Running",
|
||||
},
|
||||
{ id: "sync", label: "Syncing container inventory", progress: 85 },
|
||||
{ id: "complete", label: "Deployment completed", progress: 100 },
|
||||
];
|
||||
|
||||
function createDeployTimeline(activeStep: number): ProcessLogStep[] {
|
||||
return deployTimelineSteps.map((step, index) => ({
|
||||
...step,
|
||||
status:
|
||||
index < activeStep
|
||||
? ("success" as const)
|
||||
: index === activeStep
|
||||
? ("running" as const)
|
||||
: ("pending" as const),
|
||||
}));
|
||||
}
|
||||
|
||||
function createFailedDeployTimeline(activeStep: number): ProcessLogStep[] {
|
||||
return createDeployTimeline(activeStep).map((step, index) =>
|
||||
index === activeStep
|
||||
? { ...step, status: "error" as const }
|
||||
: index < activeStep
|
||||
? { ...step, status: "success" as const }
|
||||
: { ...step, status: "pending" as const },
|
||||
);
|
||||
}
|
||||
|
||||
function createCompletedDeployTimeline(): ProcessLogStep[] {
|
||||
return createDeployTimeline(deployTimelineSteps.length - 1).map((step) => ({
|
||||
...step,
|
||||
status: "success" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export function openDeployTimelineModal({
|
||||
onProcessOpen,
|
||||
name,
|
||||
terminalLogs,
|
||||
}: {
|
||||
onProcessOpen?: ProcessLogOpenHandler;
|
||||
name: string;
|
||||
terminalLogs: string[];
|
||||
}) {
|
||||
onProcessOpen?.({
|
||||
title: `Deployment Logs - ${name || "New Container"}`,
|
||||
description:
|
||||
"Deployment progress, inventory sync, and terminal-style output for this request.",
|
||||
imageUrl: "/assets/images/img-chibi-fixing.png",
|
||||
imageAlt: "Illustration of a character fixing something",
|
||||
timelineLogs: createDeployTimeline(0),
|
||||
terminalLogs,
|
||||
initialTab: "timeline",
|
||||
statusLabel: "Starting",
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDeployTimelineModal({
|
||||
onProcessUpdate,
|
||||
activeStep,
|
||||
terminalLogs,
|
||||
statusLabel,
|
||||
}: {
|
||||
onProcessUpdate?: ProcessLogUpdateHandler;
|
||||
activeStep: number;
|
||||
terminalLogs: string[];
|
||||
statusLabel: string;
|
||||
}) {
|
||||
onProcessUpdate?.({
|
||||
timelineLogs: createDeployTimeline(activeStep),
|
||||
terminalLogs,
|
||||
statusLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function createDeployTimelineUpdate({
|
||||
activeStep,
|
||||
statusLabel,
|
||||
}: {
|
||||
activeStep: number;
|
||||
statusLabel: string;
|
||||
}): Partial<ProcessLogsModalState> {
|
||||
return {
|
||||
timelineLogs: createDeployTimeline(activeStep),
|
||||
statusLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function completeDeployTimelineModal({
|
||||
onProcessUpdate,
|
||||
terminalLogs,
|
||||
}: {
|
||||
onProcessUpdate?: ProcessLogUpdateHandler;
|
||||
terminalLogs: string[];
|
||||
}) {
|
||||
onProcessUpdate?.({
|
||||
timelineLogs: createCompletedDeployTimeline(),
|
||||
terminalLogs,
|
||||
statusLabel: "100%",
|
||||
});
|
||||
}
|
||||
|
||||
export function failDeployTimelineModal({
|
||||
onProcessUpdate,
|
||||
name,
|
||||
message,
|
||||
}: {
|
||||
onProcessUpdate?: ProcessLogUpdateHandler;
|
||||
name: string;
|
||||
message: string;
|
||||
}) {
|
||||
onProcessUpdate?.({
|
||||
timelineLogs: createFailedDeployTimeline(2),
|
||||
terminalLogs: [
|
||||
`[deploy] Deployment failed for ${name || "new container"}`,
|
||||
`[error] ${message}`,
|
||||
],
|
||||
statusLabel: "Failed",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import TablePagination from "@/components/TablePagination";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Container as ContainerIcon,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Play,
|
||||
RotateCcw,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
Square,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { Container } from "@/lib/api";
|
||||
import StatusBadge from "../StatusBadge";
|
||||
|
||||
function extractPublishedPort(portMapping: string): string | null {
|
||||
const normalized = portMapping.trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
const publishedSegment = normalized.includes("->")
|
||||
? normalized.split("->")[0]?.trim()
|
||||
: normalized;
|
||||
|
||||
if (!publishedSegment) return null;
|
||||
|
||||
const match =
|
||||
publishedSegment.match(/:(\d+)$/) ?? publishedSegment.match(/^(\d+)$/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function getContainerWebUiUrl(container: Container): string | null {
|
||||
const serverIp = container.server?.ip?.trim();
|
||||
if (!serverIp || !container.ports?.length) return null;
|
||||
|
||||
const publishedPort = container.ports
|
||||
.map(extractPublishedPort)
|
||||
.find((port): port is string => Boolean(port));
|
||||
|
||||
return publishedPort ? `http://${serverIp}:${publishedPort}` : null;
|
||||
}
|
||||
|
||||
interface ContainersTableProps {
|
||||
loading: boolean;
|
||||
items: Container[];
|
||||
paginatedItems: Container[];
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
startItem: number;
|
||||
endItem: number;
|
||||
acting: Record<string, boolean>;
|
||||
openMenuId: string | null;
|
||||
onPageChange: (page: number) => void;
|
||||
onAction: (
|
||||
id: string,
|
||||
action: "start" | "stop" | "restart" | "delete",
|
||||
) => void;
|
||||
onOpenDetails: (container: Container) => void;
|
||||
onOpenLogs: (container: Container) => void;
|
||||
onOpenFileManager: (container: Container) => void;
|
||||
onRebuild: (container: Container) => void;
|
||||
onMenuToggle: (containerId: string) => void;
|
||||
onMenuClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContainersTable({
|
||||
loading,
|
||||
items,
|
||||
paginatedItems,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
startItem,
|
||||
endItem,
|
||||
acting,
|
||||
openMenuId,
|
||||
onPageChange,
|
||||
onAction,
|
||||
onOpenDetails,
|
||||
onOpenLogs,
|
||||
onOpenFileManager,
|
||||
onRebuild,
|
||||
onMenuToggle,
|
||||
onMenuClose,
|
||||
}: ContainersTableProps) {
|
||||
const [menuPosition, setMenuPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleMenuToggle = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>, containerId: string) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const menuWidth = 164;
|
||||
const menuHeight = 112;
|
||||
const nextLeft = Math.min(
|
||||
window.innerWidth - menuWidth - 12,
|
||||
Math.max(12, rect.right - menuWidth + rect.width),
|
||||
);
|
||||
const nextTop = Math.min(
|
||||
window.innerHeight - menuHeight - 12,
|
||||
rect.bottom + 6,
|
||||
);
|
||||
|
||||
setMenuPosition({ top: Math.max(12, nextTop), left: nextLeft });
|
||||
onMenuToggle(containerId);
|
||||
},
|
||||
[onMenuToggle],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 48, textAlign: "center" }}>
|
||||
<Loader2
|
||||
size={28}
|
||||
className="animate-spin"
|
||||
style={{ color: "var(--accent)", margin: "0 auto 12px" }}
|
||||
/>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
|
||||
Loading containers...
|
||||
</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={{ padding: 48, textAlign: "center" }}>
|
||||
<ContainerIcon
|
||||
size={36}
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
margin: "0 auto 12px",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
|
||||
No containers found.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Container</th>
|
||||
<th>Server</th>
|
||||
<th>Status</th>
|
||||
<th>Ports</th>
|
||||
<th style={{ textAlign: "right" }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedItems.map((container) => (
|
||||
<tr key={container.id}>
|
||||
{(() => {
|
||||
const webUiUrl = getContainerWebUiUrl(container);
|
||||
const canRebuild =
|
||||
container.sourceType === "APP_INSTALLER" ||
|
||||
container.sourceType === "GIT_CLONE" ||
|
||||
container.sourceType === "GIT_PROVIDER";
|
||||
const rebuildDisabled =
|
||||
!canRebuild || acting[container.id + "rebuild"];
|
||||
const rebuildTitle = !canRebuild
|
||||
? "Rebuild hanya tersedia untuk container App Installer atau Git/Repo"
|
||||
: "Rebuild container";
|
||||
|
||||
return (
|
||||
<>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: "var(--bg-input)",
|
||||
border: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ContainerIcon
|
||||
size={14}
|
||||
style={{ color: "#3b82f6" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{container.name}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{container.image}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{container.server?.name ?? "—"}
|
||||
{container.server?.ip ? (
|
||||
<>
|
||||
<br />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{container.server.ip}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={container.status} />
|
||||
</td>
|
||||
<td>
|
||||
{container.ports && container.ports.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{container.ports
|
||||
.slice(0, 3)
|
||||
.map((port, index) => (
|
||||
<span
|
||||
key={`${container.id}-${port}-${index}`}
|
||||
style={{
|
||||
background: "rgba(59,130,246,0.1)",
|
||||
color: "#3b82f6",
|
||||
padding: "1px 6px",
|
||||
borderRadius: 4,
|
||||
fontSize: 10,
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{port}
|
||||
</span>
|
||||
))}
|
||||
{container.ports.length > 3 ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
+{container.ports.length - 3}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{webUiUrl ? (
|
||||
<a
|
||||
href={webUiUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "5px 8px" }}
|
||||
title={`Open WebUI (${webUiUrl.replace("http://", "")})`}
|
||||
aria-label={`Open WebUI ${webUiUrl.replace("http://", "")}`}
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
padding: "5px 8px",
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
}}
|
||||
title="No published host port available"
|
||||
disabled
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
</button>
|
||||
)}
|
||||
{container.status === "STOPPED" ||
|
||||
container.status === "ERROR" ? (
|
||||
<button
|
||||
title="Start"
|
||||
onClick={() =>
|
||||
onAction(container.id, "start")
|
||||
}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
fontSize: 11,
|
||||
color: "#10b981",
|
||||
}}
|
||||
disabled={acting[container.id + "start"]}
|
||||
>
|
||||
{acting[container.id + "start"] ? (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<Play size={12} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
title="Stop"
|
||||
onClick={() => onAction(container.id, "stop")}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 8px", fontSize: 11 }}
|
||||
disabled={acting[container.id + "stop"]}
|
||||
>
|
||||
{acting[container.id + "stop"] ? (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<Square size={12} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
title="Restart"
|
||||
onClick={() =>
|
||||
onAction(container.id, "restart")
|
||||
}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 8px", fontSize: 11 }}
|
||||
disabled={acting[container.id + "restart"]}
|
||||
>
|
||||
{acting[container.id + "restart"] ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={12} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
title="Details"
|
||||
onClick={() => onOpenDetails(container)}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 8px", fontSize: 11 }}
|
||||
>
|
||||
<Eye size={12} />
|
||||
</button>
|
||||
<button
|
||||
title="Logs"
|
||||
onClick={() => {
|
||||
onMenuClose();
|
||||
onOpenLogs(container);
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 8px", fontSize: 11 }}
|
||||
>
|
||||
<ScrollText size={12} />
|
||||
</button>
|
||||
<div
|
||||
style={{ position: "relative" }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
title="More actions"
|
||||
onClick={(event) =>
|
||||
handleMenuToggle(event, container.id)
|
||||
}
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 8px", fontSize: 11 }}
|
||||
>
|
||||
<MoreVertical size={12} />
|
||||
</button>
|
||||
{openMenuId === container.id && menuPosition ? (
|
||||
<div
|
||||
className="card"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: menuPosition.top,
|
||||
left: menuPosition.left,
|
||||
minWidth: 140,
|
||||
padding: 6,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
zIndex: 160,
|
||||
boxShadow:
|
||||
"0 18px 40px rgba(2, 6, 23, 0.42)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onMenuClose();
|
||||
onOpenFileManager(container);
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
justifyContent: "flex-start",
|
||||
}}
|
||||
disabled={container.status !== "RUNNING"}
|
||||
>
|
||||
<FolderOpen size={12} />
|
||||
File Manager
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onMenuClose();
|
||||
onRebuild(container);
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
justifyContent: "flex-start",
|
||||
}}
|
||||
title={rebuildTitle}
|
||||
disabled={rebuildDisabled}
|
||||
>
|
||||
{acting[container.id + "rebuild"] ? (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
Rebuild
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onMenuClose();
|
||||
onAction(container.id, "delete");
|
||||
}}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
justifyContent: "flex-start",
|
||||
color: "#ef4444",
|
||||
}}
|
||||
disabled={acting[container.id + "delete"]}
|
||||
>
|
||||
{acting[container.id + "delete"] ? (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<Trash2 size={12} />
|
||||
)}
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startItem={startItem}
|
||||
endItem={endItem}
|
||||
itemLabel="containers"
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import ProcessLogsModal, {
|
||||
type ProcessLogStep,
|
||||
useProcessLogsModal,
|
||||
} from "@/components/ProcessLogsModal";
|
||||
import ToastViewport from "@/components/ToastViewport";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
containers as containersApi,
|
||||
servers as serversApi,
|
||||
type Container,
|
||||
type Server,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
readCachedPageData,
|
||||
readStoredServerSelection,
|
||||
storeServerSelection,
|
||||
writeCachedPageData,
|
||||
} from "@/lib/page-state";
|
||||
import { useTablePagination } from "@/lib/use-table-pagination";
|
||||
import { useToastManager } from "@/lib/use-toast-manager";
|
||||
import ContainerDetailsModal from "./components/modals/ContainerDetailsModal";
|
||||
import ContainerFileManagerModal from "./components/modals/ContainerFileManagerModal";
|
||||
import ContainerLogsModal from "./components/modals/ContainerLogsModal";
|
||||
import ContainersServerFilter from "./components/ContainersServerFilter";
|
||||
import ContainersSummary from "./components/ContainersSummary";
|
||||
import ContainersTable from "./components/tables/ContainersTable";
|
||||
import ContainersToolbar from "./components/ContainersToolbar";
|
||||
import DeployContainerModal from "./components/modals/DeployContainerModal";
|
||||
|
||||
const PAGE_KEY = "containers";
|
||||
|
||||
type PendingConfirmAction = {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
tone?: "danger" | "warning" | "info";
|
||||
note?: string;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
function createRebuildTimeline(activeStep: number): ProcessLogStep[] {
|
||||
const steps = [
|
||||
{ id: "prepare", label: "Preparing rebuild request", progress: 10 },
|
||||
{
|
||||
id: "rebuild",
|
||||
label: "Waiting for rebuild process",
|
||||
progress: "Running",
|
||||
},
|
||||
{ id: "sync", label: "Syncing container inventory", progress: 85 },
|
||||
{ id: "complete", label: "Rebuild completed", progress: 100 },
|
||||
];
|
||||
|
||||
return steps.map((step, index) => ({
|
||||
...step,
|
||||
status:
|
||||
index < activeStep
|
||||
? ("success" as const)
|
||||
: index === activeStep
|
||||
? ("running" as const)
|
||||
: ("pending" as const),
|
||||
}));
|
||||
}
|
||||
|
||||
function createFailedRebuildTimeline(activeStep: number): ProcessLogStep[] {
|
||||
return createRebuildTimeline(activeStep).map((step, index) =>
|
||||
index === activeStep
|
||||
? { ...step, status: "error" as const }
|
||||
: index < activeStep
|
||||
? { ...step, status: "success" as const }
|
||||
: { ...step, status: "pending" as const },
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContainersPage() {
|
||||
const [data, setData] = useState<Container[]>([]);
|
||||
const [serverList, setServerList] = useState<Server[]>([]);
|
||||
const [selectedServerId, setSelectedServerId] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [showDeploy, setShowDeploy] = useState(false);
|
||||
const [logsFor, setLogsFor] = useState<Container | null>(null);
|
||||
const [detailFor, setDetailFor] = useState<Container | null>(null);
|
||||
const [fileManagerFor, setFileManagerFor] = useState<Container | null>(null);
|
||||
const [acting, setActing] = useState<Record<string, boolean>>({});
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] =
|
||||
useState<PendingConfirmAction | null>(null);
|
||||
const { toasts, pushToast, dismissToast } = useToastManager();
|
||||
const {
|
||||
modalState: processLogsState,
|
||||
isProcessLogsOpen,
|
||||
openProcessLogs,
|
||||
updateProcessLogs,
|
||||
closeProcessLogs,
|
||||
} = useProcessLogsModal();
|
||||
|
||||
const applyCachedState = useCallback((serverId: string) => {
|
||||
const cached = readCachedPageData<{
|
||||
items: Container[];
|
||||
serverList: Server[];
|
||||
}>(PAGE_KEY, serverId);
|
||||
|
||||
if (!cached) return false;
|
||||
|
||||
setData(cached.items);
|
||||
setServerList(cached.serverList);
|
||||
setLoading(false);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const load = useCallback(
|
||||
async (options?: {
|
||||
sync?: boolean;
|
||||
serverId?: string;
|
||||
silent?: boolean;
|
||||
}) => {
|
||||
if (!options?.silent) setLoading(true);
|
||||
|
||||
try {
|
||||
const resolvedServerId = options?.serverId ?? "";
|
||||
const serverResponse = await serversApi.list();
|
||||
const nextServers = serverResponse.data ?? [];
|
||||
|
||||
let containerResponse;
|
||||
|
||||
if (options?.sync) {
|
||||
try {
|
||||
containerResponse = await containersApi.sync(
|
||||
resolvedServerId ? { serverId: resolvedServerId } : undefined,
|
||||
);
|
||||
} catch {
|
||||
containerResponse = await containersApi.list(
|
||||
resolvedServerId ? { serverId: resolvedServerId } : undefined,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
containerResponse = await containersApi.list(
|
||||
resolvedServerId ? { serverId: resolvedServerId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const nextItems = containerResponse.data ?? [];
|
||||
setData(nextItems);
|
||||
setServerList(nextServers);
|
||||
setSelectedServerId(resolvedServerId);
|
||||
storeServerSelection(PAGE_KEY, resolvedServerId);
|
||||
writeCachedPageData(
|
||||
PAGE_KEY,
|
||||
{
|
||||
items: nextItems,
|
||||
serverList: nextServers,
|
||||
},
|
||||
resolvedServerId,
|
||||
);
|
||||
} catch {
|
||||
/* auth redirect handled */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const storedServerId = readStoredServerSelection(PAGE_KEY);
|
||||
setSelectedServerId(storedServerId);
|
||||
const hasCache = applyCachedState(storedServerId);
|
||||
void load({
|
||||
sync: true,
|
||||
serverId: storedServerId,
|
||||
silent: hasCache,
|
||||
});
|
||||
}, [applyCachedState, load]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowClick = () => setOpenMenuId(null);
|
||||
window.addEventListener("click", handleWindowClick);
|
||||
return () => window.removeEventListener("click", handleWindowClick);
|
||||
}, []);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await load({ sync: true, serverId: selectedServerId });
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerChange = async (serverId: string) => {
|
||||
if (serverId === selectedServerId) return;
|
||||
storeServerSelection(PAGE_KEY, serverId);
|
||||
setSelectedServerId(serverId);
|
||||
const hasCache = applyCachedState(serverId);
|
||||
await load({ sync: true, serverId, silent: hasCache });
|
||||
};
|
||||
|
||||
const runAction = async (
|
||||
id: string,
|
||||
action: "start" | "stop" | "restart" | "delete",
|
||||
) => {
|
||||
setActing((current) => ({ ...current, [id + action]: true }));
|
||||
|
||||
try {
|
||||
const response = await containersApi.action(
|
||||
id,
|
||||
action === "delete" ? "rm" : action,
|
||||
);
|
||||
|
||||
if (action === "delete") {
|
||||
setData((current) =>
|
||||
current.filter((container) => container.id !== id),
|
||||
);
|
||||
} else if (response.data) {
|
||||
setData((current) =>
|
||||
current.map((container) =>
|
||||
container.id === id
|
||||
? { ...container, ...response.data }
|
||||
: container,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const nextStatus =
|
||||
action === "start"
|
||||
? "RUNNING"
|
||||
: action === "stop"
|
||||
? "STOPPED"
|
||||
: "STARTING";
|
||||
setData((current) =>
|
||||
current.map((container) =>
|
||||
container.id === id
|
||||
? { ...container, status: nextStatus as Container["status"] }
|
||||
: container,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Action successful",
|
||||
message:
|
||||
action === "delete"
|
||||
? "Container has been deleted."
|
||||
: `Container has been ${action}ed.`,
|
||||
duration: 3500,
|
||||
showProgress: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Action failed",
|
||||
message: err instanceof Error ? err.message : "Action failed",
|
||||
duration: 6000,
|
||||
showProgress: true,
|
||||
});
|
||||
} finally {
|
||||
setActing((current) => ({ ...current, [id + action]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const runRebuild = async (container: Container) => {
|
||||
setActing((current) => ({ ...current, [container.id + "rebuild"]: true }));
|
||||
const baseTerminalLines = [
|
||||
`[rebuild] Starting rebuild for ${container.name}`,
|
||||
`[rebuild] Source: ${container.sourceType ?? "unknown"}`,
|
||||
`[rebuild] Server ID: ${container.serverId}`,
|
||||
];
|
||||
let latestTerminalLines = [...baseTerminalLines];
|
||||
|
||||
const updateProcessTerminal = (
|
||||
lines: string[],
|
||||
patch: Parameters<typeof updateProcessLogs>[0] = {},
|
||||
) => {
|
||||
latestTerminalLines = lines;
|
||||
updateProcessLogs({
|
||||
terminalLogs: latestTerminalLines,
|
||||
...patch,
|
||||
});
|
||||
};
|
||||
|
||||
openProcessLogs({
|
||||
title: `Rebuild Logs - ${container.name}`,
|
||||
description:
|
||||
"Rebuild progress, inventory sync, and terminal-style output for this request.",
|
||||
imageUrl: "/assets/images/img-chibi-fixing.png",
|
||||
imageAlt: "Illustration of a character fixing something",
|
||||
timelineLogs: createRebuildTimeline(0),
|
||||
terminalLogs: latestTerminalLines,
|
||||
initialTab: "timeline",
|
||||
statusLabel: "Starting",
|
||||
});
|
||||
|
||||
try {
|
||||
latestTerminalLines = [
|
||||
...baseTerminalLines,
|
||||
"[rebuild] Creating backend rebuild job",
|
||||
];
|
||||
updateProcessLogs({
|
||||
timelineLogs: createRebuildTimeline(1),
|
||||
terminalLogs: latestTerminalLines,
|
||||
statusLabel: "Starting",
|
||||
});
|
||||
|
||||
const jobResponse = await containersApi.createRebuildJob(container.id);
|
||||
const job = jobResponse.data;
|
||||
let finalStatus = job.status;
|
||||
let finalError = job.error;
|
||||
|
||||
updateProcessTerminal(
|
||||
[
|
||||
...latestTerminalLines,
|
||||
`[job] Rebuild job created: ${job.id}`,
|
||||
"[job] Streaming backend rebuild logs",
|
||||
],
|
||||
{
|
||||
timelineLogs: createRebuildTimeline(1),
|
||||
statusLabel: "Streaming",
|
||||
},
|
||||
);
|
||||
|
||||
await containersApi.streamJob(job.id, {
|
||||
onLog: (entry) => {
|
||||
updateProcessTerminal([...latestTerminalLines, entry.message], {
|
||||
timelineLogs: createRebuildTimeline(1),
|
||||
statusLabel: "Streaming",
|
||||
});
|
||||
},
|
||||
onStatus: (nextJob) => {
|
||||
finalStatus = nextJob.status;
|
||||
finalError = nextJob.error;
|
||||
},
|
||||
});
|
||||
|
||||
if (finalStatus === "error") {
|
||||
throw new Error(finalError || "Rebuild job failed");
|
||||
}
|
||||
|
||||
updateProcessLogs({
|
||||
timelineLogs: createRebuildTimeline(2),
|
||||
terminalLogs: [
|
||||
...latestTerminalLines,
|
||||
"[rebuild] Rebuild command completed",
|
||||
"[rebuild] Refreshing container inventory",
|
||||
].filter(Boolean),
|
||||
statusLabel: "Syncing",
|
||||
});
|
||||
|
||||
await load({
|
||||
sync: true,
|
||||
serverId: selectedServerId,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
updateProcessLogs({
|
||||
timelineLogs: createRebuildTimeline(3).map((step) => ({
|
||||
...step,
|
||||
status: "success",
|
||||
})),
|
||||
terminalLogs: [
|
||||
...latestTerminalLines,
|
||||
"[rebuild] Rebuild command completed",
|
||||
"[rebuild] Container inventory refreshed",
|
||||
"[rebuild] Done",
|
||||
].filter(Boolean),
|
||||
statusLabel: "100%",
|
||||
});
|
||||
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Rebuild scheduled",
|
||||
message: `Container "${container.name}" berhasil direbuild.`,
|
||||
duration: 4500,
|
||||
showProgress: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Rebuild failed";
|
||||
updateProcessLogs({
|
||||
timelineLogs: createFailedRebuildTimeline(1),
|
||||
terminalLogs: [
|
||||
...baseTerminalLines,
|
||||
"[rebuild] Rebuild failed",
|
||||
`[error] ${message}`,
|
||||
],
|
||||
statusLabel: "Failed",
|
||||
});
|
||||
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Rebuild failed",
|
||||
message,
|
||||
duration: 6000,
|
||||
showProgress: true,
|
||||
});
|
||||
} finally {
|
||||
setActing((current) => ({
|
||||
...current,
|
||||
[container.id + "rebuild"]: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (
|
||||
id: string,
|
||||
action: "start" | "stop" | "restart" | "delete",
|
||||
) => {
|
||||
if (action !== "delete") {
|
||||
await runAction(id, action);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = data.find((item) => item.id === id);
|
||||
setConfirmDialog({
|
||||
title: "Delete Container",
|
||||
description: container
|
||||
? `Delete container "${container.name}"?`
|
||||
: "Delete this container?",
|
||||
confirmLabel: "Delete Container",
|
||||
tone: "danger",
|
||||
note: "This removes the container from Docker on the selected server.",
|
||||
onConfirm: () => {
|
||||
void runAction(id, action);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRebuild = (container: Container) => {
|
||||
const isGitSource =
|
||||
container.sourceType === "GIT_CLONE" ||
|
||||
container.sourceType === "GIT_PROVIDER";
|
||||
|
||||
setConfirmDialog({
|
||||
title: "Rebuild Container",
|
||||
description: `Rebuild container "${container.name}" now?`,
|
||||
confirmLabel: "Rebuild Container",
|
||||
tone: "warning",
|
||||
note: isGitSource
|
||||
? "Doktainer will fetch the latest changes from the repository, rebuild the image if necessary, and then redeploy the container."
|
||||
: "The container will be stopped, removed, and then restarted. Doktainer will attempt to pull the latest image first if available in the registry.",
|
||||
onConfirm: () => {
|
||||
void runRebuild(container);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const filtered = data.filter((container) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
container.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
container.image.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "ALL" || container.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const pagination = useTablePagination({
|
||||
items: filtered,
|
||||
resetKey: `${search}|${statusFilter}|${selectedServerId}`,
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: data.length,
|
||||
running: data.filter((container) => container.status === "RUNNING").length,
|
||||
stopped: data.filter((container) => container.status === "STOPPED").length,
|
||||
paused: data.filter((container) => container.status === "PAUSED").length,
|
||||
error: data.filter((container) => container.status === "ERROR").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Containers"
|
||||
subtitle="Manage Docker containers across all servers"
|
||||
>
|
||||
<ConfirmActionDialog
|
||||
open={confirmDialog !== null}
|
||||
title={confirmDialog?.title ?? ""}
|
||||
description={confirmDialog?.description ?? ""}
|
||||
confirmLabel={confirmDialog?.confirmLabel ?? "Confirm"}
|
||||
tone={confirmDialog?.tone ?? "danger"}
|
||||
note={confirmDialog?.note}
|
||||
onClose={() => setConfirmDialog(null)}
|
||||
onConfirm={() => {
|
||||
const current = confirmDialog;
|
||||
setConfirmDialog(null);
|
||||
current?.onConfirm();
|
||||
}}
|
||||
/>
|
||||
<ToastViewport
|
||||
toasts={toasts}
|
||||
onClose={dismissToast}
|
||||
position="top-right"
|
||||
/>
|
||||
|
||||
{showDeploy ? (
|
||||
<DeployContainerModal
|
||||
serverList={serverList}
|
||||
onClose={() => setShowDeploy(false)}
|
||||
onDeployed={load}
|
||||
onProcessOpen={openProcessLogs}
|
||||
onProcessUpdate={updateProcessLogs}
|
||||
/>
|
||||
) : null}
|
||||
{logsFor ? (
|
||||
<ContainerLogsModal
|
||||
container={logsFor}
|
||||
onClose={() => setLogsFor(null)}
|
||||
/>
|
||||
) : null}
|
||||
{detailFor ? (
|
||||
<ContainerDetailsModal
|
||||
key={detailFor.id}
|
||||
container={detailFor}
|
||||
onClose={() => setDetailFor(null)}
|
||||
/>
|
||||
) : null}
|
||||
{fileManagerFor ? (
|
||||
<ContainerFileManagerModal
|
||||
key={fileManagerFor.id}
|
||||
container={fileManagerFor}
|
||||
onClose={() => setFileManagerFor(null)}
|
||||
/>
|
||||
) : null}
|
||||
<ProcessLogsModal
|
||||
open={isProcessLogsOpen}
|
||||
onClose={closeProcessLogs}
|
||||
closeOnOverlayClick={false}
|
||||
{...(processLogsState ?? {
|
||||
title: "Process Logs",
|
||||
timelineLogs: [],
|
||||
terminalLogs: "",
|
||||
})}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="animate-slide-in"
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
<ContainersSummary
|
||||
total={stats.total}
|
||||
running={stats.running}
|
||||
stopped={stats.stopped}
|
||||
pausedAndError={stats.paused + stats.error}
|
||||
/>
|
||||
<ContainersServerFilter
|
||||
serverList={serverList}
|
||||
selectedServerId={selectedServerId}
|
||||
onChange={handleServerChange}
|
||||
/>
|
||||
<ContainersToolbar
|
||||
search={search}
|
||||
statusFilter={statusFilter}
|
||||
syncing={syncing}
|
||||
onSearchChange={setSearch}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
onSync={handleSync}
|
||||
onDeploy={() => setShowDeploy(true)}
|
||||
/>
|
||||
<ContainersTable
|
||||
loading={loading}
|
||||
items={filtered}
|
||||
paginatedItems={pagination.paginatedItems}
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.totalItems}
|
||||
startItem={pagination.startItem}
|
||||
endItem={pagination.endItem}
|
||||
acting={acting}
|
||||
openMenuId={openMenuId}
|
||||
onPageChange={pagination.setCurrentPage}
|
||||
onAction={handleAction}
|
||||
onOpenDetails={setDetailFor}
|
||||
onOpenLogs={setLogsFor}
|
||||
onOpenFileManager={setFileManagerFor}
|
||||
onRebuild={handleRebuild}
|
||||
onMenuToggle={(containerId) =>
|
||||
setOpenMenuId((current) =>
|
||||
current === containerId ? null : containerId,
|
||||
)
|
||||
}
|
||||
onMenuClose={() => setOpenMenuId(null)}
|
||||
/>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export function formatBytes(bytes?: number | null): string {
|
||||
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "—";
|
||||
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(2)} TB`;
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
export function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function joinContainerPath(basePath: string, name: string): string {
|
||||
const cleanName = name.replace(/^\/+/, "");
|
||||
if (!cleanName) return basePath || "/";
|
||||
if (!basePath || basePath === "/") return `/${cleanName}`;
|
||||
return `${basePath.replace(/\/+$/, "")}/${cleanName}`;
|
||||
}
|
||||
|
||||
export function decodeBase64ToBlob(base64: string): Blob {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new Blob([bytes]);
|
||||
}
|
||||
|
||||
export function decodeBase64ToText(base64: string): string {
|
||||
return atob(base64);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { Database, Loader2, Play, Square, Trash2 } from "lucide-react";
|
||||
import type { DatabaseContainer } from "./database-types";
|
||||
import { typeColors } from "./database-constants";
|
||||
|
||||
interface DatabaseGridProps {
|
||||
filteredData: DatabaseContainer[];
|
||||
actioning: string | null;
|
||||
removing: string | null;
|
||||
onAction: (container: DatabaseContainer, action: "start" | "stop") => void;
|
||||
onRemove: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
export default function DatabaseGrid({
|
||||
filteredData,
|
||||
actioning,
|
||||
removing,
|
||||
onAction,
|
||||
onRemove,
|
||||
}: DatabaseGridProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(380px, 100%), 1fr))",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{filteredData.map((db) => {
|
||||
const color =
|
||||
db.databaseColor ?? typeColors[db.databaseType] ?? "#3b82f6";
|
||||
const label = db.databaseLabel;
|
||||
const isRunning = db.status === "RUNNING";
|
||||
const isFailed = db.status === "ERROR";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={db.id}
|
||||
className="card"
|
||||
style={{ padding: 18, position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -20,
|
||||
right: -20,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
opacity: 0.05,
|
||||
filter: "blur(20px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 9,
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}30`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Database size={16} style={{ color }} />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{db.name}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{label}
|
||||
{db.exposedPort ? ` · :${db.exposedPort}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
background: isRunning
|
||||
? "rgba(16,185,129,0.1)"
|
||||
: isFailed
|
||||
? "rgba(239,68,68,0.1)"
|
||||
: "rgba(100,116,139,0.1)",
|
||||
color: isRunning
|
||||
? "#10b981"
|
||||
: isFailed
|
||||
? "#ef4444"
|
||||
: "#64748b",
|
||||
border: `1px solid ${isRunning ? "rgba(16,185,129,0.3)" : isFailed ? "rgba(239,68,68,0.3)" : "rgba(100,116,139,0.3)"}`,
|
||||
padding: "3px 9px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{isRunning && (
|
||||
<span
|
||||
className="animate-pulse-dot"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: "50%",
|
||||
background: "currentColor",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{db.status.charAt(0) + db.status.slice(1).toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 8,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: "Server", value: db.server?.name ?? "—" },
|
||||
{ label: "Port", value: db.exposedPort ?? "—" },
|
||||
{ label: "Container", value: db.name ?? "—" },
|
||||
{ label: "Image", value: db.image },
|
||||
].map((m) => (
|
||||
<div
|
||||
key={m.label}
|
||||
style={{
|
||||
background: "var(--bg-input)",
|
||||
borderRadius: 7,
|
||||
padding: "8px 10px",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{m.label}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{m.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{isRunning ? (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ flex: 1, fontSize: 11, padding: "5px" }}
|
||||
onClick={() => onAction(db, "stop")}
|
||||
disabled={actioning === db.id}
|
||||
>
|
||||
{actioning === db.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Square size={11} />
|
||||
)}{" "}
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-success"
|
||||
style={{ flex: 1, fontSize: 11, padding: "5px" }}
|
||||
onClick={() => onAction(db, "start")}
|
||||
disabled={actioning === db.id}
|
||||
>
|
||||
{actioning === db.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={11} />
|
||||
)}{" "}
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ flex: 1, fontSize: 11, padding: "5px" }}
|
||||
onClick={() => onRemove(db.id, db.name)}
|
||||
disabled={removing === db.id}
|
||||
>
|
||||
{removing === db.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={11} />
|
||||
)}{" "}
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { HardDrive } from "lucide-react";
|
||||
import type { Server } from "@/lib/api";
|
||||
|
||||
interface DatabaseServerFilterProps {
|
||||
serverList: Server[];
|
||||
selectedServerId: string;
|
||||
onChange: (serverId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function getButtonStyles(isSelected: boolean) {
|
||||
return {
|
||||
padding: "5px 14px",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: isSelected ? "rgba(59,130,246,0.15)" : "var(--bg-input)",
|
||||
color: isSelected ? "#3b82f6" : "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
outline: isSelected
|
||||
? "1px solid rgba(59,130,246,0.3)"
|
||||
: "1px solid var(--border)",
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default function DatabaseServerFilter({
|
||||
serverList,
|
||||
selectedServerId,
|
||||
onChange,
|
||||
}: DatabaseServerFilterProps) {
|
||||
return (
|
||||
<div
|
||||
className="card ui-responsive-toolbar"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
}}
|
||||
>
|
||||
<div className="ui-inline-cluster">
|
||||
<HardDrive size={14} style={{ color: "var(--text-muted)" }} />
|
||||
<span
|
||||
className="ui-inline-label"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Server:
|
||||
</span>
|
||||
<div className="ui-chip-scroll no-scrollbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onChange("")}
|
||||
style={getButtonStyles(selectedServerId === "")}
|
||||
>
|
||||
All Servers
|
||||
</button>
|
||||
{serverList.map((server) => (
|
||||
<button
|
||||
type="button"
|
||||
key={server.id}
|
||||
onClick={() => void onChange(server.id)}
|
||||
style={getButtonStyles(selectedServerId === server.id)}
|
||||
>
|
||||
{server.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* <p
|
||||
className="ui-toolbar-note"
|
||||
style={{ fontSize: 12, color: "var(--text-muted)" }}
|
||||
>
|
||||
{selectedServerId
|
||||
? "Sync database container only for the selected server"
|
||||
: "Default mode: show all databases from all servers"}
|
||||
</p> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Database, Loader2, Plus } from "lucide-react";
|
||||
|
||||
interface DatabaseStatePanelProps {
|
||||
loading: boolean;
|
||||
isEmpty: boolean;
|
||||
searchActive: boolean;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export default function DatabaseStatePanel({
|
||||
loading,
|
||||
isEmpty,
|
||||
searchActive,
|
||||
onAdd,
|
||||
}: DatabaseStatePanelProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div style={{ padding: 48, textAlign: "center" }}>
|
||||
<Loader2
|
||||
size={28}
|
||||
className="animate-spin"
|
||||
style={{ color: "var(--accent)", margin: "0 auto 12px" }}
|
||||
/>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
|
||||
Loading Databases...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 48,
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
}}
|
||||
>
|
||||
<Database
|
||||
size={36}
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
margin: "0 auto 12px",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
|
||||
{searchActive
|
||||
? "No database containers match your search"
|
||||
: "No database containers detected on connected Docker hosts"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ marginTop: 16, fontSize: 12 }}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<Plus size={12} /> Deploy First Database
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Activity, CheckCircle, Database, HardDrive } from "lucide-react";
|
||||
|
||||
interface DatabaseSummaryProps {
|
||||
total: number;
|
||||
running: number;
|
||||
serverCount: number;
|
||||
typeCount: number;
|
||||
}
|
||||
|
||||
export default function DatabaseSummary({
|
||||
total,
|
||||
running,
|
||||
serverCount,
|
||||
typeCount,
|
||||
}: DatabaseSummaryProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: "Total DBs",
|
||||
value: total,
|
||||
color: "#3b82f6",
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
label: "Running",
|
||||
value: running,
|
||||
color: "#10b981",
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
label: "Servers Used",
|
||||
value: serverCount,
|
||||
color: "#8b5cf6",
|
||||
icon: HardDrive,
|
||||
},
|
||||
{
|
||||
label: "DB Types",
|
||||
value: typeCount,
|
||||
color: "#f59e0b",
|
||||
icon: Activity,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{stats.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<div
|
||||
key={s.label}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 10,
|
||||
background: `${s.color}15`,
|
||||
border: `1px solid ${s.color}25`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Icon size={18} style={{ color: s.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{s.value}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import SearchField from "@/components/SearchField";
|
||||
import { Loader2, Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
interface DatabaseToolbarProps {
|
||||
search: string;
|
||||
syncing: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSync: () => void | Promise<void>;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export default function DatabaseToolbar({
|
||||
search,
|
||||
syncing,
|
||||
onSearchChange,
|
||||
onSync,
|
||||
onAdd,
|
||||
}: DatabaseToolbarProps) {
|
||||
return (
|
||||
<div
|
||||
className="card ui-responsive-toolbar"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
}}
|
||||
>
|
||||
<SearchField
|
||||
placeholder="Search databases..."
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
containerStyle={{ flex: "1 1 360px", minWidth: 240 }}
|
||||
/>
|
||||
<div className="ui-toolbar-actions" style={{}}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => void onSync()}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
Sync from Docker
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<Plus size={12} /> Add Database
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export const DB_APPS = ["postgres", "mysql", "mongodb", "redis"] as const;
|
||||
export type DbType = (typeof DB_APPS)[number];
|
||||
export type DatabaseKind = DbType | "mariadb";
|
||||
|
||||
export const DB_META: Record<
|
||||
DatabaseKind,
|
||||
{ label: string; color: string; port: string; passEnv: string }
|
||||
> = {
|
||||
postgres: {
|
||||
label: "PostgreSQL",
|
||||
color: "#3b82f6",
|
||||
port: "5432:5432",
|
||||
passEnv: "POSTGRES_PASSWORD",
|
||||
},
|
||||
mysql: {
|
||||
label: "MySQL",
|
||||
color: "#f59e0b",
|
||||
port: "3306:3306",
|
||||
passEnv: "MYSQL_ROOT_PASSWORD",
|
||||
},
|
||||
mongodb: {
|
||||
label: "MongoDB",
|
||||
color: "#10b981",
|
||||
port: "27017:27017",
|
||||
passEnv: "MONGO_INITDB_ROOT_PASSWORD",
|
||||
},
|
||||
mariadb: {
|
||||
label: "MariaDB",
|
||||
color: "#fb7185",
|
||||
port: "3306:3306",
|
||||
passEnv: "MARIADB_ROOT_PASSWORD",
|
||||
},
|
||||
redis: { label: "Redis", color: "#ef4444", port: "6379:6379", passEnv: "" },
|
||||
};
|
||||
|
||||
export const typeColors: Record<string, string> = {
|
||||
PostgreSQL: "#3b82f6",
|
||||
MySQL: "#f59e0b",
|
||||
MongoDB: "#10b981",
|
||||
Redis: "#ef4444",
|
||||
postgres: "#3b82f6",
|
||||
mysql: "#f59e0b",
|
||||
mongodb: "#10b981",
|
||||
mariadb: "#fb7185",
|
||||
redis: "#ef4444",
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Container, Server } from "@/lib/api";
|
||||
import type { DatabaseKind } from "./database-constants";
|
||||
|
||||
export type DatabaseContainer = Container & {
|
||||
databaseType: DatabaseKind;
|
||||
databaseLabel: string;
|
||||
databaseColor: string;
|
||||
exposedPort: string;
|
||||
};
|
||||
|
||||
export interface AddDatabaseModalProps {
|
||||
serverList: Server[];
|
||||
initialServerId?: string;
|
||||
initialEnvironmentId?: string;
|
||||
lockServerSelection?: boolean;
|
||||
onClose: () => void;
|
||||
onAdded: () => void;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Container } from "@/lib/api";
|
||||
import { DB_META, DatabaseKind } from "./database-constants";
|
||||
import type { DatabaseContainer } from "./database-types";
|
||||
|
||||
export function detectDatabaseType(container: Container): DatabaseKind | null {
|
||||
const source = `${container.name} ${container.image}`.toLowerCase();
|
||||
|
||||
if (source.includes("postgres")) return "postgres";
|
||||
if (source.includes("mariadb")) return "mariadb";
|
||||
if (source.includes("mysql")) return "mysql";
|
||||
if (source.includes("mongo")) return "mongodb";
|
||||
if (source.includes("redis")) return "redis";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractPort(container: Container, fallback: string): string {
|
||||
const firstPort = container.ports[0];
|
||||
if (!firstPort) return fallback;
|
||||
|
||||
const hostMatch = firstPort.match(/:(\d+)->/);
|
||||
if (hostMatch?.[1]) return hostMatch[1];
|
||||
|
||||
const containerMatch = firstPort.match(/(\d+)\/(tcp|udp)/i);
|
||||
if (containerMatch?.[1]) return containerMatch[1];
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function toDatabaseContainer(
|
||||
container: Container,
|
||||
): DatabaseContainer | null {
|
||||
const databaseType = detectDatabaseType(container);
|
||||
if (!databaseType) return null;
|
||||
|
||||
const meta = DB_META[databaseType];
|
||||
|
||||
return {
|
||||
...container,
|
||||
databaseType,
|
||||
databaseLabel: meta.label,
|
||||
databaseColor: meta.color,
|
||||
exposedPort: extractPort(container, meta.port),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import ToastViewport from "@/components/ToastViewport";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
containers as containersApi,
|
||||
servers as serversApi,
|
||||
Server,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
readCachedPageData,
|
||||
readStoredServerSelection,
|
||||
storeServerSelection,
|
||||
writeCachedPageData,
|
||||
} from "@/lib/page-state";
|
||||
import { useToastManager } from "@/lib/use-toast-manager";
|
||||
import AddDatabaseModal from "./components/AddDatabaseModal";
|
||||
import DatabaseServerFilter from "./components/DatabaseServerFilter";
|
||||
import DatabaseStatePanel from "./components/DatabaseStatePanel";
|
||||
import DatabaseSummary from "./components/DatabaseSummary";
|
||||
import DatabaseToolbar from "./components/DatabaseToolbar";
|
||||
import DatabaseGrid from "./components/DatabaseGrid";
|
||||
import { toDatabaseContainer } from "./components/database-utils";
|
||||
import type { DatabaseContainer } from "./components/database-types";
|
||||
|
||||
const PAGE_KEY = "databases";
|
||||
|
||||
type PendingConfirmAction = {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
tone?: "danger" | "warning" | "info";
|
||||
note?: string;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export default function DatabasesPage() {
|
||||
const { toasts, pushToast, dismissToast } = useToastManager();
|
||||
const [data, setData] = useState<DatabaseContainer[]>([]);
|
||||
const [serverList, setServerList] = useState<Server[]>([]);
|
||||
const [selectedServerId, setSelectedServerId] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [actioning, setActioning] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [confirmDialog, setConfirmDialog] =
|
||||
useState<PendingConfirmAction | null>(null);
|
||||
|
||||
const applyCachedState = useCallback((serverId: string) => {
|
||||
const cached = readCachedPageData<{
|
||||
items: DatabaseContainer[];
|
||||
serverList: Server[];
|
||||
}>(PAGE_KEY, serverId);
|
||||
|
||||
if (!cached) return false;
|
||||
|
||||
setData(cached.items);
|
||||
setServerList(cached.serverList);
|
||||
setLoading(false);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const load = useCallback(
|
||||
async (options?: { sync?: boolean; serverId?: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resolvedServerId = options?.serverId ?? "";
|
||||
const sr = await serversApi.list();
|
||||
const nextServers = sr.data ?? [];
|
||||
|
||||
let ir;
|
||||
|
||||
try {
|
||||
ir = options?.sync
|
||||
? await containersApi.sync(
|
||||
resolvedServerId ? { serverId: resolvedServerId } : undefined,
|
||||
)
|
||||
: await containersApi.list(
|
||||
resolvedServerId ? { serverId: resolvedServerId } : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!options?.sync) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
ir = await containersApi.list(
|
||||
resolvedServerId ? { serverId: resolvedServerId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const dbs = (ir.data ?? [])
|
||||
.map(toDatabaseContainer)
|
||||
.filter((item): item is DatabaseContainer => item !== null);
|
||||
setData(dbs);
|
||||
setServerList(nextServers);
|
||||
setSelectedServerId(resolvedServerId);
|
||||
storeServerSelection(PAGE_KEY, resolvedServerId);
|
||||
writeCachedPageData(
|
||||
PAGE_KEY,
|
||||
{
|
||||
items: dbs,
|
||||
serverList: nextServers,
|
||||
},
|
||||
resolvedServerId,
|
||||
);
|
||||
} catch {
|
||||
/* silently fail */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const storedServerId = readStoredServerSelection(PAGE_KEY);
|
||||
setSelectedServerId(storedServerId);
|
||||
const hasCache = applyCachedState(storedServerId);
|
||||
if (!hasCache) {
|
||||
void load({ sync: false, serverId: storedServerId });
|
||||
}
|
||||
}, [applyCachedState, load]);
|
||||
|
||||
const handleAction = async (
|
||||
container: DatabaseContainer,
|
||||
action: "start" | "stop",
|
||||
) => {
|
||||
setActioning(container.id);
|
||||
try {
|
||||
await containersApi.action(container.id, action);
|
||||
setData((current) =>
|
||||
current.map((item) =>
|
||||
item.id === container.id
|
||||
? { ...item, status: action === "start" ? "RUNNING" : "STOPPED" }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Database Action",
|
||||
message: `${container.name} ${action === "start" ? "started" : "stopped"} successfully`,
|
||||
showProgress: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Database Action",
|
||||
message: err instanceof Error ? err.message : "Action failed",
|
||||
showProgress: true,
|
||||
});
|
||||
} finally {
|
||||
setActioning(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDatabase = async (id: string, name: string) => {
|
||||
setRemoving(id);
|
||||
try {
|
||||
await containersApi.action(id, "rm");
|
||||
setData((prev) => prev.filter((d) => d.id !== id));
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Database Removed",
|
||||
message: `${name} removed successfully`,
|
||||
showProgress: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Database Removed",
|
||||
message: err instanceof Error ? err.message : "Remove failed",
|
||||
showProgress: true,
|
||||
});
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (id: string, name: string) => {
|
||||
setConfirmDialog({
|
||||
title: "Remove Database",
|
||||
description: `Remove database "${name}"?`,
|
||||
confirmLabel: "Remove Database",
|
||||
tone: "danger",
|
||||
note: "The database container will be removed from the selected server.",
|
||||
onConfirm: () => {
|
||||
void removeDatabase(id, name);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await load({ sync: true, serverId: selectedServerId });
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Sync Complete",
|
||||
message: "Database inventory refreshed successfully",
|
||||
showProgress: true,
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerChange = async (serverId: string) => {
|
||||
if (serverId === selectedServerId) return;
|
||||
storeServerSelection(PAGE_KEY, serverId);
|
||||
setSelectedServerId(serverId);
|
||||
const hasCache = applyCachedState(serverId);
|
||||
if (!hasCache) {
|
||||
await load({ sync: false, serverId });
|
||||
}
|
||||
};
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
const filteredData = data.filter((db) => {
|
||||
if (!query) return true;
|
||||
|
||||
return (
|
||||
db.name.toLowerCase().includes(query) ||
|
||||
db.image.toLowerCase().includes(query) ||
|
||||
db.databaseLabel.toLowerCase().includes(query) ||
|
||||
db.databaseType.toLowerCase().includes(query) ||
|
||||
(db.server?.name ?? "").toLowerCase().includes(query) ||
|
||||
(db.server?.ip ?? "").toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
const running = data.filter((d) => d.status === "RUNNING").length;
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Databases"
|
||||
subtitle="Manage database instances across all servers"
|
||||
>
|
||||
<ConfirmActionDialog
|
||||
open={confirmDialog !== null}
|
||||
title={confirmDialog?.title ?? ""}
|
||||
description={confirmDialog?.description ?? ""}
|
||||
confirmLabel={confirmDialog?.confirmLabel ?? "Confirm"}
|
||||
tone={confirmDialog?.tone ?? "danger"}
|
||||
note={confirmDialog?.note}
|
||||
onClose={() => setConfirmDialog(null)}
|
||||
onConfirm={() => {
|
||||
const current = confirmDialog;
|
||||
setConfirmDialog(null);
|
||||
current?.onConfirm();
|
||||
}}
|
||||
/>
|
||||
<ToastViewport
|
||||
toasts={toasts}
|
||||
onClose={dismissToast}
|
||||
position="top-right"
|
||||
/>
|
||||
{showAdd && (
|
||||
<AddDatabaseModal
|
||||
serverList={serverList}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onAdded={() => load({ sync: true, serverId: selectedServerId })}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="animate-slide-in"
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
<DatabaseSummary
|
||||
total={data.length}
|
||||
running={running}
|
||||
serverCount={new Set(data.map((d) => d.serverId)).size}
|
||||
typeCount={new Set(data.map((d) => d.databaseType)).size}
|
||||
/>
|
||||
<DatabaseServerFilter
|
||||
serverList={serverList}
|
||||
selectedServerId={selectedServerId}
|
||||
onChange={handleServerChange}
|
||||
/>
|
||||
<DatabaseToolbar
|
||||
search={search}
|
||||
syncing={syncing}
|
||||
onSearchChange={setSearch}
|
||||
onSync={handleSync}
|
||||
onAdd={() => setShowAdd(true)}
|
||||
/>
|
||||
<DatabaseStatePanel
|
||||
loading={loading}
|
||||
isEmpty={filteredData.length === 0}
|
||||
searchActive={Boolean(search.trim())}
|
||||
onAdd={() => setShowAdd(true)}
|
||||
/>
|
||||
{/* Cards */}
|
||||
{!loading && filteredData.length > 0 && (
|
||||
<DatabaseGrid
|
||||
filteredData={filteredData}
|
||||
actioning={actioning}
|
||||
removing={removing}
|
||||
onAction={handleAction}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { AddDomainFormState } from "./domain-types";
|
||||
|
||||
interface AddDomainDnsFieldsProps {
|
||||
form: AddDomainFormState;
|
||||
isContainerMode: boolean;
|
||||
selectedServerIp: string;
|
||||
onChange: (updates: Partial<AddDomainFormState>) => void;
|
||||
}
|
||||
|
||||
const RECORD_TYPES: AddDomainFormState["type"][] = [
|
||||
"A",
|
||||
"AAAA",
|
||||
"CNAME",
|
||||
"MX",
|
||||
"TXT",
|
||||
];
|
||||
|
||||
export default function AddDomainDnsFields({
|
||||
form,
|
||||
isContainerMode,
|
||||
selectedServerIp,
|
||||
onChange,
|
||||
}: AddDomainDnsFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Domain Name *
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(event) => onChange({ name: event.target.value })}
|
||||
placeholder="example.com"
|
||||
required
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isContainerMode ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr",
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--border)",
|
||||
background: "rgba(59,130,246,0.06)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
DNS Record Type
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value="A"
|
||||
readOnly
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
DNS Target IP
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={selectedServerIp}
|
||||
readOnly
|
||||
placeholder="Select Server Target first"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<p
|
||||
style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}
|
||||
>
|
||||
Container Config automatically uses the selected server IP as the
|
||||
DNS target, then forwards traffic to the chosen container and port
|
||||
through a reverse proxy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "140px 1fr",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Record Type
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.type}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
type: event.target.value as AddDomainFormState["type"],
|
||||
})
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{RECORD_TYPES.map((recordType) => (
|
||||
<option key={recordType}>{recordType}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
{form.type === "CNAME"
|
||||
? "Target Domain"
|
||||
: form.type === "MX"
|
||||
? "Mail Server"
|
||||
: form.type === "TXT"
|
||||
? "TXT Value"
|
||||
: "IP Address"}{" "}
|
||||
*
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.value}
|
||||
onChange={(event) => onChange({ value: event.target.value })}
|
||||
placeholder={
|
||||
form.type === "A"
|
||||
? "192.168.1.10"
|
||||
: form.type === "CNAME"
|
||||
? "target.example.com"
|
||||
: ""
|
||||
}
|
||||
required
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
{(form.type === "A" || form.type === "AAAA") && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
DNS records only accept IP addresses. Use Container Config if
|
||||
you want this domain to forward traffic into a container port.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
containers as containersApi,
|
||||
Container,
|
||||
DomainConfigMode as ApiDomainConfigMode,
|
||||
Domain,
|
||||
DomainProxy,
|
||||
domains as domainsApi,
|
||||
Server,
|
||||
servers as serversApi,
|
||||
} from "@/lib/api";
|
||||
import { Loader2, Shield, X } from "lucide-react";
|
||||
import AddDomainDnsFields from "./AddDomainDnsFields";
|
||||
import AddDomainModeSelector from "./AddDomainModeSelector";
|
||||
import AddDomainSslSection from "./AddDomainSslSection";
|
||||
import AddDomainTargetingSection from "./AddDomainTargetingSection";
|
||||
import {
|
||||
AddDomainFormState,
|
||||
DomainConfigMode,
|
||||
PortOption,
|
||||
} from "./domain-types";
|
||||
import {
|
||||
detectAvailableProxies,
|
||||
isIpv4WithPort,
|
||||
parseInspectPortOptions,
|
||||
} from "./domain-utils";
|
||||
|
||||
interface AddDomainModalProps {
|
||||
serverList: Server[];
|
||||
existingDomains: Domain[];
|
||||
domainToEdit?: Domain | null;
|
||||
onClose: () => void;
|
||||
onSaved: (
|
||||
domainName: string,
|
||||
operation: "created" | "updated",
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const PROXY_OPTIONS: DomainProxy[] = ["NONE", "TRAEFIK", "NGINX", "CADDY"];
|
||||
|
||||
export default function AddDomainModal({
|
||||
serverList,
|
||||
existingDomains,
|
||||
domainToEdit,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: AddDomainModalProps) {
|
||||
const buildInitialForm = (domain?: Domain | null): AddDomainFormState => ({
|
||||
name: domain?.name ?? "",
|
||||
type: domain?.type ?? "A",
|
||||
value: domain?.value ?? "",
|
||||
serverId: domain?.serverId ?? "",
|
||||
targetContainerId: domain?.targetContainerId ?? "",
|
||||
targetPort: domain?.targetPort ?? "",
|
||||
proxy: domain?.proxy ?? "NONE",
|
||||
proxyConfigMode: domain?.configMode ?? "ISOLATED",
|
||||
isPrimary: domain?.isPrimary ?? false,
|
||||
sslEnabled: domain?.sslEnabled ?? false,
|
||||
autoRenew: domain?.autoRenew ?? true,
|
||||
});
|
||||
|
||||
const detectInitialConfigMode = (
|
||||
domain?: Domain | null,
|
||||
): DomainConfigMode => {
|
||||
if (
|
||||
domain &&
|
||||
(domain.targetContainerId != null || domain.targetPort != null)
|
||||
) {
|
||||
return "CONTAINER";
|
||||
}
|
||||
|
||||
return "MANUAL";
|
||||
};
|
||||
|
||||
const isEditMode = Boolean(domainToEdit);
|
||||
const [form, setForm] = useState<AddDomainFormState>({
|
||||
...buildInitialForm(domainToEdit),
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [capabilityLoading, setCapabilityLoading] = useState(false);
|
||||
const [proxyCapabilityError, setProxyCapabilityError] = useState("");
|
||||
const [certbotInstalled, setCertbotInstalled] = useState(false);
|
||||
const [serverContainers, setServerContainers] = useState<Container[]>([]);
|
||||
const [portOptions, setPortOptions] = useState<PortOption[]>([]);
|
||||
const [containerInspectLoading, setContainerInspectLoading] = useState(false);
|
||||
const [availableProxies, setAvailableProxies] = useState<
|
||||
DomainProxy[] | null
|
||||
>(null);
|
||||
const [configMode, setConfigMode] = useState<DomainConfigMode>(
|
||||
detectInitialConfigMode(domainToEdit),
|
||||
);
|
||||
|
||||
const selectedServer = useMemo(
|
||||
() => serverList.find((server) => server.id === form.serverId) ?? null,
|
||||
[serverList, form.serverId],
|
||||
);
|
||||
const isContainerMode = configMode === "CONTAINER";
|
||||
const selectableProxies = availableProxies ?? PROXY_OPTIONS;
|
||||
const targetingEnabled = Boolean(form.serverId);
|
||||
|
||||
const linkedContainerDomains = useMemo(() => {
|
||||
const usage = new Map<string, string[]>();
|
||||
|
||||
for (const domain of existingDomains) {
|
||||
if (
|
||||
domain.id === domainToEdit?.id ||
|
||||
domain.serverId !== form.serverId ||
|
||||
!domain.targetContainerId ||
|
||||
!domain.name
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const names = usage.get(domain.targetContainerId) ?? [];
|
||||
names.push(domain.name);
|
||||
usage.set(domain.targetContainerId, names);
|
||||
}
|
||||
|
||||
return usage;
|
||||
}, [domainToEdit?.id, existingDomains, form.serverId]);
|
||||
|
||||
const linkedPortDomains = useMemo(() => {
|
||||
const usage = new Map<number, string[]>();
|
||||
|
||||
if (!form.targetContainerId) {
|
||||
return usage;
|
||||
}
|
||||
|
||||
for (const domain of existingDomains) {
|
||||
if (
|
||||
domain.id === domainToEdit?.id ||
|
||||
domain.serverId !== form.serverId ||
|
||||
domain.targetContainerId !== form.targetContainerId ||
|
||||
domain.targetPort == null ||
|
||||
!domain.name
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const names = usage.get(domain.targetPort) ?? [];
|
||||
names.push(domain.name);
|
||||
usage.set(domain.targetPort, names);
|
||||
}
|
||||
|
||||
return usage;
|
||||
}, [
|
||||
domainToEdit?.id,
|
||||
existingDomains,
|
||||
form.serverId,
|
||||
form.targetContainerId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(buildInitialForm(domainToEdit));
|
||||
setConfigMode(detectInitialConfigMode(domainToEdit));
|
||||
setError("");
|
||||
setLoading(false);
|
||||
}, [domainToEdit]);
|
||||
|
||||
const updateForm = (updates: Partial<AddDomainFormState>) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
...updates,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!form.serverId) {
|
||||
setCapabilityLoading(false);
|
||||
setProxyCapabilityError("");
|
||||
setAvailableProxies(null);
|
||||
setServerContainers([]);
|
||||
setPortOptions([]);
|
||||
setCertbotInstalled(false);
|
||||
setForm((current) => {
|
||||
if (
|
||||
current.proxy === "NONE" &&
|
||||
!current.sslEnabled &&
|
||||
!current.targetContainerId &&
|
||||
current.targetPort === ""
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
targetContainerId: "",
|
||||
targetPort: "",
|
||||
proxy: "NONE",
|
||||
sslEnabled: false,
|
||||
autoRenew: false,
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setCapabilityLoading(true);
|
||||
setProxyCapabilityError("");
|
||||
|
||||
void Promise.all([
|
||||
serversApi.getConfig(form.serverId),
|
||||
containersApi.list({ serverId: form.serverId }),
|
||||
])
|
||||
.then(([configRes, containersRes]) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proxies = detectAvailableProxies(
|
||||
configRes.data,
|
||||
containersRes.data,
|
||||
);
|
||||
const hasCertbot = configRes.data.webServer.components.some(
|
||||
(component) => component.key === "certbot" && component.installed,
|
||||
);
|
||||
|
||||
setCertbotInstalled(hasCertbot);
|
||||
setAvailableProxies(proxies);
|
||||
setServerContainers(
|
||||
containersRes.data.filter(
|
||||
(container) => container.status === "RUNNING",
|
||||
),
|
||||
);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
sslEnabled: hasCertbot ? current.sslEnabled : false,
|
||||
autoRenew:
|
||||
hasCertbot && current.sslEnabled ? current.autoRenew : false,
|
||||
proxy: proxies.includes(current.proxy) ? current.proxy : "NONE",
|
||||
}));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableProxies(["NONE"]);
|
||||
setCertbotInstalled(false);
|
||||
setServerContainers([]);
|
||||
setPortOptions([]);
|
||||
setForm((current) => ({ ...current, proxy: "NONE" }));
|
||||
setProxyCapabilityError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to inspect server reverse proxy availability",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setCapabilityLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!form.targetContainerId) {
|
||||
setPortOptions([]);
|
||||
setContainerInspectLoading(false);
|
||||
setForm((current) =>
|
||||
current.targetPort === "" ? current : { ...current, targetPort: "" },
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setContainerInspectLoading(true);
|
||||
|
||||
void containersApi
|
||||
.inspect(form.targetContainerId)
|
||||
.then((response) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPortOptions = parseInspectPortOptions(response.data);
|
||||
setPortOptions(nextPortOptions);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
targetPort: nextPortOptions.some(
|
||||
(option) => option.value === current.targetPort,
|
||||
)
|
||||
? current.targetPort
|
||||
: (nextPortOptions[0]?.value ?? ""),
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPortOptions([]);
|
||||
setForm((current) => ({ ...current, targetPort: "" }));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setContainerInspectLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.targetContainerId]);
|
||||
|
||||
const toggle = (key: "sslEnabled" | "autoRenew") => {
|
||||
setForm((current) => {
|
||||
if (key === "sslEnabled") {
|
||||
if (!current.serverId) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextSslEnabled = !current.sslEnabled;
|
||||
|
||||
return {
|
||||
...current,
|
||||
sslEnabled: nextSslEnabled,
|
||||
autoRenew:
|
||||
nextSslEnabled && certbotInstalled
|
||||
? current.autoRenew || true
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!current.sslEnabled) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ...current, autoRenew: !current.autoRenew };
|
||||
});
|
||||
};
|
||||
|
||||
const switchConfigMode = (nextMode: DomainConfigMode) => {
|
||||
setConfigMode(nextMode);
|
||||
setError("");
|
||||
setForm((current) => {
|
||||
if (nextMode === "MANUAL") {
|
||||
return {
|
||||
...current,
|
||||
proxy: "NONE",
|
||||
targetContainerId: "",
|
||||
targetPort: "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
type: "A",
|
||||
proxyConfigMode:
|
||||
current.proxy === "NGINX" ? current.proxyConfigMode : "ISOLATED",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
|
||||
const effectiveType = isContainerMode ? "A" : form.type;
|
||||
const effectiveValue = isContainerMode
|
||||
? (selectedServer?.ip?.trim() ?? "")
|
||||
: form.value.trim();
|
||||
|
||||
if (!effectiveValue) {
|
||||
setError(
|
||||
isContainerMode
|
||||
? "Select a target server so the DNS target IP can be derived automatically."
|
||||
: "DNS target value is required.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectiveType === "A" && isIpv4WithPort(effectiveValue)) {
|
||||
setError(
|
||||
"A record only accepts an IP address. Ports are not valid in DNS records.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isContainerMode && form.proxy === "NONE") {
|
||||
setError("Select a reverse proxy for Container Config mode.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.proxy !== "NONE" && !form.serverId) {
|
||||
setError("Select a target server before choosing a reverse proxy.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.proxy !== "NONE" && !form.targetContainerId) {
|
||||
setError(
|
||||
"Select a target container before provisioning a reverse proxy.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.proxy !== "NONE" && form.targetPort === "") {
|
||||
setError("Select a target port before provisioning a reverse proxy.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.sslEnabled && !form.serverId) {
|
||||
setError("Select a target server before enabling Auto SSL.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.sslEnabled && !certbotInstalled) {
|
||||
setError(
|
||||
"Auto SSL is disabled because Certbot is not installed on the selected server.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const normalizedDomainName = form.name.trim().toLowerCase();
|
||||
const payload = {
|
||||
name: normalizedDomainName,
|
||||
type: effectiveType,
|
||||
value: effectiveValue,
|
||||
serverId: form.serverId || undefined,
|
||||
targetContainerId:
|
||||
form.proxy !== "NONE" ? form.targetContainerId : undefined,
|
||||
targetPort:
|
||||
form.proxy !== "NONE" && form.targetPort !== ""
|
||||
? form.targetPort
|
||||
: undefined,
|
||||
proxy: form.proxy,
|
||||
configMode:
|
||||
form.proxy === "NGINX"
|
||||
? form.proxyConfigMode
|
||||
: ("ISOLATED" as ApiDomainConfigMode),
|
||||
isPrimary: form.proxy === "NGINX" ? form.isPrimary : false,
|
||||
reviewStatus:
|
||||
domainToEdit?.reviewStatus === "NEEDS_REVIEW"
|
||||
? "CONFIRMED"
|
||||
: undefined,
|
||||
sslEnabled: form.sslEnabled,
|
||||
autoRenew: form.autoRenew,
|
||||
};
|
||||
|
||||
if (domainToEdit) {
|
||||
await domainsApi.update(domainToEdit.id, payload);
|
||||
} else {
|
||||
await domainsApi.create(payload);
|
||||
}
|
||||
|
||||
await onSaved(normalizedDomainName, domainToEdit ? "updated" : "created");
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: domainToEdit
|
||||
? "Failed to update domain"
|
||||
: "Failed to add domain",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div
|
||||
className="modal animate-slide-in"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 580,
|
||||
padding: 28,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{isEditMode ? "Edit Domain" : "Add Domain"}
|
||||
</h3>
|
||||
<p
|
||||
style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}
|
||||
>
|
||||
{isEditMode
|
||||
? "Update domain target, reverse proxy, and SSL settings"
|
||||
: "Configure domain with optional auto-SSL"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 14px",
|
||||
marginBottom: 14,
|
||||
fontSize: 13,
|
||||
color: "#ef4444",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={submit}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 14 }}
|
||||
>
|
||||
<AddDomainModeSelector
|
||||
configMode={configMode}
|
||||
onChange={switchConfigMode}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border)",
|
||||
background:
|
||||
configMode === "MANUAL"
|
||||
? "rgba(148,163,184,0.08)"
|
||||
: "rgba(59,130,246,0.08)",
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{configMode === "MANUAL"
|
||||
? "Manual Config is used if you only want to store DNS records such as A, AAAA, CNAME, MX, or TXT. This mode doesn't route traffic to the container. You can still select a Target Server if you want to associate the domain with a specific server or use it for Auto SSL."
|
||||
: "Container Config is used when a domain needs to be directed to a specific container and port through a reverse proxy like Traefik, Nginx, or Caddy. The target DNS IP will follow the selected server IP."}
|
||||
</div>
|
||||
|
||||
<AddDomainDnsFields
|
||||
form={form}
|
||||
isContainerMode={isContainerMode}
|
||||
selectedServerIp={selectedServer?.ip ?? ""}
|
||||
onChange={updateForm}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Server Target{" "}
|
||||
{isContainerMode || form.proxy !== "NONE" || form.sslEnabled
|
||||
? "*"
|
||||
: "(optional)"}
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.serverId}
|
||||
onChange={(event) =>
|
||||
updateForm({
|
||||
serverId: event.target.value,
|
||||
targetContainerId: "",
|
||||
targetPort: "",
|
||||
})
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{serverList.map((server) => (
|
||||
<option key={server.id} value={server.id}>
|
||||
{server.name} ({server.ip})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!isContainerMode && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 6,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
In Manual Config, this field is optional. Select a server only
|
||||
if you want to associate the domain with a specific server or
|
||||
enable Auto SSL for that server.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isContainerMode && (
|
||||
<AddDomainTargetingSection
|
||||
form={form}
|
||||
capabilityLoading={capabilityLoading}
|
||||
proxyCapabilityError={proxyCapabilityError}
|
||||
serverContainers={serverContainers}
|
||||
portOptions={portOptions}
|
||||
containerInspectLoading={containerInspectLoading}
|
||||
selectableProxies={selectableProxies}
|
||||
targetingEnabled={targetingEnabled}
|
||||
allowSharedTargetReuse={
|
||||
isContainerMode &&
|
||||
form.proxy === "NGINX" &&
|
||||
form.proxyConfigMode === "SHARED"
|
||||
}
|
||||
linkedContainerDomains={linkedContainerDomains}
|
||||
linkedPortDomains={linkedPortDomains}
|
||||
onChange={updateForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isContainerMode && form.proxy === "NGINX" && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
background: "var(--bg-input)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Nginx Config Mode
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.proxyConfigMode}
|
||||
onChange={(event) =>
|
||||
updateForm({
|
||||
proxyConfigMode: event.target
|
||||
.value as ApiDomainConfigMode,
|
||||
isPrimary:
|
||||
event.target.value === "ISOLATED"
|
||||
? false
|
||||
: form.isPrimary,
|
||||
})
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="ISOLATED">Isolated Domain Config</option>
|
||||
<option value="SHARED">Shared Service Config</option>
|
||||
</select>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{form.proxyConfigMode === "SHARED"
|
||||
? "Shared mode groups the root domain and its subdomains into one root-domain Nginx file, for example /etc/nginx/sites-available/example.com.conf. Each hostname can still point to a different container and port."
|
||||
: "Isolated mode means exactly one domain per Nginx file, for example /etc/nginx/sites-available/app.example.com.conf. Domain lain seperti api.example.com atau admin.example.com tidak otomatis ikut masuk ke file ini dan harus dibuat sendiri, atau dipindah ke Shared mode jika memang ingin satu config bersama."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isPrimary}
|
||||
disabled={form.proxyConfigMode !== "SHARED"}
|
||||
onChange={(event) =>
|
||||
updateForm({ isPrimary: event.target.checked })
|
||||
}
|
||||
/>
|
||||
Mark this domain as primary within the shared service group
|
||||
</label>
|
||||
|
||||
{form.proxyConfigMode === "SHARED" && (
|
||||
<label
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Shared create allows selecting a container and port that are
|
||||
already used by other Shared Nginx domains on the same server.
|
||||
Mixed reuse with isolated or non-Nginx bindings remains
|
||||
blocked.
|
||||
</label>
|
||||
)}
|
||||
|
||||
{form.proxyConfigMode === "ISOLATED" && (
|
||||
<label
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Jika setelah save domain masih jatuh ke halaman default VPS,
|
||||
biasanya hostname sibling yang Anda akses memang belum dibuat
|
||||
sebagai domain terpisah. Dalam mode Isolated, hanya domain
|
||||
yang sedang disimpan yang akan diprovision ke container
|
||||
target.
|
||||
</label>
|
||||
)}
|
||||
|
||||
{domainToEdit?.reviewStatus === "NEEDS_REVIEW" && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
border: "1px solid rgba(245,158,11,0.35)",
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
fontSize: 12,
|
||||
color: "#f59e0b",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
This domain was inferred during sync and still needs review.
|
||||
Saving your changes will mark the review as confirmed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddDomainSslSection
|
||||
form={form}
|
||||
certbotInstalled={certbotInstalled}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{loading && <Loader2 size={14} className="animate-spin" />}
|
||||
<Shield size={13} /> {isEditMode ? "Save Changes" : "Add Domain"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { DomainConfigMode } from "./domain-types";
|
||||
|
||||
interface AddDomainModeSelectorProps {
|
||||
configMode: DomainConfigMode;
|
||||
onChange: (mode: DomainConfigMode) => void;
|
||||
}
|
||||
|
||||
const CONFIG_OPTIONS: Array<{
|
||||
key: DomainConfigMode;
|
||||
label: string;
|
||||
desc: string;
|
||||
}> = [
|
||||
{
|
||||
key: "MANUAL",
|
||||
label: "Manual Config",
|
||||
desc: "Fill in DNS records manually",
|
||||
},
|
||||
{
|
||||
key: "CONTAINER",
|
||||
label: "Container Config",
|
||||
desc: "Point domain to container via reverse proxy",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AddDomainModeSelector({
|
||||
configMode,
|
||||
onChange,
|
||||
}: AddDomainModeSelectorProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 8,
|
||||
padding: 4,
|
||||
borderRadius: 10,
|
||||
background: "rgba(148,163,184,0.12)",
|
||||
}}
|
||||
>
|
||||
{CONFIG_OPTIONS.map((option) => {
|
||||
const active = configMode === option.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => onChange(option.key)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
border: active
|
||||
? "1px solid var(--accent)"
|
||||
: "1px solid transparent",
|
||||
background: active ? "rgba(59,130,246,0.12)" : "transparent",
|
||||
color: active ? "var(--text-primary)" : "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700 }}>{option.label}</div>
|
||||
<div style={{ fontSize: 11, marginTop: 2 }}>{option.desc}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { AddDomainFormState } from "./domain-types";
|
||||
|
||||
interface AddDomainSslSectionProps {
|
||||
form: AddDomainFormState;
|
||||
certbotInstalled: boolean;
|
||||
sslSupported?: boolean;
|
||||
unsupportedMessage?: string;
|
||||
onToggle: (key: "sslEnabled" | "autoRenew") => void;
|
||||
}
|
||||
|
||||
const SSL_OPTIONS = [
|
||||
{
|
||||
key: "sslEnabled",
|
||||
label: "Auto SSL (Let's Encrypt)",
|
||||
desc: "Generate free SSL certificate",
|
||||
},
|
||||
{
|
||||
key: "autoRenew",
|
||||
label: "Auto-Renew",
|
||||
desc: "Automatically renew before expiry",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function AddDomainSslSection({
|
||||
form,
|
||||
certbotInstalled,
|
||||
sslSupported = true,
|
||||
unsupportedMessage,
|
||||
onToggle,
|
||||
}: AddDomainSslSectionProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
background: "var(--bg-input)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{SSL_OPTIONS.map(({ key, label, desc }) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>{desc}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(key)}
|
||||
disabled={
|
||||
key === "sslEnabled"
|
||||
? !sslSupported || !form.serverId || !certbotInstalled
|
||||
: !sslSupported || !form.sslEnabled || !certbotInstalled
|
||||
}
|
||||
style={{
|
||||
width: 38,
|
||||
height: 21,
|
||||
borderRadius: 11,
|
||||
background: form[key] ? "#3b82f6" : "#334155",
|
||||
border: "none",
|
||||
position: "relative",
|
||||
cursor:
|
||||
key === "sslEnabled"
|
||||
? sslSupported && form.serverId && certbotInstalled
|
||||
? "pointer"
|
||||
: "not-allowed"
|
||||
: sslSupported && form.sslEnabled && certbotInstalled
|
||||
? "pointer"
|
||||
: "not-allowed",
|
||||
flexShrink: 0,
|
||||
opacity:
|
||||
key === "sslEnabled"
|
||||
? sslSupported && form.serverId && certbotInstalled
|
||||
? 1
|
||||
: 0.5
|
||||
: sslSupported && form.sslEnabled && certbotInstalled
|
||||
? 1
|
||||
: 0.5,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
left: form[key] ? "auto" : 2,
|
||||
right: form[key] ? 2 : "auto",
|
||||
width: 17,
|
||||
height: 17,
|
||||
borderRadius: "50%",
|
||||
background: "white",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!form.serverId && (
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
Auto SSL is only available after a target server is selected.
|
||||
</p>
|
||||
)}
|
||||
{form.serverId && !sslSupported && unsupportedMessage && (
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{unsupportedMessage}
|
||||
</p>
|
||||
)}
|
||||
{form.serverId && !certbotInstalled && (
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
Auto SSL is disabled because Certbot is not installed on the selected
|
||||
server.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { Container, DomainProxy } from "@/lib/api";
|
||||
import { AddDomainFormState, PortOption } from "./domain-types";
|
||||
|
||||
interface AddDomainTargetingSectionProps {
|
||||
form: AddDomainFormState;
|
||||
capabilityLoading: boolean;
|
||||
proxyCapabilityError: string;
|
||||
serverContainers: Container[];
|
||||
portOptions: PortOption[];
|
||||
containerInspectLoading: boolean;
|
||||
selectableProxies: DomainProxy[];
|
||||
targetingEnabled: boolean;
|
||||
allowSharedTargetReuse: boolean;
|
||||
linkedContainerDomains: Map<string, string[]>;
|
||||
linkedPortDomains: Map<number, string[]>;
|
||||
onChange: (updates: Partial<AddDomainFormState>) => void;
|
||||
}
|
||||
|
||||
const PROXY_OPTIONS: DomainProxy[] = ["NONE", "TRAEFIK", "NGINX", "CADDY"];
|
||||
|
||||
export default function AddDomainTargetingSection({
|
||||
form,
|
||||
capabilityLoading,
|
||||
proxyCapabilityError,
|
||||
serverContainers,
|
||||
portOptions,
|
||||
containerInspectLoading,
|
||||
selectableProxies,
|
||||
targetingEnabled,
|
||||
allowSharedTargetReuse,
|
||||
linkedContainerDomains,
|
||||
linkedPortDomains,
|
||||
onChange,
|
||||
}: AddDomainTargetingSectionProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Target Container *
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.targetContainerId}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
targetContainerId: event.target.value,
|
||||
targetPort: "",
|
||||
})
|
||||
}
|
||||
disabled={!form.serverId || capabilityLoading || !targetingEnabled}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="">— Select container —</option>
|
||||
{serverContainers.map((container) => {
|
||||
const linkedDomains =
|
||||
linkedContainerDomains.get(container.id) ?? [];
|
||||
const disabled =
|
||||
linkedDomains.length > 0 && !allowSharedTargetReuse;
|
||||
|
||||
return (
|
||||
<option
|
||||
key={container.id}
|
||||
value={container.id}
|
||||
disabled={disabled}
|
||||
>
|
||||
{disabled
|
||||
? `${container.name} (${container.image}) - used by ${linkedDomains.length > 1 ? "1+ domains" : linkedDomains.join(", ")}`
|
||||
: `${container.name} (${container.image})`}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Target Port *
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.targetPort}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
targetPort: event.target.value
|
||||
? Number(event.target.value)
|
||||
: "",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
!targetingEnabled ||
|
||||
!form.targetContainerId ||
|
||||
containerInspectLoading ||
|
||||
portOptions.length === 0
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="">— Select port —</option>
|
||||
{portOptions.map((option) => {
|
||||
const linkedDomains = linkedPortDomains.get(option.value) ?? [];
|
||||
const disabled =
|
||||
linkedDomains.length > 0 && !allowSharedTargetReuse;
|
||||
|
||||
return (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
{disabled
|
||||
? `${option.label} - used by ${linkedDomains.join(", ")}`
|
||||
: option.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.serverId && serverContainers.length === 0 && (
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
No running containers were found on the selected server.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Reverse Proxy
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{PROXY_OPTIONS.map((proxy) => {
|
||||
const disabled =
|
||||
proxy !== "NONE" &&
|
||||
(!form.serverId ||
|
||||
capabilityLoading ||
|
||||
!selectableProxies.includes(proxy));
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={proxy}
|
||||
onClick={() => onChange({ proxy })}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "8px 0",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${form.proxy === proxy ? "var(--accent-green)" : "var(--border)"}`,
|
||||
background: disabled
|
||||
? "rgba(148,163,184,0.12)"
|
||||
: form.proxy === proxy
|
||||
? "rgba(59,130,246,0.1)"
|
||||
: "var(--bg-primary)",
|
||||
color: disabled
|
||||
? "rgba(100,116,139,0.8)"
|
||||
: form.proxy === proxy
|
||||
? "var(--accent-green)"
|
||||
: "var(--accent-green)",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.65 : 1,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{proxy === "NONE"
|
||||
? "None"
|
||||
: proxy === "CADDY"
|
||||
? "Caddy"
|
||||
: proxy.charAt(0) + proxy.slice(1).toLowerCase()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
|
||||
{form.serverId
|
||||
? capabilityLoading
|
||||
? "Inspecting selected server for available reverse proxies..."
|
||||
: "Choose container and port first, then pick one of the proxies detected on the selected server."
|
||||
: "Select Server Target first to enable proxy-aware validation."}
|
||||
</p>
|
||||
{proxyCapabilityError && (
|
||||
<p style={{ fontSize: 11, color: "#f59e0b", marginTop: 4 }}>
|
||||
{proxyCapabilityError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
// <>
|
||||
// <div>
|
||||
// <label
|
||||
// style={{
|
||||
// fontSize: 12,
|
||||
// color: "var(--text-muted)",
|
||||
// display: "block",
|
||||
// marginBottom: 5,
|
||||
// }}
|
||||
// >
|
||||
// Reverse Proxy
|
||||
// </label>
|
||||
// <div style={{ display: "flex", gap: 8 }}>
|
||||
// {PROXY_OPTIONS.map((proxy) => {
|
||||
// const disabled =
|
||||
// proxy !== "NONE" &&
|
||||
// (!form.serverId ||
|
||||
// capabilityLoading ||
|
||||
// !selectableProxies.includes(proxy));
|
||||
|
||||
// return (
|
||||
// <button
|
||||
// type="button"
|
||||
// key={proxy}
|
||||
// onClick={() => onChange({ proxy })}
|
||||
// disabled={disabled}
|
||||
// style={{
|
||||
// flex: 1,
|
||||
// padding: "8px 0",
|
||||
// borderRadius: 8,
|
||||
// border: `1px solid ${form.proxy === proxy ? "var(--accent)" : "var(--border)"}`,
|
||||
// background: disabled
|
||||
// ? "rgba(148,163,184,0.12)"
|
||||
// : form.proxy === proxy
|
||||
// ? "rgba(59,130,246,0.1)"
|
||||
// : "var(--bg-input)",
|
||||
// color: disabled
|
||||
// ? "rgba(100,116,139,0.8)"
|
||||
// : form.proxy === proxy
|
||||
// ? "var(--accent)"
|
||||
// : "var(--text-muted)",
|
||||
// cursor: disabled ? "not-allowed" : "pointer",
|
||||
// opacity: disabled ? 0.65 : 1,
|
||||
// fontSize: 12,
|
||||
// fontWeight: 600,
|
||||
// }}
|
||||
// >
|
||||
// {proxy === "NONE"
|
||||
// ? "None"
|
||||
// : proxy === "CADDY"
|
||||
// ? "Caddy"
|
||||
// : proxy.charAt(0) + proxy.slice(1).toLowerCase()}
|
||||
// </button>
|
||||
// );
|
||||
// })}
|
||||
// </div>
|
||||
// <p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
|
||||
// {form.serverId
|
||||
// ? capabilityLoading
|
||||
// ? "Inspecting selected server for available reverse proxies..."
|
||||
// : "Choose container and port first, then pick one of the proxies detected on the selected server."
|
||||
// : "Select Server Target first to enable proxy-aware validation."}
|
||||
// </p>
|
||||
// {proxyCapabilityError && (
|
||||
// <p style={{ fontSize: 11, color: "#f59e0b", marginTop: 4 }}>
|
||||
// {proxyCapabilityError}
|
||||
// </p>
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
// <div
|
||||
// style={{
|
||||
// display: "grid",
|
||||
// gridTemplateColumns: "1fr 1fr",
|
||||
// gap: 12,
|
||||
// }}
|
||||
// >
|
||||
// <div>
|
||||
// <label
|
||||
// style={{
|
||||
// fontSize: 12,
|
||||
// color: "var(--text-muted)",
|
||||
// display: "block",
|
||||
// marginBottom: 5,
|
||||
// }}
|
||||
// >
|
||||
// Target Container *
|
||||
// </label>
|
||||
// <select
|
||||
// className="input"
|
||||
// value={form.targetContainerId}
|
||||
// onChange={(event) =>
|
||||
// onChange({
|
||||
// targetContainerId: event.target.value,
|
||||
// targetPort: "",
|
||||
// })
|
||||
// }
|
||||
// disabled={!form.serverId || capabilityLoading || !targetingEnabled}
|
||||
// style={{ width: "100%" }}
|
||||
// >
|
||||
// <option value="">— Select container —</option>
|
||||
// {serverContainers.map((container) => (
|
||||
// <option key={container.id} value={container.id}>
|
||||
// {container.name} ({container.image})
|
||||
// </option>
|
||||
// ))}
|
||||
// </select>
|
||||
// </div>
|
||||
// <div>
|
||||
// <label
|
||||
// style={{
|
||||
// fontSize: 12,
|
||||
// color: "var(--text-muted)",
|
||||
// display: "block",
|
||||
// marginBottom: 5,
|
||||
// }}
|
||||
// >
|
||||
// Target Port *
|
||||
// </label>
|
||||
// <select
|
||||
// className="input"
|
||||
// value={form.targetPort}
|
||||
// onChange={(event) =>
|
||||
// onChange({
|
||||
// targetPort: event.target.value
|
||||
// ? Number(event.target.value)
|
||||
// : "",
|
||||
// })
|
||||
// }
|
||||
// disabled={
|
||||
// !targetingEnabled ||
|
||||
// !form.targetContainerId ||
|
||||
// containerInspectLoading ||
|
||||
// portOptions.length === 0
|
||||
// }
|
||||
// style={{ width: "100%" }}
|
||||
// >
|
||||
// <option value="">— Select port —</option>
|
||||
// {portOptions.map((option) => (
|
||||
// <option key={option.value} value={option.value}>
|
||||
// {option.label}
|
||||
// </option>
|
||||
// ))}
|
||||
// </select>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {form.serverId && serverContainers.length === 0 && (
|
||||
// <p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
// No running containers were found on the selected server.
|
||||
// </p>
|
||||
// )}
|
||||
// </>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { DomainDiscoverySource } from "@/lib/api";
|
||||
import { Zap } from "lucide-react";
|
||||
import { getDiscoverySourceMeta } from "./domain-utils";
|
||||
|
||||
interface DiscoveryBadgeProps {
|
||||
source: DomainDiscoverySource;
|
||||
}
|
||||
|
||||
export default function DiscoveryBadge({ source }: DiscoveryBadgeProps) {
|
||||
const meta = getDiscoverySourceMeta(source);
|
||||
|
||||
return (
|
||||
<span
|
||||
title={`${meta.label} ${meta.detail}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
marginTop: 5,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: meta.color,
|
||||
background: meta.background,
|
||||
border: `1px solid ${meta.border}`,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Zap size={10} />
|
||||
{meta.label} {meta.detail}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Globe,
|
||||
Lock,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DomainStatsProps {
|
||||
total: number;
|
||||
active: number;
|
||||
sslValid: number;
|
||||
sslExpiring: number;
|
||||
}
|
||||
|
||||
type StatItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
export default function DomainStats({
|
||||
total,
|
||||
active,
|
||||
sslValid,
|
||||
sslExpiring,
|
||||
}: DomainStatsProps) {
|
||||
const items: StatItem[] = [
|
||||
{
|
||||
label: "Total Domains",
|
||||
value: total,
|
||||
color: "#3b82f6",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
label: "Active",
|
||||
value: active,
|
||||
color: "#10b981",
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
label: "SSL Valid",
|
||||
value: sslValid,
|
||||
color: "#8b5cf6",
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
label: "SSL Expiring",
|
||||
value: sslExpiring,
|
||||
color: "#f59e0b",
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 10,
|
||||
background: `${item.color}15`,
|
||||
border: `1px solid ${item.color}25`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Icon size={18} style={{ color: item.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import TablePagination from "@/components/TablePagination";
|
||||
import { Domain, SslCert } from "@/lib/api";
|
||||
import {
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import DiscoveryBadge from "./DiscoveryBadge";
|
||||
import SSLBadge from "./SSLBadge";
|
||||
import { formatDate, formatProxyLabel, sslStatusKey } from "./domain-utils";
|
||||
|
||||
function renderMetaBadge(
|
||||
label: string,
|
||||
styles: { color: string; background: string; border: string },
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 999,
|
||||
color: styles.color,
|
||||
background: styles.background,
|
||||
border: `1px solid ${styles.border}`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type DomainsTablePagination = {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
startItem: number;
|
||||
endItem: number;
|
||||
paginatedItems: Domain[];
|
||||
setCurrentPage: (page: number) => void;
|
||||
};
|
||||
|
||||
interface DomainsTableProps {
|
||||
domains: Domain[];
|
||||
pagination: DomainsTablePagination;
|
||||
deleting: string | null;
|
||||
renewing: string | null;
|
||||
onEdit: (domain: Domain) => void;
|
||||
onDelete: (id: string, name: string) => void;
|
||||
onRenew: (cert: SslCert) => void;
|
||||
}
|
||||
|
||||
export default function DomainsTable({
|
||||
domains,
|
||||
pagination,
|
||||
deleting,
|
||||
renewing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRenew,
|
||||
}: DomainsTableProps) {
|
||||
if (domains.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Type</th>
|
||||
<th>Target / IP</th>
|
||||
<th>SSL Status</th>
|
||||
<th>SSL Expiry</th>
|
||||
<th>Proxy</th>
|
||||
<th>Server</th>
|
||||
<th style={{ textAlign: "right" }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagination.paginatedItems.map((domain) => {
|
||||
const status = sslStatusKey(domain.sslCert);
|
||||
|
||||
return (
|
||||
<tr key={domain.id}>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Globe
|
||||
size={13}
|
||||
style={{ color: "#3b82f6", flexShrink: 0 }}
|
||||
/>
|
||||
<div>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{domain.name}
|
||||
</span>
|
||||
{!domain.isActive && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
background: "var(--bg-input)",
|
||||
padding: "1px 5px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
inactive
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<DiscoveryBadge source={domain.discoverySource} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 6,
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{renderMetaBadge(
|
||||
domain.configMode === "SHARED"
|
||||
? domain.isPrimary
|
||||
? "Shared Primary"
|
||||
: "Shared"
|
||||
: "Isolated",
|
||||
domain.configMode === "SHARED"
|
||||
? {
|
||||
color: "#3b82f6",
|
||||
background: "rgba(59,130,246,0.12)",
|
||||
border: "rgba(59,130,246,0.28)",
|
||||
}
|
||||
: {
|
||||
color: "#64748b",
|
||||
background: "rgba(100,116,139,0.12)",
|
||||
border: "rgba(100,116,139,0.25)",
|
||||
},
|
||||
)}
|
||||
{domain.reviewStatus === "NEEDS_REVIEW" &&
|
||||
renderMetaBadge("Needs Review", {
|
||||
color: "#f59e0b",
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
border: "rgba(245,158,11,0.28)",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
background: "rgba(6,182,212,0.1)",
|
||||
color: "#06b6d4",
|
||||
border: "1px solid rgba(6,182,212,0.25)",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 5,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
}}
|
||||
>
|
||||
{domain.type}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{domain.value}
|
||||
</td>
|
||||
<td>
|
||||
<SSLBadge status={status} />
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
color:
|
||||
status === "expiring" ? "#f59e0b" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{formatDate(domain.sslCert?.expiresAt)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-secondary)",
|
||||
background: "var(--bg-input)",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 5,
|
||||
border: "1px solid var(--border)",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{formatProxyLabel(domain.proxy)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{domain.server?.name ?? "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={`https://${domain.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "5px 8px" }}
|
||||
title="Open domain"
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "5px 8px" }}
|
||||
title="Edit domain"
|
||||
onClick={() => onEdit(domain)}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
{domain.sslCert && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "5px 8px" }}
|
||||
title="Renew SSL"
|
||||
onClick={() => onRenew(domain.sslCert as SslCert)}
|
||||
disabled={renewing === domain.sslCert.id}
|
||||
>
|
||||
{renewing === domain.sslCert.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Shield size={11} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ padding: "5px 8px" }}
|
||||
title="Delete"
|
||||
onClick={() => onDelete(domain.id, domain.name)}
|
||||
disabled={deleting === domain.id}
|
||||
>
|
||||
{deleting === domain.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={11} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<TablePagination
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.totalItems}
|
||||
startItem={pagination.startItem}
|
||||
endItem={pagination.endItem}
|
||||
itemLabel="domains"
|
||||
onPageChange={pagination.setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { AlertTriangle, CheckCircle, Clock } from "lucide-react";
|
||||
import { SslStatusKey } from "./domain-types";
|
||||
|
||||
interface SSLBadgeProps {
|
||||
status: SslStatusKey;
|
||||
}
|
||||
|
||||
export default function SSLBadge({ status }: SSLBadgeProps) {
|
||||
const cfg: Record<
|
||||
SslStatusKey,
|
||||
{ bg: string; color: string; border: string; label: string }
|
||||
> = {
|
||||
valid: {
|
||||
bg: "rgba(16,185,129,0.1)",
|
||||
color: "#10b981",
|
||||
border: "rgba(16,185,129,0.3)",
|
||||
label: "Valid",
|
||||
},
|
||||
expiring: {
|
||||
bg: "rgba(245,158,11,0.1)",
|
||||
color: "#f59e0b",
|
||||
border: "rgba(245,158,11,0.3)",
|
||||
label: "Expiring",
|
||||
},
|
||||
expired: {
|
||||
bg: "rgba(239,68,68,0.1)",
|
||||
color: "#ef4444",
|
||||
border: "rgba(239,68,68,0.3)",
|
||||
label: "Expired",
|
||||
},
|
||||
pending: {
|
||||
bg: "rgba(139,92,246,0.1)",
|
||||
color: "#8b5cf6",
|
||||
border: "rgba(139,92,246,0.3)",
|
||||
label: "Pending",
|
||||
},
|
||||
none: {
|
||||
bg: "rgba(100,116,139,0.1)",
|
||||
color: "#64748b",
|
||||
border: "rgba(100,116,139,0.3)",
|
||||
label: "No SSL",
|
||||
},
|
||||
};
|
||||
|
||||
const meta = cfg[status];
|
||||
const Icon =
|
||||
status === "valid"
|
||||
? CheckCircle
|
||||
: status === "expiring" || status === "expired"
|
||||
? AlertTriangle
|
||||
: Clock;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
background: meta.bg,
|
||||
color: meta.color,
|
||||
border: `1px solid ${meta.border}`,
|
||||
padding: "3px 9px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<Icon size={10} />
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Domain, SslCert, SslRenewOperation } from "@/lib/api";
|
||||
import { Loader2, Lock, RefreshCw, Zap } from "lucide-react";
|
||||
import SSLBadge from "./SSLBadge";
|
||||
import { formatDate, sslStatusKey } from "./domain-utils";
|
||||
|
||||
interface SslCertificatesGridProps {
|
||||
domains: Domain[];
|
||||
renewing: string | null;
|
||||
renewBusy: boolean;
|
||||
renewOperations?: Record<string, SslRenewOperation>;
|
||||
onRenew: (cert: SslCert) => void;
|
||||
}
|
||||
|
||||
export default function SslCertificatesGrid({
|
||||
domains,
|
||||
renewing,
|
||||
renewBusy,
|
||||
renewOperations = {},
|
||||
onRenew,
|
||||
}: SslCertificatesGridProps) {
|
||||
if (domains.length === 0) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 48, textAlign: "center" }}>
|
||||
<Lock
|
||||
size={36}
|
||||
style={{ color: "var(--text-muted)", marginBottom: 12 }}
|
||||
/>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
|
||||
No SSL certificates found. Enable SSL when adding a domain.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(380px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{domains.map((domain) => {
|
||||
const status = sslStatusKey(domain.sslCert);
|
||||
const renewOperation = domain.sslCert
|
||||
? renewOperations[domain.sslCert.id]
|
||||
: null;
|
||||
const statusColor =
|
||||
status === "valid"
|
||||
? "#10b981"
|
||||
: status === "expiring"
|
||||
? "#f59e0b"
|
||||
: "#ef4444";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={domain.id}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 18,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -15,
|
||||
right: -15,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: "50%",
|
||||
background: statusColor,
|
||||
opacity: 0.06,
|
||||
filter: "blur(15px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: `${statusColor}18`,
|
||||
border: `1px solid ${statusColor}30`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Lock size={15} style={{ color: statusColor }} />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{domain.name}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{domain.sslCert?.issuer ?? "Let's Encrypt"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SSLBadge status={status} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
Expiry
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
color:
|
||||
status === "expiring"
|
||||
? "#f59e0b"
|
||||
: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{formatDate(domain.sslCert?.expiresAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
Server
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{domain.server?.name ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
Auto-Renew
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: domain.sslCert?.autoRenew
|
||||
? "#10b981"
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{domain.sslCert?.autoRenew ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{renewOperation ? (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 10px",
|
||||
marginTop: 12,
|
||||
background: "rgba(59,130,246,0.06)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-secondary)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0,
|
||||
marginBottom: 3,
|
||||
}}
|
||||
>
|
||||
{renewOperation.stage.replace(/_/g, " ")}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{renewOperation.message ?? "Renewal is still running"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 14, display: "flex", gap: 6 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ flex: 1, fontSize: 11, padding: "5px" }}
|
||||
onClick={() => domain.sslCert && onRenew(domain.sslCert)}
|
||||
disabled={renewBusy}
|
||||
>
|
||||
{renewing === domain.sslCert?.id ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={11} />
|
||||
)}{" "}
|
||||
{renewing === domain.sslCert?.id ? "Renewing..." : "Renew"}
|
||||
</button>
|
||||
{status === "expiring" && domain.sslCert && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1, fontSize: 11, padding: "5px" }}
|
||||
onClick={() => onRenew(domain.sslCert as SslCert)}
|
||||
disabled={renewBusy}
|
||||
>
|
||||
<Zap size={11} /> Urgent Renew
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
DomainConfigMode as ApiDomainConfigMode,
|
||||
DomainProxy,
|
||||
} from "@/lib/api";
|
||||
|
||||
export type AddDomainFormState = {
|
||||
name: string;
|
||||
type: "A" | "AAAA" | "CNAME" | "MX" | "TXT";
|
||||
value: string;
|
||||
serverId: string;
|
||||
targetContainerId: string;
|
||||
targetPort: number | "";
|
||||
proxy: DomainProxy;
|
||||
proxyConfigMode: ApiDomainConfigMode;
|
||||
isPrimary: boolean;
|
||||
sslEnabled: boolean;
|
||||
autoRenew: boolean;
|
||||
};
|
||||
|
||||
export type DomainConfigMode = "MANUAL" | "CONTAINER";
|
||||
|
||||
export type DomainTab = "domains" | "ssl";
|
||||
|
||||
export type SslStatusKey =
|
||||
| "valid"
|
||||
| "expiring"
|
||||
| "expired"
|
||||
| "pending"
|
||||
| "none";
|
||||
|
||||
export type PortOption = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
Container,
|
||||
DomainDiscoverySource,
|
||||
DomainProxy,
|
||||
ServerConfigSnapshot,
|
||||
SslCert,
|
||||
} from "@/lib/api";
|
||||
import { SslStatusKey } from "./domain-types";
|
||||
|
||||
export function sslStatusKey(cert: SslCert | null | undefined): SslStatusKey {
|
||||
if (!cert) return "none";
|
||||
|
||||
switch (cert.status) {
|
||||
case "VALID":
|
||||
return "valid";
|
||||
case "EXPIRING":
|
||||
return "expiring";
|
||||
case "EXPIRED":
|
||||
return "expired";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(d: string | null | undefined): string {
|
||||
if (!d) return "—";
|
||||
|
||||
return new Date(d).toLocaleDateString("en-GB", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function isIpv4WithPort(value: string): boolean {
|
||||
return /^(?:\d{1,3}\.){3}\d{1,3}:\d{1,5}$/.test(value.trim());
|
||||
}
|
||||
|
||||
export function detectAvailableProxies(
|
||||
snapshot: ServerConfigSnapshot,
|
||||
containers: Container[],
|
||||
): DomainProxy[] {
|
||||
const available = new Set<DomainProxy>(["NONE"]);
|
||||
|
||||
if (
|
||||
snapshot.webServer.components.some(
|
||||
(component) =>
|
||||
component.key === "nginx" &&
|
||||
component.installed &&
|
||||
component.active === "active",
|
||||
)
|
||||
) {
|
||||
available.add("NGINX");
|
||||
}
|
||||
|
||||
if (
|
||||
snapshot.webServer.components.some(
|
||||
(component) =>
|
||||
component.key === "caddy" &&
|
||||
component.installed &&
|
||||
component.active === "active",
|
||||
)
|
||||
) {
|
||||
available.add("CADDY");
|
||||
}
|
||||
|
||||
if (
|
||||
containers.some((container) =>
|
||||
`${container.name} ${container.image}`.toLowerCase().includes("traefik"),
|
||||
)
|
||||
) {
|
||||
available.add("TRAEFIK");
|
||||
}
|
||||
|
||||
return ["NONE", "TRAEFIK", "NGINX", "CADDY"].filter((proxy) =>
|
||||
available.has(proxy as DomainProxy),
|
||||
) as DomainProxy[];
|
||||
}
|
||||
|
||||
export function parseInspectPortOptions(
|
||||
inspect: Record<string, unknown>,
|
||||
): Array<{ value: number; label: string }> {
|
||||
const networkSettings = (inspect.NetworkSettings ?? {}) as {
|
||||
Ports?: Record<
|
||||
string,
|
||||
Array<{ HostPort?: string; HostIp?: string }> | null
|
||||
>;
|
||||
};
|
||||
const config = (inspect.Config ?? {}) as {
|
||||
ExposedPorts?: Record<string, Record<string, never>>;
|
||||
};
|
||||
|
||||
const options = new Map<number, string>();
|
||||
|
||||
for (const [rawPort, hosts] of Object.entries(networkSettings.Ports ?? {})) {
|
||||
const [containerPortRaw, protocol = "tcp"] = rawPort.split("/");
|
||||
const containerPort = Number(containerPortRaw);
|
||||
|
||||
if (!Number.isFinite(containerPort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostLabels = (hosts ?? [])
|
||||
.map((host) => host?.HostPort)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(", ");
|
||||
|
||||
options.set(
|
||||
containerPort,
|
||||
hostLabels
|
||||
? `${containerPort}/${protocol} (published as ${hostLabels})`
|
||||
: `${containerPort}/${protocol} (container internal)`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const rawPort of Object.keys(config.ExposedPorts ?? {})) {
|
||||
const [containerPortRaw, protocol = "tcp"] = rawPort.split("/");
|
||||
const containerPort = Number(containerPortRaw);
|
||||
|
||||
if (!Number.isFinite(containerPort) || options.has(containerPort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.set(
|
||||
containerPort,
|
||||
`${containerPort}/${protocol} (container internal)`,
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(options.entries())
|
||||
.sort((left, right) => left[0] - right[0])
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
}
|
||||
|
||||
export function formatProxyLabel(proxy: DomainProxy): string {
|
||||
if (proxy === "NONE") return "None";
|
||||
if (proxy === "TRAEFIK") return "Traefik";
|
||||
if (proxy === "NGINX") return "Nginx";
|
||||
return "Caddy";
|
||||
}
|
||||
|
||||
export function getDiscoverySourceMeta(source: DomainDiscoverySource): {
|
||||
label: string;
|
||||
detail: string;
|
||||
color: string;
|
||||
background: string;
|
||||
border: string;
|
||||
} {
|
||||
switch (source) {
|
||||
case "NGINX":
|
||||
return {
|
||||
label: "Detected",
|
||||
detail: "from Nginx",
|
||||
color: "#10b981",
|
||||
background: "rgba(16,185,129,0.12)",
|
||||
border: "rgba(16,185,129,0.28)",
|
||||
};
|
||||
case "TRAEFIK":
|
||||
return {
|
||||
label: "Detected",
|
||||
detail: "from Traefik",
|
||||
color: "#f59e0b",
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
border: "rgba(245,158,11,0.28)",
|
||||
};
|
||||
case "CADDY":
|
||||
return {
|
||||
label: "Detected",
|
||||
detail: "from Caddy file",
|
||||
color: "#3b82f6",
|
||||
background: "rgba(59,130,246,0.12)",
|
||||
border: "rgba(59,130,246,0.28)",
|
||||
};
|
||||
case "CADDY_ADMIN":
|
||||
return {
|
||||
label: "Detected",
|
||||
detail: "from Caddy Admin API",
|
||||
color: "#8b5cf6",
|
||||
background: "rgba(139,92,246,0.12)",
|
||||
border: "rgba(139,92,246,0.28)",
|
||||
};
|
||||
case "CERTBOT":
|
||||
return {
|
||||
label: "Detected",
|
||||
detail: "from SSL certificate",
|
||||
color: "#06b6d4",
|
||||
background: "rgba(6,182,212,0.12)",
|
||||
border: "rgba(6,182,212,0.28)",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: "Manual",
|
||||
detail: "added manually",
|
||||
color: "#64748b",
|
||||
background: "rgba(100,116,139,0.12)",
|
||||
border: "rgba(100,116,139,0.25)",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import ToastViewport from "@/components/ToastViewport";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Globe, Loader2, Lock, Plus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
domains as domainsApi,
|
||||
sslCerts as sslApi,
|
||||
Domain,
|
||||
Server,
|
||||
SslCert,
|
||||
SslRenewOperation,
|
||||
servers as serversApi,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
readCachedPageData,
|
||||
readStoredServerSelection,
|
||||
storeServerSelection,
|
||||
writeCachedPageData,
|
||||
} from "@/lib/page-state";
|
||||
import { useTablePagination } from "@/lib/use-table-pagination";
|
||||
import { useToastManager } from "@/lib/use-toast-manager";
|
||||
import AddDomainModal from "./components/AddDomainModal";
|
||||
import DomainStats from "./components/DomainStats";
|
||||
import DomainsTable from "./components/DomainsTable";
|
||||
import SslCertificatesGrid from "./components/SslCertificatesGrid";
|
||||
|
||||
const PAGE_KEY = "domains";
|
||||
const RENEW_POLL_INTERVAL_MS = 3000;
|
||||
const RENEW_POLL_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
type PendingConfirmAction = {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
tone?: "danger" | "warning" | "info";
|
||||
note?: string;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { toasts, pushToast, dismissToast } = useToastManager();
|
||||
const [data, setData] = useState<Domain[]>([]);
|
||||
const [serverList, setServerList] = useState<Server[]>([]);
|
||||
const [selectedServerId, setSelectedServerId] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState("");
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [editingDomain, setEditingDomain] = useState<Domain | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"domains" | "ssl">("domains");
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [renewing, setRenewing] = useState<string | null>(null);
|
||||
const [renewOperations, setRenewOperations] = useState<
|
||||
Record<string, SslRenewOperation>
|
||||
>({});
|
||||
const [confirmDialog, setConfirmDialog] =
|
||||
useState<PendingConfirmAction | null>(null);
|
||||
|
||||
const applyCachedState = useCallback((serverId: string) => {
|
||||
const cached = readCachedPageData<{
|
||||
items: Domain[];
|
||||
serverList: Server[];
|
||||
}>(PAGE_KEY, serverId);
|
||||
|
||||
if (!cached) return false;
|
||||
|
||||
setData(cached.items);
|
||||
setServerList(cached.serverList);
|
||||
setLoading(false);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (serverId = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [dr, sr] = await Promise.all([
|
||||
domainsApi.list(),
|
||||
serversApi.list(),
|
||||
]);
|
||||
const nextData = dr.data ?? [];
|
||||
const nextServers = sr.data ?? [];
|
||||
setData(nextData);
|
||||
setServerList(nextServers);
|
||||
setSelectedServerId(serverId);
|
||||
storeServerSelection(PAGE_KEY, serverId);
|
||||
writeCachedPageData(
|
||||
PAGE_KEY,
|
||||
{
|
||||
items: nextData,
|
||||
serverList: nextServers,
|
||||
},
|
||||
serverId,
|
||||
);
|
||||
} catch {
|
||||
/* silently fail */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedServerId = readStoredServerSelection(PAGE_KEY);
|
||||
setSelectedServerId(storedServerId);
|
||||
const hasCache = applyCachedState(storedServerId);
|
||||
if (!hasCache) {
|
||||
void load(storedServerId);
|
||||
}
|
||||
}, [applyCachedState, load]);
|
||||
|
||||
const handleServerChange = async (serverId: string) => {
|
||||
if (serverId === selectedServerId) return;
|
||||
storeServerSelection(PAGE_KEY, serverId);
|
||||
setSelectedServerId(serverId);
|
||||
const hasCache = applyCachedState(serverId);
|
||||
if (!hasCache) {
|
||||
await load(serverId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDomainSaved = useCallback(
|
||||
async (domainName: string, operation: "created" | "updated") => {
|
||||
await load(selectedServerId);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: operation === "created" ? "Domain Added" : "Domain Updated",
|
||||
message:
|
||||
operation === "created"
|
||||
? `Domain "${domainName}" added successfully`
|
||||
: `Domain "${domainName}" updated successfully`,
|
||||
});
|
||||
},
|
||||
[load, pushToast, selectedServerId],
|
||||
);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setSyncMessage("Scanning domain configs on server...");
|
||||
try {
|
||||
await domainsApi.sync(
|
||||
selectedServerId ? { serverId: selectedServerId } : undefined,
|
||||
);
|
||||
setSyncMessage("Scanning SSL certificates on server...");
|
||||
await sslApi.sync(
|
||||
selectedServerId ? { serverId: selectedServerId } : undefined,
|
||||
);
|
||||
setSyncMessage("Refreshing local data...");
|
||||
await load(selectedServerId);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Sync Complete",
|
||||
message: "Domain and SSL inventory refreshed successfully",
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Sync Failed",
|
||||
message: err instanceof Error ? err.message : "Sync failed",
|
||||
});
|
||||
} finally {
|
||||
setSyncMessage("");
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteDomain = async (id: string, name: string) => {
|
||||
setDeleting(id);
|
||||
try {
|
||||
await domainsApi.delete(id);
|
||||
setData((prev) => prev.filter((d) => d.id !== id));
|
||||
await load(selectedServerId);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
message: `Domain \"${name}\" deleted successfully`,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
message: err instanceof Error ? err.message : "Delete failed",
|
||||
});
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string, name: string) => {
|
||||
setConfirmDialog({
|
||||
title: "Delete Domain",
|
||||
description: `Delete domain "${name}"?`,
|
||||
confirmLabel: "Delete Domain",
|
||||
tone: "danger",
|
||||
note: "Associated DNS, routing, or SSL setup may need to be recreated after removal.",
|
||||
onConfirm: () => {
|
||||
void deleteDomain(id, name);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRenew = async (cert: SslCert) => {
|
||||
const domainName =
|
||||
data.find((domain) => domain.sslCert?.id === cert.id)?.name ??
|
||||
cert.domain?.name ??
|
||||
cert.domainId;
|
||||
|
||||
setRenewing(cert.id);
|
||||
try {
|
||||
pushToast({
|
||||
tone: "info",
|
||||
title: "SSL Renewal",
|
||||
message: `Renewal started for ${domainName}. We will keep checking the progress automatically.`,
|
||||
});
|
||||
|
||||
const operation = (await sslApi.renew(cert.id)).data;
|
||||
if (!operation) {
|
||||
throw new Error("Renew operation did not return a status handle");
|
||||
}
|
||||
|
||||
let latestOperation: SslRenewOperation = operation;
|
||||
setRenewOperations((current) => ({
|
||||
...current,
|
||||
[cert.id]: latestOperation,
|
||||
}));
|
||||
const deadline = Date.now() + RENEW_POLL_TIMEOUT_MS;
|
||||
|
||||
while (latestOperation.status === "RUNNING" && Date.now() < deadline) {
|
||||
await delay(RENEW_POLL_INTERVAL_MS);
|
||||
latestOperation = (
|
||||
await sslApi.getRenewOperation(operation.operationId)
|
||||
).data;
|
||||
setRenewOperations((current) => ({
|
||||
...current,
|
||||
[cert.id]: latestOperation,
|
||||
}));
|
||||
}
|
||||
|
||||
if (latestOperation.status === "FAILED") {
|
||||
throw new Error(
|
||||
latestOperation.error ||
|
||||
latestOperation.message ||
|
||||
`Failed to renew certificate for ${domainName}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (latestOperation.status !== "COMPLETED") {
|
||||
throw new Error(
|
||||
`Renewal for ${domainName} is still running in the background.`,
|
||||
);
|
||||
}
|
||||
|
||||
await load(selectedServerId);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "SSL Renewal",
|
||||
message:
|
||||
latestOperation.message || `Renewal completed for ${domainName}`,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "SSL Renewal",
|
||||
message: err instanceof Error ? err.message : "Renew failed",
|
||||
});
|
||||
} finally {
|
||||
setRenewing(null);
|
||||
setRenewOperations((current) => {
|
||||
const next = { ...current };
|
||||
delete next[cert.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const visibleData = useMemo(
|
||||
() =>
|
||||
selectedServerId
|
||||
? data.filter((domain) => domain.serverId === selectedServerId)
|
||||
: data,
|
||||
[data, selectedServerId],
|
||||
);
|
||||
|
||||
const stats = {
|
||||
total: visibleData.length,
|
||||
active: visibleData.filter((d) => d.isActive).length,
|
||||
sslValid: visibleData.filter((d) => d.sslCert?.status === "VALID").length,
|
||||
sslExpiring: visibleData.filter((d) => d.sslCert?.status === "EXPIRING")
|
||||
.length,
|
||||
};
|
||||
const hasPendingSslAction = syncing || renewing !== null || deleting !== null;
|
||||
const domainsWithSsl = visibleData.filter((d) => d.sslEnabled && d.sslCert);
|
||||
const pagination = useTablePagination({
|
||||
items: visibleData,
|
||||
resetKey: `${activeTab}|${selectedServerId}`,
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Domains & SSL"
|
||||
subtitle="Manage domains, DNS records, and SSL certificates"
|
||||
>
|
||||
<ConfirmActionDialog
|
||||
open={confirmDialog !== null}
|
||||
title={confirmDialog?.title ?? ""}
|
||||
description={confirmDialog?.description ?? ""}
|
||||
confirmLabel={confirmDialog?.confirmLabel ?? "Confirm"}
|
||||
tone={confirmDialog?.tone ?? "danger"}
|
||||
note={confirmDialog?.note}
|
||||
onClose={() => setConfirmDialog(null)}
|
||||
onConfirm={() => {
|
||||
const current = confirmDialog;
|
||||
setConfirmDialog(null);
|
||||
current?.onConfirm();
|
||||
}}
|
||||
/>
|
||||
<ToastViewport toasts={toasts} onClose={dismissToast} />
|
||||
{showAdd && (
|
||||
<AddDomainModal
|
||||
serverList={serverList}
|
||||
existingDomains={data}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onSaved={handleDomainSaved}
|
||||
/>
|
||||
)}
|
||||
{editingDomain && (
|
||||
<AddDomainModal
|
||||
serverList={serverList}
|
||||
existingDomains={data}
|
||||
domainToEdit={editingDomain}
|
||||
onClose={() => setEditingDomain(null)}
|
||||
onSaved={handleDomainSaved}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="animate-slide-in"
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
<DomainStats
|
||||
total={stats.total}
|
||||
active={stats.active}
|
||||
sslValid={stats.sslValid}
|
||||
sslExpiring={stats.sslExpiring}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="card ui-responsive-toolbar"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
}}
|
||||
>
|
||||
<div className="ui-inline-cluster">
|
||||
<Globe size={14} style={{ color: "var(--text-muted)" }} />
|
||||
<span
|
||||
className="ui-inline-label"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Server:
|
||||
</span>
|
||||
<div className="ui-chip-scroll no-scrollbar">
|
||||
<button
|
||||
onClick={() => void handleServerChange("")}
|
||||
style={{
|
||||
padding: "5px 14px",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background:
|
||||
selectedServerId === ""
|
||||
? "rgba(59,130,246,0.15)"
|
||||
: "var(--bg-input)",
|
||||
color:
|
||||
selectedServerId === "" ? "#3b82f6" : "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
outline:
|
||||
selectedServerId === ""
|
||||
? "1px solid rgba(59,130,246,0.3)"
|
||||
: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
All Servers
|
||||
</button>
|
||||
{serverList.map((server) => (
|
||||
<button
|
||||
key={server.id}
|
||||
onClick={() => void handleServerChange(server.id)}
|
||||
style={{
|
||||
padding: "5px 14px",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background:
|
||||
selectedServerId === server.id
|
||||
? "rgba(59,130,246,0.15)"
|
||||
: "var(--bg-input)",
|
||||
color:
|
||||
selectedServerId === server.id
|
||||
? "#3b82f6"
|
||||
: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
outline:
|
||||
selectedServerId === server.id
|
||||
? "1px solid rgba(59,130,246,0.3)"
|
||||
: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{server.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* <p style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{selectedServerId
|
||||
? "Showing domain and SSL records for the selected server"
|
||||
: "Showing all domain and SSL records across all servers"}
|
||||
</p> */}
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="ui-tab-shell">
|
||||
<div className="ui-tab-scroll no-scrollbar" style={{}}>
|
||||
{(
|
||||
[
|
||||
{ key: "domains", label: "Domains", icon: Globe },
|
||||
{ key: "ssl", label: "SSL Certs", icon: Lock },
|
||||
] as const
|
||||
).map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setActiveTab(t.key)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "7px 16px",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
background:
|
||||
activeTab === t.key
|
||||
? "rgba(59,130,246,0.15)"
|
||||
: "transparent",
|
||||
color:
|
||||
activeTab === t.key ? "#3b82f6" : "var(--text-muted)",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="ui-toolbar-actions" style={{}}>
|
||||
{syncing && syncMessage && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{syncMessage}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => void load(selectedServerId)}
|
||||
disabled={hasPendingSslAction}
|
||||
title={
|
||||
hasPendingSslAction
|
||||
? "Wait for the current SSL action to finish"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<RefreshCw size={12} /> Refresh
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => void handleSync()}
|
||||
disabled={hasPendingSslAction}
|
||||
title={
|
||||
hasPendingSslAction
|
||||
? "Wait for the current SSL action to finish"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
Sync from Server
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => setShowAdd(true)}
|
||||
disabled={syncing}
|
||||
>
|
||||
<Plus size={12} /> Add Domain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading spinner */}
|
||||
{loading && (
|
||||
<div className="card" style={{ overflow: "hidden" }}>
|
||||
<div style={{ padding: 48, textAlign: "center" }}>
|
||||
<Loader2
|
||||
size={28}
|
||||
className="animate-spin"
|
||||
style={{ color: "var(--accent)", margin: "0 auto 12px" }}
|
||||
/>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
|
||||
Loading domains...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && visibleData.length === 0 && (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 48,
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
}}
|
||||
>
|
||||
<Globe
|
||||
size={36}
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
margin: "0 auto 12px",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
|
||||
{selectedServerId
|
||||
? "No domains configured for the selected server"
|
||||
: "No domains configured yet"}
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ marginTop: 16, fontSize: 12 }}
|
||||
onClick={() => setShowAdd(true)}
|
||||
>
|
||||
<Plus size={12} /> Add First Domain
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && visibleData.length > 0 && activeTab === "domains" && (
|
||||
<DomainsTable
|
||||
domains={visibleData}
|
||||
pagination={pagination}
|
||||
deleting={deleting}
|
||||
renewing={renewing}
|
||||
onEdit={setEditingDomain}
|
||||
onDelete={handleDelete}
|
||||
onRenew={handleRenew}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "ssl" && (
|
||||
<SslCertificatesGrid
|
||||
domains={domainsWithSsl}
|
||||
renewing={renewing}
|
||||
renewBusy={renewing !== null}
|
||||
renewOperations={renewOperations}
|
||||
onRenew={handleRenew}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,167 @@
|
||||
import { AlertTriangle, ExternalLink } from "lucide-react";
|
||||
import { FieldLabel } from "@/app/settings/components/SettingsPrimitives";
|
||||
import {
|
||||
BITBUCKET_SCOPES,
|
||||
type GitProviderModalBaseProps,
|
||||
getProviderMeta,
|
||||
} from "./git-provider-shared";
|
||||
|
||||
export default function BitbucketProviderModal({
|
||||
draft,
|
||||
setupUrl,
|
||||
updateDraft,
|
||||
}: GitProviderModalBaseProps) {
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 14,
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${getProviderMeta(draft.provider).accentBorder}`,
|
||||
background: "var(--bg-input)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
border: "1px solid rgba(249,115,22,0.24)",
|
||||
background: "rgba(249,115,22,0.14)",
|
||||
color: "rgb(250, 143, 28)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={18} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<div style={{ fontSize: 12, lineHeight: 1.6 }}>
|
||||
Bitbucket App Passwords are deprecated for new providers. Use an API
|
||||
Token instead. Existing providers with App Passwords will continue
|
||||
to work until 9th June 2026.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={setupUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--text-primary)",
|
||||
textDecoration: "none",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
Manage tokens in Bitbucket settings <ExternalLink size={13} />
|
||||
</a>
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Select the following scopes:
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{BITBUCKET_SCOPES.map((scope) => (
|
||||
<span
|
||||
key={scope}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(59,130,246,0.2)",
|
||||
background: "rgba(59,130,246,0.08)",
|
||||
fontSize: 11,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 14,
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
}}
|
||||
>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.name}
|
||||
onChange={(event) => updateDraft("name", event.target.value)}
|
||||
placeholder="Your Bitbucket Provider, eg: my-personal-account"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Bitbucket Username</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.accountUsername}
|
||||
onChange={(event) =>
|
||||
updateDraft("accountUsername", event.target.value)
|
||||
}
|
||||
placeholder="Your Bitbucket username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Bitbucket Email</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.accountEmail}
|
||||
onChange={(event) =>
|
||||
updateDraft("accountEmail", event.target.value)
|
||||
}
|
||||
placeholder="Your Bitbucket email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FieldLabel>API Token</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={draft.clientSecret}
|
||||
onChange={(event) => {
|
||||
updateDraft("clientSecret", event.target.value);
|
||||
if (event.target.value.trim()) {
|
||||
updateDraft("hasClientSecret", true);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
draft.hasClientSecret
|
||||
? "Token configured"
|
||||
: "Paste your Bitbucket API token"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FieldLabel>Workspace Name (Optional)</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.namespace}
|
||||
onChange={(event) => updateDraft("namespace", event.target.value)}
|
||||
placeholder="For organization accounts"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { FieldLabel } from "@/app/settings/components/SettingsPrimitives";
|
||||
import {
|
||||
type GitProviderModalBaseProps,
|
||||
ProviderBadge,
|
||||
getProviderMeta,
|
||||
} from "./git-provider-shared";
|
||||
import { markGitProviderCallbackIntent } from "@/lib/git-provider-callback-intent";
|
||||
|
||||
export default function GiteaProviderModal({
|
||||
draft,
|
||||
setupUrl,
|
||||
callbackUrl,
|
||||
updateDraft,
|
||||
}: GitProviderModalBaseProps) {
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 14,
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${getProviderMeta(draft.provider).accentBorder}`,
|
||||
background: "var(--bg-input)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* <ProviderBadge provider="gitea" size={40} /> */}
|
||||
<img
|
||||
src="https://thesvg.org/icons/gitea/default.svg"
|
||||
alt="Gitea"
|
||||
width={50}
|
||||
height={50}
|
||||
style={{ background: "transparent" }}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Gitea Provider
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 4,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
For self-hosted Gitea, create a new OAuth2 Application and record
|
||||
the Client ID and Secret in Doktainer.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr style={{ borderColor: "var(--border)" }} />
|
||||
|
||||
<a
|
||||
href={setupUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => markGitProviderCallbackIntent("gitea")}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 18,
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--text-primary)",
|
||||
textDecoration: "none",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
Go to your Gitea settings <ExternalLink size={13} />
|
||||
</a>
|
||||
|
||||
<ol
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: 18,
|
||||
display: "grid",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<li>Navigate to Applications and create a new OAuth2 Application.</li>
|
||||
<li>
|
||||
Use the app name <strong>{draft.appName}</strong> and redirect URI{" "}
|
||||
<strong>{callbackUrl}</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Copy the generated Client ID and Client Secret back into Doktainer.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 14,
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
}}
|
||||
>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.name}
|
||||
onChange={(event) => updateDraft("name", event.target.value)}
|
||||
placeholder="Random Name eg(my-personal-account)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Application Name</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.appName}
|
||||
onChange={(event) => updateDraft("appName", event.target.value)}
|
||||
placeholder="Doktainer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Gitea URL</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.providerUrl}
|
||||
onChange={(event) => updateDraft("providerUrl", event.target.value)}
|
||||
placeholder="https://gitea.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Internal URL (Optional)</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.internalUrl}
|
||||
onChange={(event) => updateDraft("internalUrl", event.target.value)}
|
||||
placeholder="http://gitea:3000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Redirect URI</FieldLabel>
|
||||
<input className="input" value={callbackUrl} readOnly />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Client ID</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.clientId}
|
||||
onChange={(event) => updateDraft("clientId", event.target.value)}
|
||||
placeholder="Client ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Client Secret</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={draft.clientSecret}
|
||||
onChange={(event) => {
|
||||
updateDraft("clientSecret", event.target.value);
|
||||
if (event.target.value.trim()) {
|
||||
updateDraft("hasClientSecret", true);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
draft.hasClientSecret ? "Secret configured" : "Client Secret"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import {
|
||||
FieldLabel,
|
||||
Toggle,
|
||||
} from "@/app/settings/components/SettingsPrimitives";
|
||||
import {
|
||||
type GithubProviderModalProps,
|
||||
ProviderBadge,
|
||||
getProviderMeta,
|
||||
launchGithubManifestFlow,
|
||||
} from "./git-provider-shared";
|
||||
import { markGitProviderCallbackIntent } from "@/lib/git-provider-callback-intent";
|
||||
|
||||
export default function GithubProviderModal({
|
||||
draft,
|
||||
setupUrl,
|
||||
updateDraft,
|
||||
detailsOpen,
|
||||
setDetailsOpen,
|
||||
}: GithubProviderModalProps) {
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 16,
|
||||
padding: 22,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${getProviderMeta(draft.provider).accentBorder}`,
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* <ProviderBadge provider="github" size={40} /> */}
|
||||
<img
|
||||
src="https://thesvg.org/icons/github/light.svg"
|
||||
alt="GitHub"
|
||||
width={50}
|
||||
height={50}
|
||||
style={{
|
||||
color: "var(--bg-primary)",
|
||||
background: "transparent",
|
||||
filter: "invert(1)",
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
GitHub Provider
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 4,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
To integrate your GitHub account with Doktainer, create and
|
||||
install a GitHub App first. This flow only takes a few minutes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Organization?
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
Enable this if the GitHub App should be created under an
|
||||
organization instead of a personal account.
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={draft.organizationScoped}
|
||||
onChange={() =>
|
||||
updateDraft("organizationScoped", !draft.organizationScoped)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{draft.organizationScoped ? (
|
||||
<div>
|
||||
<FieldLabel>Organization Name</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.organizationName}
|
||||
onChange={(event) =>
|
||||
updateDraft("organizationName", event.target.value)
|
||||
}
|
||||
placeholder="Organization name"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 14,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
Unsure if you already have an app?
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsOpen((current) => !current)}
|
||||
style={{
|
||||
marginTop: 6,
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{detailsOpen
|
||||
? "Hide existing app details"
|
||||
: "I already have a GitHub App"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
markGitProviderCallbackIntent("github");
|
||||
if (!launchGithubManifestFlow(draft)) {
|
||||
window.location.assign(setupUrl);
|
||||
}
|
||||
}}
|
||||
disabled={!setupUrl}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Create GitHub App
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{detailsOpen ? (
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 14,
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
}}
|
||||
>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FieldLabel>Config Name</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.name}
|
||||
onChange={(event) => updateDraft("name", event.target.value)}
|
||||
placeholder="GitHub App Production"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>App Name</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.appName}
|
||||
onChange={(event) => updateDraft("appName", event.target.value)}
|
||||
placeholder={`Doktainer-${new Date().toISOString().split("T")[0].replace(/-/g, "")}-${Math.random().toString(36).slice(2, 10)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>App ID</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.appId}
|
||||
onChange={(event) => updateDraft("appId", event.target.value)}
|
||||
placeholder="Paste GitHub App ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Client ID</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.clientId}
|
||||
onChange={(event) => updateDraft("clientId", event.target.value)}
|
||||
placeholder="Iv1..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Client Secret</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={draft.clientSecret}
|
||||
onChange={(event) => {
|
||||
updateDraft("clientSecret", event.target.value);
|
||||
if (event.target.value.trim()) {
|
||||
updateDraft("hasClientSecret", true);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
draft.hasClientSecret
|
||||
? "Secret configured"
|
||||
: "Paste client secret"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Webhook Secret</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={draft.webhookSecret}
|
||||
onChange={(event) => {
|
||||
updateDraft("webhookSecret", event.target.value);
|
||||
if (event.target.value.trim()) {
|
||||
updateDraft("hasWebhookSecret", true);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
draft.hasWebhookSecret
|
||||
? "Webhook secret configured"
|
||||
: "Optional webhook secret"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FieldLabel>App URL</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.appUrl}
|
||||
onChange={(event) => updateDraft("appUrl", event.target.value)}
|
||||
placeholder="https://github.com/apps/your-app"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FieldLabel>Installation URL</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.installationUrl}
|
||||
onChange={(event) =>
|
||||
updateDraft("installationUrl", event.target.value)
|
||||
}
|
||||
placeholder="https://github.com/apps/your-app/installations/new"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { FieldLabel } from "@/app/settings/components/SettingsPrimitives";
|
||||
import {
|
||||
type GitProviderModalBaseProps,
|
||||
ProviderBadge,
|
||||
getProviderMeta,
|
||||
} from "./git-provider-shared";
|
||||
import { markGitProviderCallbackIntent } from "@/lib/git-provider-callback-intent";
|
||||
|
||||
export default function GitlabProviderModal({
|
||||
draft,
|
||||
setupUrl,
|
||||
callbackUrl,
|
||||
updateDraft,
|
||||
}: GitProviderModalBaseProps) {
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 14,
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${getProviderMeta(draft.provider).accentBorder}`,
|
||||
background: "var(--bg-input)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* <ProviderBadge provider="gitlab" size={40} /> */}
|
||||
<img
|
||||
src="https://thesvg.org/icons/gitlab/default.svg"
|
||||
alt="GitLab"
|
||||
width={50}
|
||||
height={50}
|
||||
style={{ background: "transparent" }}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
GitLab Provider
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 4,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
Create an OAuth application in GitLab first, then paste the
|
||||
generated Application ID and Secret below.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr style={{ borderColor: "var(--border)" }} />
|
||||
|
||||
<a
|
||||
href={setupUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => markGitProviderCallbackIntent("gitlab")}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 18,
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--text-primary)",
|
||||
textDecoration: "none",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
Go to your GitLab profile settings <ExternalLink size={13} />
|
||||
</a>
|
||||
|
||||
<ol
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: 18,
|
||||
display: "grid",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<li>Navigate to Applications.</li>
|
||||
<li>
|
||||
Create a new application with the name{" "}
|
||||
<strong>{draft.appName}</strong>, redirect URI{" "}
|
||||
<strong>{callbackUrl}</strong>, and scopes <strong>api</strong>,{" "}
|
||||
<strong>read_user</strong>, and <strong>read_repository</strong>.
|
||||
</li>
|
||||
<li>
|
||||
After creating the app, copy the Application ID and Secret into
|
||||
Doktainer.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 14,
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
}}
|
||||
>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.name}
|
||||
onChange={(event) => updateDraft("name", event.target.value)}
|
||||
placeholder="Random Name eg(my-personal-account)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Application Name</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.appName}
|
||||
onChange={(event) => updateDraft("appName", event.target.value)}
|
||||
placeholder="Doktainer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>GitLab URL</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.providerUrl}
|
||||
onChange={(event) => updateDraft("providerUrl", event.target.value)}
|
||||
placeholder="https://gitlab.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Internal URL (Optional)</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.internalUrl}
|
||||
onChange={(event) => updateDraft("internalUrl", event.target.value)}
|
||||
placeholder="http://gitlab:80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Redirect URI</FieldLabel>
|
||||
<input className="input" value={callbackUrl} readOnly />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Application ID</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.clientId}
|
||||
onChange={(event) => updateDraft("clientId", event.target.value)}
|
||||
placeholder="Application ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Application Secret</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={draft.clientSecret}
|
||||
onChange={(event) => {
|
||||
updateDraft("clientSecret", event.target.value);
|
||||
if (event.target.value.trim()) {
|
||||
updateDraft("hasClientSecret", true);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
draft.hasClientSecret ? "Secret configured" : "Application Secret"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FieldLabel>Group Name (Optional, Comma-Separated List)</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
value={draft.namespace}
|
||||
onChange={(event) => updateDraft("namespace", event.target.value)}
|
||||
placeholder="my-org"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type {
|
||||
GitProviderInput,
|
||||
GitProviderRecord,
|
||||
GitProviderType,
|
||||
} from "@/lib/api";
|
||||
import { encodeGithubManifestState } from "@/lib/github-manifest-state";
|
||||
import {
|
||||
isPublicHttpsUrl,
|
||||
resolvePublicAppOrigin,
|
||||
resolvePublicAppUrl,
|
||||
} from "@/server/lib/public-url";
|
||||
|
||||
export type GitProviderDraft = GitProviderInput & {
|
||||
id?: string;
|
||||
hasClientSecret: boolean;
|
||||
hasWebhookSecret: boolean;
|
||||
};
|
||||
|
||||
export type UpdateGitProviderDraft = <K extends keyof GitProviderDraft>(
|
||||
key: K,
|
||||
value: GitProviderDraft[K],
|
||||
) => void;
|
||||
|
||||
export interface GitProviderModalBaseProps {
|
||||
draft: GitProviderDraft;
|
||||
setupUrl: string;
|
||||
callbackUrl: string;
|
||||
updateDraft: UpdateGitProviderDraft;
|
||||
}
|
||||
|
||||
export interface GithubProviderModalProps extends GitProviderModalBaseProps {
|
||||
detailsOpen: boolean;
|
||||
setDetailsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const PROVIDER_OPTIONS: Array<{
|
||||
value: GitProviderType;
|
||||
label: string;
|
||||
description: string;
|
||||
color: string;
|
||||
accentBackground: string;
|
||||
accentBorder: string;
|
||||
code: string;
|
||||
}> = [
|
||||
{
|
||||
value: "github",
|
||||
label: "GitHub",
|
||||
description:
|
||||
"Register GitHub Apps and keep multiple installation configs per organization.",
|
||||
color: "#f8fafc",
|
||||
accentBackground: "rgba(255,255,255,0.06)",
|
||||
accentBorder: "rgba(148,163,184,0.22)",
|
||||
code: "GH",
|
||||
},
|
||||
{
|
||||
value: "gitlab",
|
||||
label: "GitLab",
|
||||
description:
|
||||
"Track GitLab application credentials for repository automation flows.",
|
||||
color: "#f97316",
|
||||
accentBackground: "rgba(249,115,22,0.12)",
|
||||
accentBorder: "rgba(249,115,22,0.24)",
|
||||
code: "GL",
|
||||
},
|
||||
{
|
||||
value: "bitbucket",
|
||||
label: "Bitbucket",
|
||||
description:
|
||||
"Store Bitbucket integration setup for workspace or team repositories.",
|
||||
color: "#3b82f6",
|
||||
accentBackground: "rgba(59,130,246,0.14)",
|
||||
accentBorder: "rgba(59,130,246,0.24)",
|
||||
code: "BB",
|
||||
},
|
||||
{
|
||||
value: "gitea",
|
||||
label: "Gitea",
|
||||
description:
|
||||
"Record self-hosted Gitea app config and callback details for Doktainer.",
|
||||
color: "#22c55e",
|
||||
accentBackground: "rgba(34,197,94,0.14)",
|
||||
accentBorder: "rgba(34,197,94,0.24)",
|
||||
code: "GT",
|
||||
},
|
||||
];
|
||||
|
||||
export const BITBUCKET_SCOPES = [
|
||||
"read:repository:bitbucket",
|
||||
"read:pullrequest:bitbucket",
|
||||
"read:webhook:bitbucket",
|
||||
"read:workspace:bitbucket",
|
||||
"write:webhook:bitbucket",
|
||||
];
|
||||
|
||||
export function createGitProviderDraft(
|
||||
provider: GitProviderType = "github",
|
||||
): GitProviderDraft {
|
||||
return {
|
||||
provider,
|
||||
name: "",
|
||||
enabled: true,
|
||||
appName:
|
||||
provider === "github"
|
||||
? `Doktainer-${new Date().toISOString().split("T")[0].replace(/-/g, "")}-${Math.random().toString(36).slice(2, 10)}`
|
||||
: "Doktainer Git Integration",
|
||||
appId: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
hasClientSecret: false,
|
||||
webhookSecret: "",
|
||||
hasWebhookSecret: false,
|
||||
appUrl: "",
|
||||
installationUrl: "",
|
||||
providerUrl:
|
||||
provider === "gitlab"
|
||||
? "https://gitlab.com"
|
||||
: provider === "gitea"
|
||||
? "https://gitea.com"
|
||||
: "",
|
||||
internalUrl: "",
|
||||
accountUsername: "",
|
||||
accountEmail: "",
|
||||
namespace: "",
|
||||
organizationScoped: false,
|
||||
organizationName: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function getProviderMeta(provider: GitProviderType) {
|
||||
return (
|
||||
PROVIDER_OPTIONS.find((option) => option.value === provider) ||
|
||||
PROVIDER_OPTIONS[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderBadge({
|
||||
provider,
|
||||
size = 34,
|
||||
}: {
|
||||
provider: GitProviderType;
|
||||
size?: number;
|
||||
}) {
|
||||
const meta = getProviderMeta(provider);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: Math.round(size / 3),
|
||||
background: meta.accentBackground,
|
||||
border: `1px solid ${meta.accentBorder}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
color: meta.color,
|
||||
fontSize: size <= 24 ? 9 : 11,
|
||||
fontWeight: 800,
|
||||
letterSpacing: 0.4,
|
||||
}}
|
||||
>
|
||||
{meta.code}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildGitProviderSetupUrl(draft: GitProviderDraft) {
|
||||
const orgName = (draft.organizationName ?? "").trim();
|
||||
const providerUrl = (draft.providerUrl ?? "").trim().replace(/\/+$/, "");
|
||||
const normalizedBaseUrl = resolvePublicAppOrigin();
|
||||
|
||||
switch (draft.provider) {
|
||||
case "github": {
|
||||
const url = new URL("/git/github/create", `${normalizedBaseUrl}/`);
|
||||
if (draft.appName?.trim()) {
|
||||
url.searchParams.set("appName", draft.appName.trim());
|
||||
}
|
||||
if (draft.name?.trim()) {
|
||||
url.searchParams.set("configName", draft.name.trim());
|
||||
}
|
||||
if (draft.organizationScoped) {
|
||||
url.searchParams.set("organizationScoped", "true");
|
||||
if (orgName) {
|
||||
url.searchParams.set("organizationName", orgName);
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
case "gitlab":
|
||||
return `${providerUrl || "https://gitlab.com"}/-/user_settings/applications`;
|
||||
case "bitbucket":
|
||||
return "https://id.atlassian.com/manage-profile/security/api-tokens";
|
||||
case "gitea":
|
||||
default:
|
||||
return providerUrl
|
||||
? `${providerUrl}/user/settings/applications`
|
||||
: "https://docs.gitea.com/usage/oauth2-provider";
|
||||
}
|
||||
}
|
||||
|
||||
export function getGitProviderCallbackUrl(provider: GitProviderType) {
|
||||
return resolvePublicAppUrl(`/api/providers/${provider}/callback`);
|
||||
}
|
||||
|
||||
export function getGitProviderRedirectUrl(provider: GitProviderType) {
|
||||
if (provider === "github") {
|
||||
return resolvePublicAppUrl("/git/github/callback");
|
||||
}
|
||||
|
||||
return getGitProviderCallbackUrl(provider);
|
||||
}
|
||||
|
||||
export function getGitProviderBaseUrl() {
|
||||
return resolvePublicAppOrigin();
|
||||
}
|
||||
|
||||
export function hasPublicGitProviderBaseUrl() {
|
||||
return isPublicHttpsUrl(getGitProviderBaseUrl());
|
||||
}
|
||||
|
||||
export function launchGithubManifestFlow(draft: GitProviderDraft) {
|
||||
const normalizedPanelUrl = getGitProviderBaseUrl();
|
||||
if (!hasPublicGitProviderBaseUrl() || !normalizedPanelUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appName = draft.appName?.trim() || "Doktainer GitHub App";
|
||||
const configName = draft.name?.trim() || "";
|
||||
const organizationName = (draft.organizationName ?? "").trim();
|
||||
const organizationScoped = Boolean(
|
||||
draft.organizationScoped && organizationName,
|
||||
);
|
||||
const callbackUrl = getGitProviderCallbackUrl("github");
|
||||
const redirectUrl = getGitProviderRedirectUrl("github");
|
||||
const action = organizationScoped
|
||||
? `https://github.com/organizations/${encodeURIComponent(organizationName)}/settings/apps/new`
|
||||
: "https://github.com/settings/apps/new";
|
||||
const state = encodeGithubManifestState({
|
||||
appName,
|
||||
configName,
|
||||
organizationScoped,
|
||||
organizationName: organizationScoped ? organizationName : "",
|
||||
nonce: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
});
|
||||
const manifest = JSON.stringify({
|
||||
name: appName,
|
||||
url: normalizedPanelUrl,
|
||||
redirect_url: redirectUrl,
|
||||
callback_urls: [callbackUrl],
|
||||
setup_url: `${normalizedPanelUrl}/git`,
|
||||
description:
|
||||
"Doktainer Git integration for repository access and deployment workflows.",
|
||||
public: false,
|
||||
request_oauth_on_install: true,
|
||||
hook_attributes: {
|
||||
url: resolvePublicAppUrl("/api/providers/github/webhook"),
|
||||
active: true,
|
||||
},
|
||||
default_permissions: {
|
||||
contents: "read",
|
||||
metadata: "read",
|
||||
pull_requests: "write",
|
||||
checks: "write",
|
||||
statuses: "write",
|
||||
},
|
||||
default_events: ["push", "pull_request", "check_suite", "check_run"],
|
||||
});
|
||||
|
||||
const form = document.createElement("form");
|
||||
form.method = "post";
|
||||
form.action = action;
|
||||
form.style.display = "none";
|
||||
|
||||
const manifestInput = document.createElement("input");
|
||||
manifestInput.type = "hidden";
|
||||
manifestInput.name = "manifest";
|
||||
manifestInput.value = manifest;
|
||||
form.appendChild(manifestInput);
|
||||
|
||||
const stateInput = document.createElement("input");
|
||||
stateInput.type = "hidden";
|
||||
stateInput.name = "state";
|
||||
stateInput.value = state;
|
||||
form.appendChild(stateInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getGitProviderSummary(
|
||||
provider: GitProviderDraft | GitProviderRecord,
|
||||
) {
|
||||
const appId = (provider.appId ?? "").trim();
|
||||
const parts = [
|
||||
provider.appName,
|
||||
provider.organizationScoped ? (provider.organizationName ?? "") : "Global",
|
||||
]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (appId) {
|
||||
parts.push(`App ID ${appId}`);
|
||||
}
|
||||
|
||||
if (provider.provider === "bitbucket" && provider.accountUsername?.trim()) {
|
||||
parts.push(`@${provider.accountUsername.trim()}`);
|
||||
}
|
||||
|
||||
if (
|
||||
(provider.provider === "gitlab" || provider.provider === "bitbucket") &&
|
||||
provider.namespace?.trim()
|
||||
) {
|
||||
parts.push(provider.namespace.trim());
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(" • ") : "Konfigurasi Git belum lengkap";
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getToken, gitProvidersApi, redirectToLogin } from "@/lib/api";
|
||||
import { decodeGithubManifestState } from "@/lib/github-manifest-state";
|
||||
|
||||
export default function GithubCallbackPage() {
|
||||
const router = useRouter();
|
||||
const handledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (handledRef.current || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
handledRef.current = true;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("code");
|
||||
const returnedState = params.get("state");
|
||||
const error = params.get("error");
|
||||
const errorDescription =
|
||||
params.get("error_description") || params.get("error_reason");
|
||||
|
||||
const redirectWithMessage = (
|
||||
status: "success" | "error",
|
||||
message: string,
|
||||
) => {
|
||||
const target = new URL("/git", window.location.origin);
|
||||
target.searchParams.set("callbackProvider", "github");
|
||||
target.searchParams.set("callbackStatus", status);
|
||||
target.searchParams.set("callbackMessage", message);
|
||||
router.replace(target.pathname + target.search);
|
||||
};
|
||||
|
||||
const stored = decodeGithubManifestState(returnedState);
|
||||
|
||||
if (error) {
|
||||
redirectWithMessage(
|
||||
"error",
|
||||
errorDescription || `GitHub returned ${error} during app creation.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
redirectWithMessage("error", "GitHub manifest callback code is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stored) {
|
||||
redirectWithMessage(
|
||||
"error",
|
||||
"GitHub manifest callback state is invalid or has expired.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const complete = async () => {
|
||||
try {
|
||||
const conversionResponse = await fetch(
|
||||
"/api/providers/github/manifest/complete",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
},
|
||||
);
|
||||
|
||||
if (conversionResponse.status === 401 && getToken()) {
|
||||
redirectToLogin("session-expired");
|
||||
return;
|
||||
}
|
||||
|
||||
const conversionPayload = (await conversionResponse.json()) as {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
id?: number;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
webhookSecret?: string;
|
||||
htmlUrl?: string;
|
||||
name?: string;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (
|
||||
!conversionResponse.ok ||
|
||||
!conversionPayload.success ||
|
||||
!conversionPayload.data
|
||||
) {
|
||||
throw new Error(
|
||||
conversionPayload.error || "Failed to convert GitHub App manifest.",
|
||||
);
|
||||
}
|
||||
|
||||
const appName =
|
||||
conversionPayload.data.name ||
|
||||
stored?.appName ||
|
||||
`Doktainer-${new Date().toISOString().split("T")[0].replace(/-/g, "")}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const providerName = stored?.configName?.trim() || appName;
|
||||
|
||||
await gitProvidersApi.create({
|
||||
provider: "github",
|
||||
name: providerName,
|
||||
enabled: true,
|
||||
appName,
|
||||
appId: String(conversionPayload.data.id || ""),
|
||||
clientId: conversionPayload.data.clientId || "",
|
||||
clientSecret: conversionPayload.data.clientSecret || "",
|
||||
webhookSecret: conversionPayload.data.webhookSecret || "",
|
||||
appUrl: conversionPayload.data.htmlUrl || "",
|
||||
installationUrl: conversionPayload.data.htmlUrl
|
||||
? `${conversionPayload.data.htmlUrl}/installations/new`
|
||||
: "",
|
||||
providerUrl: "",
|
||||
internalUrl: "",
|
||||
accountUsername: "",
|
||||
accountEmail: "",
|
||||
namespace: "",
|
||||
organizationScoped: Boolean(stored?.organizationScoped),
|
||||
organizationName: stored?.organizationName || "",
|
||||
});
|
||||
|
||||
redirectWithMessage(
|
||||
"success",
|
||||
"GitHub App created and recorded in Doktainer successfully.",
|
||||
);
|
||||
} catch (conversionError) {
|
||||
redirectWithMessage(
|
||||
"error",
|
||||
conversionError instanceof Error
|
||||
? conversionError.message
|
||||
: "Failed to complete the GitHub App flow.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
void complete();
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "#020617",
|
||||
color: "#f8fafc",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "min(480px, 100%)",
|
||||
padding: 24,
|
||||
borderRadius: 18,
|
||||
border: "1px solid rgba(148,163,184,0.18)",
|
||||
background: "rgba(2,6,23,0.92)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Finalizing GitHub App setup...
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { headers } from "next/headers";
|
||||
import { encodeGithubManifestState } from "@/lib/github-manifest-state";
|
||||
import {
|
||||
isPublicHttpsUrl,
|
||||
resolvePublicAppOrigin,
|
||||
resolvePublicAppUrl,
|
||||
} from "@/server/lib/public-url";
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
function firstQueryValue(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
|
||||
}
|
||||
|
||||
export default async function GithubCreatePage(props: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const appName =
|
||||
firstQueryValue(searchParams.appName) || "Doktainer GitHub App";
|
||||
const configName = firstQueryValue(searchParams.configName);
|
||||
const organizationScoped =
|
||||
firstQueryValue(searchParams.organizationScoped) === "true";
|
||||
const organizationName = firstQueryValue(searchParams.organizationName);
|
||||
|
||||
const headerStore = await headers();
|
||||
const normalizedPanelUrl = resolvePublicAppOrigin({ headers: headerStore });
|
||||
const hasPublicUrl = isPublicHttpsUrl(normalizedPanelUrl);
|
||||
const callbackUrl = resolvePublicAppUrl("/api/providers/github/callback", {
|
||||
headers: headerStore,
|
||||
});
|
||||
const redirectUrl = resolvePublicAppUrl("/git/github/callback", {
|
||||
headers: headerStore,
|
||||
});
|
||||
const webhookUrl = resolvePublicAppUrl("/api/providers/github/webhook", {
|
||||
headers: headerStore,
|
||||
});
|
||||
const manifestAction =
|
||||
organizationScoped && organizationName
|
||||
? `https://github.com/organizations/${encodeURIComponent(organizationName)}/settings/apps/new`
|
||||
: "https://github.com/settings/apps/new";
|
||||
const state = encodeGithubManifestState({
|
||||
appName,
|
||||
configName,
|
||||
organizationScoped,
|
||||
organizationName,
|
||||
nonce: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
});
|
||||
const manifest = JSON.stringify({
|
||||
name: appName.trim() || "Doktainer GitHub App",
|
||||
url: normalizedPanelUrl,
|
||||
redirect_url: redirectUrl,
|
||||
callback_urls: [callbackUrl],
|
||||
setup_url: `${normalizedPanelUrl}/git`,
|
||||
description:
|
||||
"Doktainer Git integration for repository access and deployment workflows.",
|
||||
public: false,
|
||||
request_oauth_on_install: true,
|
||||
hook_attributes: {
|
||||
url: webhookUrl,
|
||||
active: true,
|
||||
},
|
||||
default_permissions: {
|
||||
contents: "read",
|
||||
metadata: "read",
|
||||
pull_requests: "write",
|
||||
checks: "write",
|
||||
statuses: "write",
|
||||
},
|
||||
default_events: ["push", "pull_request", "check_suite", "check_run"],
|
||||
});
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "#020617",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<form
|
||||
id="github-manifest-form"
|
||||
action={manifestAction}
|
||||
method="post"
|
||||
style={{
|
||||
width: "min(560px, 100%)",
|
||||
padding: 28,
|
||||
borderRadius: 18,
|
||||
border: "1px solid rgba(148,163,184,0.18)",
|
||||
background: "rgba(2,6,23,0.92)",
|
||||
boxShadow: "0 24px 60px rgba(0,0,0,0.35)",
|
||||
display: "grid",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="manifest" value={manifest} />
|
||||
<input type="hidden" name="state" value={state} />
|
||||
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
{hasPublicUrl
|
||||
? "Opening GitHub..."
|
||||
: "GitHub App Setup Needs A Public URL"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{hasPublicUrl ? (
|
||||
<>
|
||||
<div style={{ fontSize: 13, color: "#cbd5e1", lineHeight: 1.7 }}>
|
||||
Doktainer is redirecting you to GitHub's manifest flow so you
|
||||
only need to confirm the app name there.
|
||||
</div>
|
||||
<noscript>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
height: 42,
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(34,197,94,0.4)",
|
||||
background: "#15803d",
|
||||
color: "#f8fafc",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Continue to GitHub
|
||||
</button>
|
||||
</noscript>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: 14 }}>
|
||||
<div style={{ fontSize: 13, color: "#e2e8f0", lineHeight: 1.7 }}>
|
||||
GitHub App manifests require a publicly reachable HTTPS URL for
|
||||
the app homepage, webhook, and callback. The current panel URL is{" "}
|
||||
<strong>{normalizedPanelUrl || "not configured"}</strong>, which
|
||||
GitHub rejects when it points to localhost or a non-public
|
||||
address.
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8", lineHeight: 1.7 }}>
|
||||
Set <strong>NEXT_PUBLIC_PANEL_URL</strong> or
|
||||
<strong> FRONTEND_URL</strong> to your real public Doktainer URL,
|
||||
or keep both unset and expose Doktainer through a reverse proxy
|
||||
that forwards <strong>X-Forwarded-Host</strong> and
|
||||
<strong> X-Forwarded-Proto</strong>. Then try again.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{hasPublicUrl ? (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
'window.requestAnimationFrame(function(){var form=document.getElementById("github-manifest-form"); if(form){ form.submit(); }});',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
|
||||
import GuardedPage from "@/components/GuardedPage";
|
||||
import ToastViewport from "@/components/ToastViewport";
|
||||
import GitProvidersPanel from "@/app/git/components/GitProvidersPanel";
|
||||
import {
|
||||
Banner,
|
||||
SettingsLoadingPanel,
|
||||
} from "@/app/settings/components/SettingsPrimitives";
|
||||
import {
|
||||
gitProvidersApi,
|
||||
type GitProviderInput,
|
||||
type GitProviderRecord,
|
||||
} from "@/lib/api";
|
||||
import { useToastManager } from "@/lib/use-toast-manager";
|
||||
|
||||
type GitProviderDraft = GitProviderInput & {
|
||||
id?: string;
|
||||
hasClientSecret: boolean;
|
||||
hasWebhookSecret: boolean;
|
||||
};
|
||||
|
||||
type PendingConfirmAction = {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
tone?: "danger" | "warning" | "info";
|
||||
note?: string;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
function toProviderInput(
|
||||
provider: GitProviderDraft | GitProviderRecord,
|
||||
): GitProviderInput {
|
||||
return {
|
||||
provider: provider.provider,
|
||||
name: provider.name,
|
||||
enabled: provider.enabled,
|
||||
appName: provider.appName,
|
||||
appId: provider.appId,
|
||||
clientId: provider.clientId,
|
||||
clientSecret: provider.clientSecret,
|
||||
webhookSecret: provider.webhookSecret,
|
||||
appUrl: provider.appUrl,
|
||||
installationUrl: provider.installationUrl,
|
||||
providerUrl: provider.providerUrl,
|
||||
internalUrl: provider.internalUrl,
|
||||
accountUsername: provider.accountUsername,
|
||||
accountEmail: provider.accountEmail,
|
||||
namespace: provider.namespace,
|
||||
organizationScoped: provider.organizationScoped,
|
||||
organizationName: provider.organizationName,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertProviderState(
|
||||
current: GitProviderRecord[],
|
||||
next: GitProviderRecord,
|
||||
) {
|
||||
const existingIndex = current.findIndex((item) => item.id === next.id);
|
||||
if (existingIndex === -1) {
|
||||
return [...current, next].sort((left, right) =>
|
||||
left.createdAt.localeCompare(right.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
const updated = [...current];
|
||||
updated[existingIndex] = next;
|
||||
return updated;
|
||||
}
|
||||
|
||||
export default function GitPage() {
|
||||
const [providers, setProviders] = useState<GitProviderRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] =
|
||||
useState<PendingConfirmAction | null>(null);
|
||||
const callbackHandledRef = useRef<string | null>(null);
|
||||
const { toasts, pushToast, dismissToast } = useToastManager();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const callbackProvider = params.get("callbackProvider");
|
||||
const callbackStatus = params.get("callbackStatus");
|
||||
const callbackMessage = params.get("callbackMessage");
|
||||
|
||||
if (!callbackProvider || !callbackStatus || !callbackMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackKey = `${callbackProvider}:${callbackStatus}:${callbackMessage}`;
|
||||
if (callbackHandledRef.current === callbackKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
callbackHandledRef.current = callbackKey;
|
||||
pushToast({
|
||||
tone: callbackStatus === "error" ? "error" : "success",
|
||||
message: callbackMessage,
|
||||
});
|
||||
window.history.replaceState({}, "", "/git");
|
||||
}, [pushToast]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadProviders = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await gitProvidersApi.list();
|
||||
if (!mounted) return;
|
||||
setProviders(response.data);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load git providers",
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadProviders();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const upsertProvider = async (draft: GitProviderDraft) => {
|
||||
const pendingId = draft.id || "new-provider";
|
||||
setSavingProviderId(pendingId);
|
||||
|
||||
try {
|
||||
const payload = toProviderInput(draft);
|
||||
const response = draft.id
|
||||
? await gitProvidersApi.update(draft.id, payload)
|
||||
: await gitProvidersApi.create(payload);
|
||||
|
||||
setProviders((current) => upsertProviderState(current, response.data));
|
||||
pushToast({
|
||||
tone: "success",
|
||||
message:
|
||||
response.message ||
|
||||
(draft.id
|
||||
? "Git provider updated successfully"
|
||||
: "Git provider added successfully"),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to save git provider",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setSavingProviderId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleProvider = async (provider: GitProviderRecord) => {
|
||||
const pendingId = `toggle-${provider.id}`;
|
||||
setSavingProviderId(pendingId);
|
||||
|
||||
try {
|
||||
const response = await gitProvidersApi.update(provider.id, {
|
||||
...toProviderInput(provider),
|
||||
enabled: !provider.enabled,
|
||||
clientSecret: "",
|
||||
webhookSecret: "",
|
||||
});
|
||||
|
||||
setProviders((current) => upsertProviderState(current, response.data));
|
||||
pushToast({
|
||||
tone: "success",
|
||||
message: response.message || "Git provider status updated successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to update git provider status",
|
||||
});
|
||||
} finally {
|
||||
setSavingProviderId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const performDeleteProvider = async (provider: GitProviderRecord) => {
|
||||
const providerId = provider.id;
|
||||
setSavingProviderId(`delete-${providerId}`);
|
||||
|
||||
try {
|
||||
const response = await gitProvidersApi.remove(providerId);
|
||||
setProviders((current) =>
|
||||
current.filter((provider) => provider.id !== providerId),
|
||||
);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
message: response.message || "Git provider deleted successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to delete git provider",
|
||||
});
|
||||
} finally {
|
||||
setSavingProviderId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProvider = (provider: GitProviderRecord) => {
|
||||
setConfirmDialog({
|
||||
title: "Delete Git Provider",
|
||||
description: `Delete git provider "${provider.name}"?`,
|
||||
confirmLabel: "Delete Provider",
|
||||
tone: "danger",
|
||||
note: "This removes the saved Git provider configuration and credentials from Doktainer.",
|
||||
onConfirm: () => {
|
||||
void performDeleteProvider(provider);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const verifyProvider = async (draft: GitProviderDraft) => {
|
||||
setActionLoading(`verify-${draft.id ?? "draft"}`);
|
||||
|
||||
try {
|
||||
const response = await gitProvidersApi.verify({
|
||||
...toProviderInput(draft),
|
||||
id: draft.id,
|
||||
});
|
||||
|
||||
pushToast({
|
||||
tone: "success",
|
||||
message:
|
||||
response.message ||
|
||||
"Git provider configuration verified successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to verify git provider",
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GuardedPage
|
||||
route="/git"
|
||||
title="Git"
|
||||
subtitle="Manage Git provider apps and keep per-organization configurations ready for Doktainer workflows"
|
||||
redirectSubtitle="Redirecting to a page allowed for your role"
|
||||
>
|
||||
<ConfirmActionDialog
|
||||
open={confirmDialog !== null}
|
||||
title={confirmDialog?.title ?? ""}
|
||||
description={confirmDialog?.description ?? ""}
|
||||
confirmLabel={confirmDialog?.confirmLabel ?? "Confirm"}
|
||||
tone={confirmDialog?.tone ?? "danger"}
|
||||
note={confirmDialog?.note}
|
||||
onClose={() => setConfirmDialog(null)}
|
||||
onConfirm={() => {
|
||||
const current = confirmDialog;
|
||||
setConfirmDialog(null);
|
||||
current?.onConfirm();
|
||||
}}
|
||||
/>
|
||||
<ToastViewport toasts={toasts} onClose={dismissToast} />
|
||||
<div
|
||||
className="animate-slide-in"
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
{error ? <Banner message={error} tone="error" /> : null}
|
||||
|
||||
{loading ? (
|
||||
<SettingsLoadingPanel />
|
||||
) : (
|
||||
<GitProvidersPanel
|
||||
providers={providers}
|
||||
actionLoading={actionLoading}
|
||||
savingProviderId={savingProviderId}
|
||||
onUpsertProvider={upsertProvider}
|
||||
onVerifyProvider={verifyProvider}
|
||||
onToggleProvider={toggleProvider}
|
||||
onDeleteProvider={handleDeleteProvider}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GuardedPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg-primary: #0c0e16;
|
||||
--bg-secondary: #0c0e16;
|
||||
--bg-card: #0c0e16;
|
||||
--bg-card-hover: #161820;
|
||||
--bg-sidebar: #060913;
|
||||
--bg-input: #11151f;
|
||||
--border: #1c1f25;
|
||||
--border-hover: #1d2029;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #8b9ec7;
|
||||
--text-danger: #ef4444;
|
||||
--text-muted: #8c93a3;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-blue-glow: rgba(59, 130, 246, 0.15);
|
||||
--accent-green: #10b981;
|
||||
--accent-green-glow: rgba(16, 185, 129, 0.15);
|
||||
--accent-yellow: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-purple: #8b5cf6;
|
||||
--accent-cyan: #06b6d4;
|
||||
--terminal-surface: #070b12;
|
||||
--terminal-header: #0d1224;
|
||||
--terminal-row-hover: rgba(59, 130, 246, 0.04);
|
||||
--terminal-timestamp: #2d3f5a;
|
||||
--terminal-body: #8b9ec7;
|
||||
--metrics-surface: rgba(15, 23, 42, 0.34);
|
||||
--metrics-surface-strong: linear-gradient(
|
||||
180deg,
|
||||
rgba(40, 53, 83, 0.74) 0%,
|
||||
rgba(15, 23, 42, 0.42) 100%
|
||||
);
|
||||
--metrics-surface-soft: rgba(15, 23, 42, 0.24);
|
||||
--metrics-shell: linear-gradient(
|
||||
180deg,
|
||||
rgba(11, 14, 24, 0.96) 0%,
|
||||
rgba(26, 29, 37, 0.96) 100%
|
||||
);
|
||||
--metrics-border: rgba(148, 163, 184, 0.14);
|
||||
--metrics-border-muted: rgba(148, 163, 184, 0.18);
|
||||
--metrics-grid: rgba(148, 163, 184, 0.12);
|
||||
--metrics-tooltip-bg: rgba(15, 23, 42, 0.94);
|
||||
--metrics-tooltip-text: #e2e8f0;
|
||||
--metrics-tooltip-label: #94a3b8;
|
||||
--metrics-axis: #64748b;
|
||||
--metrics-server-accent: #60a5fa;
|
||||
--metrics-error-text: #fca5a5;
|
||||
--metrics-error-border: rgba(239, 68, 68, 0.18);
|
||||
--metrics-error-bg: rgba(127, 29, 29, 0.16);
|
||||
--scrollbar-track: #0d1224;
|
||||
--scrollbar-thumb: #1e2a3d;
|
||||
|
||||
--font--body:
|
||||
IBM Plex Sans, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
--font--input:
|
||||
General Sans, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
--font--code: "JetBrains Mono", monospace;
|
||||
--font--terminal: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--bg-primary: #f4f7fb;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #eef3fb;
|
||||
--bg-sidebar: #eaf0f7;
|
||||
--bg-input: #f6f9fc;
|
||||
--border: #d8e1ef;
|
||||
--border-hover: #bcc8db;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #334155;
|
||||
--text-muted: #64748b;
|
||||
--terminal-surface: #fbfdff;
|
||||
--terminal-header: #eef4fb;
|
||||
--terminal-row-hover: rgba(59, 130, 246, 0.08);
|
||||
--terminal-timestamp: #64748b;
|
||||
--terminal-body: #334155;
|
||||
--metrics-surface: rgba(255, 255, 255, 0.96);
|
||||
--metrics-surface-strong: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.98) 0%,
|
||||
rgba(246, 249, 252, 0.98) 100%
|
||||
);
|
||||
--metrics-surface-soft: rgba(246, 249, 252, 0.92);
|
||||
--metrics-shell: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.98) 0%,
|
||||
rgba(244, 247, 251, 0.98) 100%
|
||||
);
|
||||
--metrics-border: rgba(148, 163, 184, 0.22);
|
||||
--metrics-border-muted: rgba(148, 163, 184, 0.28);
|
||||
--metrics-grid: rgba(148, 163, 184, 0.2);
|
||||
--metrics-tooltip-bg: rgba(255, 255, 255, 0.98);
|
||||
--metrics-tooltip-text: #0f172a;
|
||||
--metrics-tooltip-label: #475569;
|
||||
--metrics-axis: #64748b;
|
||||
--metrics-server-accent: #2563eb;
|
||||
--metrics-error-text: #b91c1c;
|
||||
--metrics-error-border: rgba(239, 68, 68, 0.24);
|
||||
--metrics-error-bg: rgba(254, 242, 242, 0.96);
|
||||
--scrollbar-track: #e2e8f0;
|
||||
--scrollbar-thumb: #cbd5e1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font--body);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-hover);
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
@keyframes glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
}
|
||||
@keyframes gradient-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
.animate-pulse-dot {
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
.animate-glow {
|
||||
animation: glow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 3s linear infinite;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.ui-responsive-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ui-inline-cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-inline-label {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-chip-scroll {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-chip-scroll > * {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-pill-switch {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ui-pill-switch > * {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-tab-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ui-tab-scroll {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-card);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ui-tab-scroll > * {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-toolbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ui-toolbar-note {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ui-responsive-toolbar,
|
||||
.ui-tab-shell {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ui-inline-cluster,
|
||||
.ui-toolbar-note,
|
||||
.ui-toolbar-actions {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.ui-toolbar-note {
|
||||
margin-left: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ui-toolbar-actions {
|
||||
margin-left: 0;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.ui-toolbar-actions > button,
|
||||
.ui-toolbar-actions > a {
|
||||
flex: 1 1 160px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ui-pill-switch,
|
||||
.ui-tab-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #06b6d4);
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 4s ease infinite;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge-online {
|
||||
--badge-color: #10b981;
|
||||
--badge-bg: rgba(16, 185, 129, 0.12);
|
||||
--badge-border: rgba(16, 185, 129, 0.86);
|
||||
}
|
||||
.badge-offline {
|
||||
--badge-color: #ef4444;
|
||||
--badge-bg: rgba(239, 68, 68, 0.12);
|
||||
--badge-border: rgba(239, 68, 68, 0.86);
|
||||
}
|
||||
.badge-warning {
|
||||
--badge-color: #f59e0b;
|
||||
--badge-bg: rgba(245, 158, 11, 0.12);
|
||||
--badge-border: rgba(245, 158, 11, 0.88);
|
||||
}
|
||||
.badge-info {
|
||||
--badge-color: #3b82f6;
|
||||
--badge-bg: rgba(59, 130, 246, 0.12);
|
||||
--badge-border: rgba(59, 130, 246, 0.88);
|
||||
}
|
||||
|
||||
.ui-badge {
|
||||
/* --badge-color: var(--text-secondary);
|
||||
--badge-bg: rgba(148, 163, 184, 0.1);
|
||||
--badge-border: rgba(148, 163, 184, 0.5); */
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
min-height: 24px;
|
||||
padding: 3px 11px;
|
||||
border: 1px solid var(--badge-border);
|
||||
border-radius: 999px;
|
||||
background: var(--badge-bg);
|
||||
color: var(--badge-color);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Progress bars */
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* Sidebar nav items */
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #3b82f6;
|
||||
border-left: 2px solid #3b82f6;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.nav-item.active .nav-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.data-table thead th {
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.data-table tbody tr {
|
||||
border-bottom: 1px solid rgba(30, 42, 61, 0.5);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.data-table tbody tr:hover {
|
||||
background: rgba(59, 130, 246, 0.04);
|
||||
}
|
||||
.data-table tbody td {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
.btn-sm {
|
||||
max-height: 35px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #1d4ed8;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #1e40af;
|
||||
/* transform: translateY(-1px); */
|
||||
/* box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); */
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
.btn-warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-warning:hover {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger {
|
||||
background: rgba(173, 39, 39, 0.15);
|
||||
color: #dd2222;
|
||||
border: 1px solid rgba(173, 39, 39, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.btn-success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-success:hover {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
.btn-normal {
|
||||
background: #e2e8f0;
|
||||
color: #121213;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-normal:hover {
|
||||
background: #d7dadf;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font--input);
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: none;
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: slide-in 0.2s ease-out;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
contain: layout paint style;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
/* box-shadow: 0 18px 44px rgba(0, 0, 0, 0.45); */
|
||||
contain: content;
|
||||
}
|
||||
|
||||
.modal::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.modal::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 12px 6px 12px 0;
|
||||
}
|
||||
|
||||
.modal::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 999px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.modal::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-hover);
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Floating particles background */
|
||||
.particles-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Terminal font */
|
||||
.terminal-text {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.terminal-pane {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.terminal-pane .terminal-pane__xterm,
|
||||
.terminal-pane .xterm {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-pane .xterm-viewport,
|
||||
.terminal-pane .xterm-screen,
|
||||
.terminal-pane .xterm-helpers,
|
||||
.terminal-pane .xterm-helper-textarea,
|
||||
.terminal-pane .xterm-accessibility {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
}
|
||||
.tooltip-content {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
transform 0.15s;
|
||||
z-index: 100;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
.tooltip:hover .tooltip-content {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
Shield,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { auth, setToken, setUser, UserRole } from "@/lib/api";
|
||||
|
||||
function roleLabel(role: UserRole) {
|
||||
switch (role) {
|
||||
case "SUPER_ADMIN":
|
||||
return "Super Admin";
|
||||
case "OPERATOR":
|
||||
return "Operator";
|
||||
case "DEVELOPER":
|
||||
return "Developer";
|
||||
case "VIEWER":
|
||||
default:
|
||||
return "Viewer";
|
||||
}
|
||||
}
|
||||
|
||||
export default function InvitationPage() {
|
||||
const params = useParams<{ token: string }>();
|
||||
const router = useRouter();
|
||||
const token = Array.isArray(params?.token) ? params.token[0] : params?.token;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [invitation, setInvitation] = useState<{
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
allServersAccess: boolean;
|
||||
serverIds: string[];
|
||||
expiresAt: string;
|
||||
} | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError("Invitation token is missing.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
setError("");
|
||||
const response = await auth.getInvitation(token);
|
||||
setInvitation(response.data);
|
||||
setForm((current) => ({ ...current, name: response.data.name }));
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load invitation",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError("Password confirmation does not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await auth.acceptInvitation(token, {
|
||||
name: form.name,
|
||||
password: form.password,
|
||||
});
|
||||
|
||||
setToken(response.token);
|
||||
setUser(response.user);
|
||||
router.replace("/");
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to accept invitation",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background: "var(--bg-primary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="card animate-slide-in"
|
||||
style={{ width: "100%", maxWidth: 460, padding: 32 }}
|
||||
>
|
||||
<div style={{ textAlign: "center", marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(135deg, #3b82f6, #6366f1)",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<CheckCircle size={26} color="#fff" />
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Accept Invitation
|
||||
</h1>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 13, marginTop: 4 }}>
|
||||
Finish onboarding and set your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 20,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Loading invitation...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 14px",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={15} color="#ef4444" />
|
||||
<span style={{ fontSize: 13, color: "#ef4444" }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invitation && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Mail size={13} />
|
||||
<span>{invitation.email}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<User size={13} />
|
||||
<span>{roleLabel(invitation.role)}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Shield size={13} />
|
||||
<span>
|
||||
{invitation.allServersAccess
|
||||
? "Access to all servers"
|
||||
: `${invitation.serverIds.length} selected server(s)`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 14 }}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Lock
|
||||
size={14}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 11,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
placeholder="Minimum 8 characters"
|
||||
value={form.password}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
password: event.target.value,
|
||||
}))
|
||||
}
|
||||
minLength={8}
|
||||
required
|
||||
style={{ paddingLeft: 34 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Lock
|
||||
size={14}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 11,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
placeholder="Repeat password"
|
||||
value={form.confirmPassword}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
confirmPassword: event.target.value,
|
||||
}))
|
||||
}
|
||||
minLength={8}
|
||||
required
|
||||
style={{ paddingLeft: 34 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<CheckCircle size={13} />
|
||||
)}
|
||||
Accept Invitation
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import ThemeController from "@/components/ThemeController";
|
||||
import { getInitialThemeScript } from "@/lib/theme";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Doktainer — Server & Infrastructure Management",
|
||||
description:
|
||||
"Manage your VPS servers, Docker containers, domains, SSL, and security all from one powerful dashboard.",
|
||||
keywords: ["VPS", "Docker", "Server Management", "SSL", "Domain", "DevOps"],
|
||||
authors: [{ name: "KodekaTeamOfficial" }],
|
||||
openGraph: {
|
||||
title: "Doktainer — Server & Infrastructure Management",
|
||||
description:
|
||||
"Manage your VPS servers, Docker containers, domains, SSL, and security all from one powerful dashboard.",
|
||||
type: "website",
|
||||
},
|
||||
icons: {
|
||||
icon: "/assets/images/favicon.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: getInitialThemeScript() }} />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<ThemeController />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "var(--bg-main)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "14px 18px",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Loading page...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
} from "lucide-react";
|
||||
import { auth, getToken, setToken, setUser } from "@/lib/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [registrationOpen, setRegistrationOpen] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
totpCode: "",
|
||||
});
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [awaitingTwoFactor, setAwaitingTwoFactor] = useState(false);
|
||||
const [reason, setReason] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const reasonParam = params.get("reason");
|
||||
setReason(reasonParam);
|
||||
|
||||
if (reasonParam === "session-expired") {
|
||||
setError("The login session has expired. Please log in again.");
|
||||
} else if (getToken()) {
|
||||
router.replace("/");
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadRegistrationStatus = async () => {
|
||||
try {
|
||||
const res = await auth.registrationStatus();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRegistrationOpen(res.data.registrationOpen);
|
||||
setMode(res.data.registrationOpen ? "register" : "login");
|
||||
} catch {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRegistrationOpen(false);
|
||||
setMode("login");
|
||||
}
|
||||
};
|
||||
|
||||
void loadRegistrationStatus();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const resetTwoFactorStep = () => {
|
||||
setAwaitingTwoFactor(false);
|
||||
setForm((current) => ({ ...current, totpCode: "" }));
|
||||
};
|
||||
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res =
|
||||
mode === "login"
|
||||
? await auth.login(
|
||||
form.email,
|
||||
form.password,
|
||||
awaitingTwoFactor ? form.totpCode : undefined,
|
||||
)
|
||||
: await auth.register(form.name, form.email, form.password);
|
||||
|
||||
if (
|
||||
mode === "login" &&
|
||||
"requiresTwoFactor" in res &&
|
||||
res.requiresTwoFactor
|
||||
) {
|
||||
setAwaitingTwoFactor(true);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!("token" in res) || !("user" in res)) {
|
||||
throw new Error("Authentication failed");
|
||||
}
|
||||
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
router.replace("/");
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Authentication failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background:
|
||||
"radial-gradient(circle at top, rgba(59,130,246,0.16), transparent 32%), var(--bg-primary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="card animate-slide-in"
|
||||
style={{ width: "100%", maxWidth: 420, padding: 36 }}
|
||||
>
|
||||
<div style={{ textAlign: "center", marginBottom: 28 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 135,
|
||||
height: 135,
|
||||
borderRadius: 14,
|
||||
// background: "linear-gradient(135deg, #2563eb, #0ea5e9)",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
{/* <Server size={26} color="#fff" /> */}
|
||||
<img
|
||||
src={
|
||||
reason === "session-expired"
|
||||
? "/assets/images/img-chibi-forgiveness.png"
|
||||
: "/assets/images/img-chibi-happy.png"
|
||||
}
|
||||
alt="Logo"
|
||||
width={135}
|
||||
height={135}
|
||||
/>
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
marginTop: -25,
|
||||
}}
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_PANEL_NAME || "DOKTAINER"}
|
||||
</h1>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 13, marginTop: 4 }}>
|
||||
{registrationOpen === null
|
||||
? "Checking account setup"
|
||||
: mode === "login"
|
||||
? awaitingTwoFactor
|
||||
? "Enter your 2FA code to access your account"
|
||||
: "Sign in to your account"
|
||||
: "Create your account"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 14px",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={15} color="#ef4444" />
|
||||
<span style={{ fontSize: 13, color: "#ef4444" }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationOpen === null && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
<span>Checking account setup...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{awaitingTwoFactor && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(14,165,233,0.28)",
|
||||
background: "rgba(14,165,233,0.08)",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Two-factor authentication is enabled on this account. Please enter
|
||||
the 6-digit code from your authenticator app to proceed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationOpen !== null && (
|
||||
<form
|
||||
onSubmit={handle}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 14 }}
|
||||
>
|
||||
{mode === "register" && !awaitingTwoFactor && (
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="John Doe"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!awaitingTwoFactor && (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Mail
|
||||
size={14}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 11,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
placeholder="[email protected]"
|
||||
value={form.email}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, email: e.target.value })
|
||||
}
|
||||
required
|
||||
style={{ width: "100%", paddingLeft: 34 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Lock
|
||||
size={14}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 11,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type={showPw ? "text" : "password"}
|
||||
className="input"
|
||||
placeholder={
|
||||
mode === "register"
|
||||
? "Min 8 characters"
|
||||
: "Your password"
|
||||
}
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, password: e.target.value })
|
||||
}
|
||||
required
|
||||
minLength={mode === "register" ? 8 : 6}
|
||||
style={{
|
||||
width: "100%",
|
||||
paddingLeft: 34,
|
||||
paddingRight: 36,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPw(!showPw)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{showPw ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{awaitingTwoFactor && (
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
display: "block",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Authentication Code
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<KeyRound
|
||||
size={14}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 11,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
value={form.totpCode}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
totpCode: e.target.value.replace(/\D/g, "").slice(0, 6),
|
||||
})
|
||||
}
|
||||
required
|
||||
style={{
|
||||
width: "100%",
|
||||
paddingLeft: 34,
|
||||
letterSpacing: "0.28em",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-${mode === "login" ? "primary" : "success"} btn-sm`}
|
||||
disabled={loading || registrationOpen === null}
|
||||
style={{
|
||||
marginTop: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{loading && <Loader2 size={15} className="animate-spin" />}
|
||||
{mode === "login"
|
||||
? awaitingTwoFactor
|
||||
? "Verify Code"
|
||||
: "Sign In"
|
||||
: "Create Account"}
|
||||
</button>
|
||||
|
||||
{awaitingTwoFactor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetTwoFactorStep}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Use different email or password
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{registrationOpen !== null && (
|
||||
<p
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
{registrationOpen === null
|
||||
? ""
|
||||
: registrationOpen
|
||||
? "Register above - first user become Super Admin."
|
||||
: "Sign in with an existing account."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
"use client";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import SearchField from "@/components/SearchField";
|
||||
import ToastViewport from "@/components/ToastViewport";
|
||||
import {
|
||||
logs as logsApi,
|
||||
servers as serversApi,
|
||||
type AuditLog,
|
||||
type Server,
|
||||
} from "@/lib/api";
|
||||
import { sanitizeLogText } from "@/lib/terminal-output";
|
||||
import { useToastManager } from "@/lib/use-toast-manager";
|
||||
import { FileText, Download, Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const categoryOptions = [
|
||||
"ALL",
|
||||
"AUTH",
|
||||
"SERVER",
|
||||
"CONTAINER",
|
||||
"DOMAIN",
|
||||
"SSL",
|
||||
"SECURITY",
|
||||
"SYSTEM",
|
||||
"TERMINAL",
|
||||
] as const;
|
||||
|
||||
const levelOptions = [
|
||||
"ALL",
|
||||
"DEBUG",
|
||||
"INFO",
|
||||
"WARNING",
|
||||
"ERROR",
|
||||
"SUCCESS",
|
||||
] as const;
|
||||
|
||||
type LogCategoryFilter = (typeof categoryOptions)[number];
|
||||
type LogLevelFilter = (typeof levelOptions)[number];
|
||||
|
||||
function sanitizeAuditLog(log: AuditLog): AuditLog {
|
||||
return {
|
||||
...log,
|
||||
message: sanitizeLogText(log.message),
|
||||
action: sanitizeLogText(log.action),
|
||||
category: sanitizeLogText(log.category),
|
||||
level: sanitizeLogText(log.level),
|
||||
server: log.server
|
||||
? {
|
||||
...log.server,
|
||||
name: sanitizeLogText(log.server.name),
|
||||
}
|
||||
: log.server,
|
||||
user: log.user
|
||||
? {
|
||||
...log.user,
|
||||
name: sanitizeLogText(log.user.name),
|
||||
}
|
||||
: log.user,
|
||||
};
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
INFO: "#3b82f6",
|
||||
WARNING: "#f59e0b",
|
||||
ERROR: "#ef4444",
|
||||
DEBUG: "#64748b",
|
||||
SUCCESS: "#10b981",
|
||||
};
|
||||
|
||||
function formatLogTimestamp(value: string) {
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString("id-ID", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
fractionalSecondDigits: 3,
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatLastUpdated(value: string | null) {
|
||||
if (!value) {
|
||||
return "Belum ada sinkronisasi";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "Belum ada sinkronisasi";
|
||||
}
|
||||
|
||||
return date.toLocaleTimeString("id-ID", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function escapeCsvCell(value: string) {
|
||||
const escaped = value.replace(/"/g, '""');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
function buildExportFileName() {
|
||||
return `logs-${new Date().toISOString().replace(/[.:]/g, "-")}.csv`;
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [level, setLevel] = useState<LogLevelFilter>("ALL");
|
||||
const [category, setCategory] = useState<LogCategoryFilter>("ALL");
|
||||
const [serverId, setServerId] = useState("");
|
||||
const [live, setLive] = useState(true);
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { toasts, pushToast, dismissToast } = useToastManager();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadServers() {
|
||||
try {
|
||||
const response = await serversApi.list();
|
||||
|
||||
if (!cancelled) {
|
||||
setServers(response.data ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
pushToast({
|
||||
tone: "warning",
|
||||
title: "Servers tidak termuat",
|
||||
message:
|
||||
"Filter server tetap tersedia, tetapi daftar server gagal diambil.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadServers();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pushToast]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadLogs(showLoading: boolean) {
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setRefreshing(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await logsApi.list({
|
||||
serverId: serverId || undefined,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLogs((response.data ?? []).map(sanitizeAuditLog));
|
||||
setError("");
|
||||
setLastUpdatedAt(new Date().toISOString());
|
||||
} catch (err) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Gagal memuat data logs.",
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadLogs(true);
|
||||
|
||||
if (!live) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void loadLogs(false);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [live, serverId]);
|
||||
|
||||
const searchTerm = search.trim().toLowerCase();
|
||||
const filtered = [...logs]
|
||||
.filter((log) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
[
|
||||
log.message,
|
||||
log.action,
|
||||
log.category,
|
||||
log.level,
|
||||
log.server?.name ?? "",
|
||||
log.user?.name ?? "",
|
||||
].some((value) => value.toLowerCase().includes(searchTerm));
|
||||
const matchesLevel = level === "ALL" || log.level === level;
|
||||
const matchesCategory = category === "ALL" || log.category === category;
|
||||
|
||||
return matchesSearch && matchesLevel && matchesCategory;
|
||||
})
|
||||
.reverse();
|
||||
|
||||
useEffect(() => {
|
||||
if (live) {
|
||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||
}
|
||||
}, [filtered.length, live]);
|
||||
|
||||
function handleExport() {
|
||||
if (!filtered.length) {
|
||||
pushToast({
|
||||
tone: "warning",
|
||||
title: "Tidak ada data",
|
||||
message: "Tidak ada log yang bisa diekspor untuk filter saat ini.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setExporting(true);
|
||||
|
||||
try {
|
||||
const csvRows = [
|
||||
[
|
||||
"Timestamp",
|
||||
"Level",
|
||||
"Category",
|
||||
"Server",
|
||||
"Action",
|
||||
"Message",
|
||||
"User",
|
||||
],
|
||||
...filtered.map((log) => [
|
||||
formatLogTimestamp(log.createdAt),
|
||||
log.level,
|
||||
log.category,
|
||||
log.server?.name ?? "-",
|
||||
log.action,
|
||||
log.message,
|
||||
log.user?.name ?? "-",
|
||||
]),
|
||||
];
|
||||
const csv = csvRows
|
||||
.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(","))
|
||||
.join("\r\n");
|
||||
const blob = new Blob([`\uFEFF${csv}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = url;
|
||||
link.download = buildExportFileName();
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Export berhasil",
|
||||
message: `${filtered.length} log berhasil diekspor ke CSV.`,
|
||||
});
|
||||
} catch {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Export gagal",
|
||||
message: "Terjadi masalah saat membuat file export logs.",
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardLayout
|
||||
title="Live Logs"
|
||||
subtitle="Audit log nyata dengan refresh berkala dan export CSV"
|
||||
>
|
||||
<div
|
||||
className="animate-slide-in"
|
||||
style={{ display: "flex", flexDirection: "column", gap: 14 }}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<SearchField
|
||||
placeholder="Cari message, action, server, atau user..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
containerStyle={{
|
||||
flex: "1 1 280px",
|
||||
minWidth: 220,
|
||||
maxWidth: 480,
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={serverId}
|
||||
onChange={(e) => setServerId(e.target.value)}
|
||||
className="input"
|
||||
style={{ width: 190, appearance: "none", flex: "0 0 auto" }}
|
||||
>
|
||||
<option value="">All servers</option>
|
||||
{servers.map((server) => (
|
||||
<option key={server.id} value={server.id}>
|
||||
{server.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as LogCategoryFilter)}
|
||||
className="input"
|
||||
style={{ width: 160, appearance: "none", flex: "0 0 auto" }}
|
||||
>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option === "ALL" ? "All categories" : option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
background: "var(--bg-input)",
|
||||
borderRadius: 8,
|
||||
padding: 3,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{levelOptions.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setLevel(option)}
|
||||
type="button"
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background:
|
||||
level === option ? "var(--bg-card)" : "transparent",
|
||||
color:
|
||||
level === option
|
||||
? levelColors[option] || "var(--text-primary)"
|
||||
: "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setLive((current) => !current)}
|
||||
type="button"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${live ? "rgba(16,185,129,0.3)" : "var(--border)"}`,
|
||||
background: live
|
||||
? "rgba(16,185,129,0.08)"
|
||||
: "var(--bg-input)",
|
||||
color: live ? "#10b981" : "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{live && (
|
||||
<span
|
||||
className="animate-pulse-dot"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: "#10b981",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{live ? "Live" : "Paused"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={handleExport}
|
||||
type="button"
|
||||
disabled={exporting || !filtered.length}
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={12} />
|
||||
)}
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: error ? "#fca5a5" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{error ||
|
||||
`Showing ${filtered.length} of ${logs.length} latest logs`}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
Last updated {formatLastUpdated(lastUpdatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="card terminal-text"
|
||||
style={{
|
||||
background: "var(--terminal-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--terminal-header)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<FileText size={13} style={{ color: "#3b82f6" }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Audit Log Stream
|
||||
</span>
|
||||
{(loading || refreshing) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#60a5fa",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Synchronizing...
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{filtered.length} lines
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: "calc(100vh - 340px)",
|
||||
overflowY: "auto",
|
||||
padding: "12px 0",
|
||||
}}
|
||||
>
|
||||
{!loading && !filtered.length ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "24px 16px",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Tidak ada log yang cocok dengan filter saat ini.
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 0,
|
||||
padding: "2px 16px",
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
((e.currentTarget as HTMLElement).style.background =
|
||||
"var(--terminal-row-hover)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
((e.currentTarget as HTMLElement).style.background =
|
||||
"transparent")
|
||||
}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--terminal-timestamp)",
|
||||
flexShrink: 0,
|
||||
width: 205,
|
||||
}}
|
||||
>
|
||||
{formatLogTimestamp(log.createdAt)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: levelColors[log.level] || "#3b82f6",
|
||||
flexShrink: 0,
|
||||
width: 86,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span
|
||||
style={{ color: "#3b82f6", flexShrink: 0, width: 170 }}
|
||||
>
|
||||
{log.server?.name ?? log.category}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "#94a3b8",
|
||||
flexShrink: 0,
|
||||
width: 320,
|
||||
}}
|
||||
>
|
||||
{log.action}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
log.level === "ERROR"
|
||||
? "#ef4444"
|
||||
: log.level === "WARNING"
|
||||
? "#f59e0b"
|
||||
: log.level === "SUCCESS"
|
||||
? "#10b981"
|
||||
: "var(--terminal-body)",
|
||||
}}
|
||||
>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
<ToastViewport toasts={toasts} onClose={dismissToast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||