Compare commits

...

7 Commits

7 changed files with 650 additions and 369 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
.next
.git
*.log
logs
.env
.env.*
!.env.example
.env.local
.env.*.local
.vscode
.idea
.DS_Store

View File

@@ -1,14 +1,22 @@
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
NODE_ENV=production
NEXT_PUBLIC_APP_URL=http://localhost:3000
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
PLESK_CRED_ENC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
CRON_SHARED_SECRET=
# Existing runtime keys used by current codebase
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
STRIPE_PRICE_ID=

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM cgr.dev/chainguard/node:20-dev AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM cgr.dev/chainguard/node:20-dev AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM cgr.dev/chainguard/node:20-dev AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM cgr.dev/chainguard/node:20 AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.mjs ./next.config.mjs
COPY --from=builder /app/.next ./.next
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-H", "0.0.0.0", "-p", "3000"]

13
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json(
{
ok: true,
service: "plesk-agency-portal",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV ?? "development",
},
{ status: 200 },
);
}

View File

@@ -160,6 +160,23 @@ function getDomainHref(domainName: string) {
return `https://${normalized}`;
}
function getLatestTimestamp(values: Array<string | null | undefined>) {
let latest: string | null = null;
let latestMs = Number.NEGATIVE_INFINITY;
for (const value of values) {
if (!value) continue;
const ms = new Date(value).getTime();
if (Number.isNaN(ms)) continue;
if (ms > latestMs) {
latestMs = ms;
latest = value;
}
}
return latest;
}
export function DashboardControls({
instances,
subscriptions,
@@ -184,6 +201,7 @@ export function DashboardControls({
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [isConnectPanelOpen, setIsConnectPanelOpen] = useState(!hasInstances);
const [isInstancesPanelOpen, setIsInstancesPanelOpen] = useState(true);
useEffect(() => {
if (!hasInstances) {
@@ -217,6 +235,38 @@ export function DashboardControls({
);
}, [hasInstances, isConnectPanelOpen]);
useEffect(() => {
if (!hasInstances) {
setIsInstancesPanelOpen(true);
return;
}
const savedState = window.localStorage.getItem(
"dashboard_instances_panel_open",
);
if (savedState === "true") {
setIsInstancesPanelOpen(true);
return;
}
if (savedState === "false") {
setIsInstancesPanelOpen(false);
return;
}
setIsInstancesPanelOpen(true);
}, [hasInstances]);
useEffect(() => {
if (!hasInstances) return;
window.localStorage.setItem(
"dashboard_instances_panel_open",
isInstancesPanelOpen ? "true" : "false",
);
}, [hasInstances, isInstancesPanelOpen]);
const healthCounts = instances.reduce(
(acc, instance) => {
if (instance.status === "connected") acc.connected += 1;
@@ -245,6 +295,30 @@ export function DashboardControls({
return statusMatches && searchMatches;
});
const instanceHealthSummary = instances.reduce(
(acc, instance) => {
const status = (instance.health_status ?? "unknown").toLowerCase();
if (status === "ok") acc.ok += 1;
else if (status === "degraded") acc.degraded += 1;
else if (status === "down") acc.down += 1;
else acc.unknown += 1;
return acc;
},
{ ok: 0, degraded: 0, down: 0, unknown: 0 },
);
const autoSyncEnabledCount = instances.filter(
(instance) => instance.auto_sync_enabled,
).length;
const latestInstanceSyncAt = getLatestTimestamp(
instances.map((instance) => instance.last_sync_at),
);
const latestInstanceHealthCheckAt = getLatestTimestamp(
instances.map((instance) => instance.health_last_checked_at),
);
async function runRequest(url: string, body?: unknown) {
const response = await fetch(url, {
method: "POST",
@@ -548,8 +622,45 @@ export function DashboardControls({
</div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Plesk Instances</h2>
{hasInstances ? (
<button
type="button"
onClick={() => setIsInstancesPanelOpen((open) => !open)}
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
>
{isInstancesPanelOpen ? "Hide" : "Show"}
</button>
) : null}
</div>
{!isInstancesPanelOpen && hasInstances ? (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-700">
<p className="font-medium text-slate-900">
{instances.length} instance{instances.length === 1 ? "" : "s"}
</p>
<p className="mt-1">
Health OK: {instanceHealthSummary.ok}, Degraded:{" "}
{instanceHealthSummary.degraded}, Down:{" "}
{instanceHealthSummary.down}, Unknown:{" "}
{instanceHealthSummary.unknown}
</p>
<p className="mt-1">
Auto-sync enabled on {autoSyncEnabledCount} instance
{autoSyncEnabledCount === 1 ? "" : "s"}
</p>
<p className="mt-1">
Last sync: {formatDateTime(latestInstanceSyncAt)}
</p>
<p className="mt-1">
Last health check: {formatDateTime(latestInstanceHealthCheckAt)}
</p>
</div>
) : null}
{isInstancesPanelOpen ? (
<>
<div className="space-y-2">
{instances.length === 0 ? (
<p className="text-sm text-slate-500">
@@ -576,7 +687,8 @@ export function DashboardControls({
: "bg-rose-100 text-rose-700"
}`}
>
Auto-sync {autoSyncByInstance[instance.id].status}
Auto-sync{" "}
{autoSyncByInstance[instance.id].status}
</span>
) : null}
<p className="text-sm font-semibold text-slate-900">
@@ -594,7 +706,9 @@ export function DashboardControls({
<p className="text-xs text-slate-500">
Health:{" "}
<span
title={instance.health_last_error ?? undefined}
title={
instance.health_last_error ?? undefined
}
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${health.className}`}
>
{health.label}
@@ -602,7 +716,9 @@ export function DashboardControls({
</p>
<p className="text-xs text-slate-500">
Health last checked:{" "}
{formatDateTime(instance.health_last_checked_at)}
{formatDateTime(
instance.health_last_checked_at,
)}
</p>
<p className="text-xs text-slate-500">
Health latency:{" "}
@@ -623,7 +739,8 @@ export function DashboardControls({
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${
instance.last_sync_status === "success"
? "bg-emerald-100 text-emerald-700"
: instance.last_sync_status === "failed" ||
: instance.last_sync_status ===
"failed" ||
instance.last_sync_status === "error"
? "bg-rose-100 text-rose-700"
: "bg-slate-100 text-slate-600"
@@ -663,8 +780,9 @@ export function DashboardControls({
<p className="text-xs text-slate-500">
Lock status:{" "}
{instance.auto_sync_lock_until &&
new Date(instance.auto_sync_lock_until).getTime() >
Date.now()
new Date(
instance.auto_sync_lock_until,
).getTime() > Date.now()
? "Sync in progress"
: "Idle"}
</p>
@@ -686,10 +804,14 @@ export function DashboardControls({
Last sync error: {instance.last_sync_error}
</p>
) : null}
{autoSyncByInstance[instance.id]?.error_message ? (
{autoSyncByInstance[instance.id]
?.error_message ? (
<p className="mt-1 text-xs text-rose-600">
Auto-sync error:{" "}
{autoSyncByInstance[instance.id].error_message}
{
autoSyncByInstance[instance.id]
.error_message
}
</p>
) : null}
{instance.health_last_error ? (
@@ -709,11 +831,14 @@ export function DashboardControls({
<input
type="checkbox"
checked={instance.auto_sync_enabled}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
onChange={(
e: ChangeEvent<HTMLInputElement>,
) =>
onUpdateAutoSync(instance.id, {
auto_sync_enabled: e.target.checked,
auto_sync_frequency_minutes:
instance.auto_sync_frequency_minutes ?? 60,
instance.auto_sync_frequency_minutes ??
60,
})
}
disabled={loading}
@@ -725,7 +850,8 @@ export function DashboardControls({
)}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onUpdateAutoSync(instance.id, {
auto_sync_enabled: instance.auto_sync_enabled,
auto_sync_enabled:
instance.auto_sync_enabled,
auto_sync_frequency_minutes: Number(
e.target.value,
),
@@ -836,7 +962,9 @@ export function DashboardControls({
<td className="px-2 py-1">
{formatDateTime(event.created_at)}
</td>
<td className="px-2 py-1">{event.action}</td>
<td className="px-2 py-1">
{event.action}
</td>
<td className="px-2 py-1">
{mapHealthResult(event.action)}
</td>
@@ -871,7 +999,9 @@ export function DashboardControls({
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Subscription Action</label>
<label className="text-sm font-medium">
Subscription Action
</label>
<select
value={actionSubId}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
@@ -921,7 +1051,11 @@ export function DashboardControls({
</button>
</div>
{message ? <p className="text-sm text-slate-600">{message}</p> : null}
{message ? (
<p className="text-sm text-slate-600">{message}</p>
) : null}
</>
) : null}
</div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 lg:col-span-2">

23
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
web:
container_name: plesk-agency-portal
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- .env
ports:
- "3000:3000"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s

View File

@@ -0,0 +1,58 @@
# Production Deployment Runtime Foundation
This project can be deployed in a dedicated Proxmox container using Docker Compose. The files added here establish a minimal production runtime baseline without changing application business logic or Supabase security boundaries.
## Dockerfile purpose
The `Dockerfile` uses a multi-stage build on Node 20:
- installs dependencies
- runs `next build`
- prepares a lean runtime image with production dependencies
- runs the app with `next start` on port `3000`
This keeps the runtime image small and focused on serving the built Next.js app.
## docker-compose.prod.yml purpose
`docker-compose.prod.yml` defines one service:
- service name: `web`
- container name: `plesk-agency-portal`
- restart policy: `unless-stopped`
- environment from `.env`
- port mapping `3000:3000`
- health check against `/api/health`
## External Supabase requirement
Supabase remains external to this application container. The app container must only connect to Supabase via environment variables. No local Supabase container is included in this production compose file.
## Health endpoint usage
`GET /api/health` provides a fast, dependency-light runtime check for container orchestration and uptime checks.
Example:
```bash
curl -s http://localhost:3000/api/health
```
Expected response shape:
- `ok: true`
- `service: "plesk-agency-portal"`
- `timestamp`
- `environment`
## Production environment expectations
Before deploying, set production-safe values in `.env` (using `.env.example` as the template), including:
- app URL and `NODE_ENV`
- Supabase public and server credentials
- Plesk credential encryption key
- Stripe secrets
- cron/job shared secrets
Keep secrets out of source control.