first commit

This commit is contained in:
KodekaTeam committed 2026-06-02 00:17:50 +07:00
1 parent abb09fbea8
commit 72f6d49818
355 files changed
+121239 -3

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
node_modules/
.next/
dist/
.git/
.gitignore
.env
npm-debug.log
docker-compose*.yml
coverage/
scripts/debug-auth.mts
+83
View File
@@ -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
+54
View File
@@ -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=
+51
View File
@@ -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
+36
View File
@@ -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.
+61
View File
@@ -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 -1
View File
@@ -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
+365
View File
@@ -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
![GitHub forks](https://img.shields.io/github/forks/DoktainerApp/doktainer)
![GitHub forks](https://img.shields.io/github/stars/DoktainerApp/doktainer)<br>
[![Discord](https://img.shields.io/discord/1497461926609948733?color=5865f2&label=Discord&style=flat-square)](https://discord.gg/tDUBDCYw9Q)
[![Docker Pulls](https://img.shields.io/docker/pulls/DoktainerApp/doktainer.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/DoktainerApp/doktainer)<br>
[![GHCR](https://img.shields.io/badge/GHCR-doktainer%2Fdoktainer-blue?logo=github)](https://github.com/DoktainerApp/doktainer/pkgs/container/doktainer)
[![License](https://img.shields.io/npm/l/doktainer.svg)](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>
+365 -2
View File
@@ -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
![GitHub forks](https://img.shields.io/github/forks/DoktainerApp/doktainer)
![GitHub forks](https://img.shields.io/github/stars/DoktainerApp/doktainer)<br>
[![Discord](https://img.shields.io/discord/1497461926609948733?color=5865f2&label=Discord&style=flat-square)](https://discord.gg/tDUBDCYw9Q)
[![Docker Pulls](https://img.shields.io/docker/pulls/DoktainerApp/doktainer.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/DoktainerApp/doktainer)<br>
[![GHCR](https://img.shields.io/badge/GHCR-doktainer%2Fdoktainer-blue?logo=github)](https://github.com/DoktainerApp/doktainer/pkgs/container/doktainer)
[![License](https://img.shields.io/npm/l/doktainer.svg)](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>
+13
View File
@@ -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}
+60
View File
@@ -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
+60
View File
@@ -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
+17
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+6
View File
@@ -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.
+71
View File
@@ -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;
+12532
View File
File diff suppressed because it is too large. Load diff
+87
View File
@@ -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"
}
}
+7
View File
@@ -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';
+776
View File
@@ -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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+43
View File
@@ -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);
}
+475
View File
@@ -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>
);
}
File diff suppressed because it is too large. Load diff
+77
View File
@@ -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");
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
+591
View File
@@ -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);
}
File diff suppressed because it is too large. Load diff
@@ -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),
};
}
+307
View File
@@ -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>
);
}
+118
View File
@@ -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>
);
}
+295
View File
@@ -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>
);
}
+72
View File
@@ -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;
};
+199
View File
@@ -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)",
};
}
}
+602
View File
@@ -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>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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";
}
+168
View File
@@ -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>
);
}
+169
View File
@@ -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&apos;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>
);
}
+308
View File
@@ -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>
);
}
+714
View File
@@ -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);
}
+366
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+467
View File
@@ -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>
);
}
+626
View File
@@ -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} />
</>
);
}
Loaded 100 of 355 files, more files were not shown because too many files have changed in this diff. Show more