Compare commits
79 Commits
cl3.6a-dom
...
fix/commen
| Author | SHA1 | Date | |
|---|---|---|---|
| c0d3eb84bd | |||
| c42709194c | |||
| 63590a975e | |||
| e0f7636ea6 | |||
| 403c6dd0a0 | |||
| a3030703ce | |||
| 820b676bd8 | |||
| cf1cd39c54 | |||
| 4a60c7ec39 | |||
| b8d2693c67 | |||
| f4ff058a8f | |||
| 7378b09302 | |||
| ad9b6ef7d2 | |||
| fa97ae0400 | |||
| d0a0a81a31 | |||
| 627a34728e | |||
| 826e7da895 | |||
| 8d86378341 | |||
| fb385b59c4 | |||
| 9411f292a8 | |||
| 953b62ef6e | |||
| 464dccedea | |||
| 699454f183 | |||
| 03dfa6a5ae | |||
| 4f2e0c8192 | |||
| 5e5fb3221a | |||
| 34aa17a74a | |||
| c943d2cbf6 | |||
| c11fcd01a4 | |||
| 710227a49a | |||
| b72176b276 | |||
| 81b0617390 | |||
| 757e8953be | |||
| c528392682 | |||
| a678713d1d | |||
| 033d816d42 | |||
| a22fc2c4d2 | |||
| 825bf8dc84 | |||
| 2c52d89dc9 | |||
| cc7e30092d | |||
| ce8b04285a | |||
| ed31598a1d | |||
| a49448c04b | |||
| dceccecf0c | |||
| 8e5d9865ac | |||
| ee9b90e3d1 | |||
| ea671336d3 | |||
| 0d914f5973 | |||
| fbeb089b9e | |||
| 269c9e47c9 | |||
| c9e3fe1dbc | |||
| 78d3f104de | |||
| e4465d9549 | |||
| 698090853c | |||
| e9c65f8096 | |||
| 84a1396265 | |||
| fec7c682f0 | |||
| 5adc9f0923 | |||
| d87808af23 | |||
| 57005859c3 | |||
| b221ad45fa | |||
| 9ec1259403 | |||
| 84165f47ff | |||
| 067e0d9080 | |||
| e90b34e6ca | |||
| f2ad5c9d7d | |||
| 3bc2cf09c3 | |||
| e619c12c35 | |||
| a64de5aba3 | |||
| b80b3797fe | |||
| fa43428153 | |||
| 37a70cafdd | |||
| 878ef7555d | |||
| 3599ccbb39 | |||
| 09d66ea601 | |||
| 698bd85c7d | |||
| 736cee870c | |||
| 0b7a95947f | |||
| 0dba5d2ebf |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
logs
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
19
.env.example
19
.env.example
@@ -1,13 +1,22 @@
|
|||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NODE_ENV=production
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
|
||||||
|
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
JOB_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=
|
PLESK_CRED_ENC_KEY=
|
||||||
|
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
CRON_SHARED_SECRET=
|
||||||
|
|
||||||
|
# Existing runtime keys used by current codebase
|
||||||
|
JOB_SECRET=
|
||||||
|
CRON_SECRET=
|
||||||
|
ENCRYPTION_KEY=
|
||||||
STRIPE_PRICE_ID=
|
STRIPE_PRICE_ID=
|
||||||
|
|||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal 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"]
|
||||||
85
Jenkinsfile
vendored
Normal file
85
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
environment {
|
||||||
|
REGISTRY_IMAGE = "plesk-agency-portal"
|
||||||
|
IMAGE_LATEST = "${REGISTRY_IMAGE}:latest"
|
||||||
|
IMAGE_BUILD = "${REGISTRY_IMAGE}:build-${BUILD_NUMBER}"
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Install dependencies') {
|
||||||
|
steps {
|
||||||
|
sh 'npm ci'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stage('Lint') {
|
||||||
|
// steps {
|
||||||
|
// script {
|
||||||
|
// def hasLint = sh(script: "node -e \"const fs=require('fs');const p=require('./package.json');process.exit(p?.scripts?.lint?0:1)\"", returnStatus: true)
|
||||||
|
// if (hasLint == 0) {
|
||||||
|
// sh 'npm run lint'
|
||||||
|
// } else {
|
||||||
|
// echo 'No lint script configured; skipping lint stage.'
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
stage('Build application') {
|
||||||
|
steps {
|
||||||
|
sh 'npm run build'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build Docker image') {
|
||||||
|
steps {
|
||||||
|
sh 'docker build -t ${IMAGE_LATEST} -t ${IMAGE_BUILD} .'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
if (!env.DEPLOY_SSH_CREDENTIAL_ID) {
|
||||||
|
error 'DEPLOY_SSH_CREDENTIAL_ID is required'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sshagent(credentials: ["${env.DEPLOY_SSH_CREDENTIAL_ID}"]) {
|
||||||
|
sh '''
|
||||||
|
set -euo pipefail
|
||||||
|
: "${DEPLOY_SSH_HOST:?Missing DEPLOY_SSH_HOST}"
|
||||||
|
: "${DEPLOY_SSH_USER:?Missing DEPLOY_SSH_USER}"
|
||||||
|
: "${DEPLOY_PATH:?Missing DEPLOY_PATH}"
|
||||||
|
|
||||||
|
rsync -az \
|
||||||
|
docker-compose.prod.yml \
|
||||||
|
scripts/deploy-prod.sh \
|
||||||
|
scripts/smoke-check.sh \
|
||||||
|
scripts/rollback-prod.sh \
|
||||||
|
${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:${DEPLOY_PATH}/
|
||||||
|
|
||||||
|
docker save ${IMAGE_LATEST} ${IMAGE_BUILD} \
|
||||||
|
| ssh ${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST} 'docker load'
|
||||||
|
|
||||||
|
ssh ${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST} \
|
||||||
|
"chmod +x ${DEPLOY_PATH}/deploy-prod.sh ${DEPLOY_PATH}/smoke-check.sh ${DEPLOY_PATH}/rollback-prod.sh && APP_IMAGE='${IMAGE_BUILD}' DEPLOY_PATH='${DEPLOY_PATH}' ${DEPLOY_PATH}/deploy-prod.sh"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Smoke check') {
|
||||||
|
steps {
|
||||||
|
sh 'chmod +x scripts/smoke-check.sh && scripts/smoke-check.sh ${APP_BASE_URL}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
README.md
37
README.md
@@ -63,7 +63,9 @@ values ('<agency_uuid>', '<auth_user_uuid>', 'owner');
|
|||||||
|
|
||||||
- `POST /api/plesk/instances` — create + validate Plesk connection
|
- `POST /api/plesk/instances` — create + validate Plesk connection
|
||||||
- `POST /api/plesk/instances/:id/sync` — pull/sync subscriptions + domains from one Plesk instance
|
- `POST /api/plesk/instances/:id/sync` — pull/sync subscriptions + domains from one Plesk instance
|
||||||
|
- `POST /api/plesk/instances/:id/autosync` — update auto-sync enabled/frequency for one Plesk instance
|
||||||
- `POST /api/jobs/plesk-sync-all` — background sync job for all connected instances (requires `X-JOB-SECRET`)
|
- `POST /api/jobs/plesk-sync-all` — background sync job for all connected instances (requires `X-JOB-SECRET`)
|
||||||
|
- `POST /api/cron/autosync` — scheduled auto-sync tick (requires `X-CRON-SECRET`)
|
||||||
- `POST /api/plesk/subscriptions/:id/action` — suspend or unsuspend
|
- `POST /api/plesk/subscriptions/:id/action` — suspend or unsuspend
|
||||||
- `POST /api/stripe/checkout` — create Stripe Checkout session
|
- `POST /api/stripe/checkout` — create Stripe Checkout session
|
||||||
- `POST /api/stripe/portal` — create Stripe Customer Portal session
|
- `POST /api/stripe/portal` — create Stripe Customer Portal session
|
||||||
@@ -74,6 +76,7 @@ values ('<agency_uuid>', '<auth_user_uuid>', 'owner');
|
|||||||
- Set a strong `ENCRYPTION_KEY`; it encrypts stored Plesk auth secrets.
|
- Set a strong `ENCRYPTION_KEY`; it encrypts stored Plesk auth secrets.
|
||||||
- Set a strong `PLESK_CRED_ENC_KEY` (32-byte base64 or 64-char hex) for Plesk credential encryption.
|
- Set a strong `PLESK_CRED_ENC_KEY` (32-byte base64 or 64-char hex) for Plesk credential encryption.
|
||||||
- Set `JOB_SECRET` for internal cron-triggered job auth.
|
- Set `JOB_SECRET` for internal cron-triggered job auth.
|
||||||
|
- Set `CRON_SECRET` for `/api/cron/autosync` auth.
|
||||||
- `/api/stripe/webhook` is excluded from auth middleware for Stripe signature verification.
|
- `/api/stripe/webhook` is excluded from auth middleware for Stripe signature verification.
|
||||||
- Current implementation is intentionally MVP-focused; add stronger validation, retries, idempotency keys, and richer error observability for production.
|
- Current implementation is intentionally MVP-focused; add stronger validation, retries, idempotency keys, and richer error observability for production.
|
||||||
|
|
||||||
@@ -97,3 +100,37 @@ Notes:
|
|||||||
- Job uses DB lock (`job_locks`) to avoid overlapping runs.
|
- Job uses DB lock (`job_locks`) to avoid overlapping runs.
|
||||||
- Job processes at most 10 connected instances per run.
|
- Job processes at most 10 connected instances per run.
|
||||||
- Instances synced in the last 3 minutes are skipped.
|
- Instances synced in the last 3 minutes are skipped.
|
||||||
|
|
||||||
|
## 6) Auto Sync Worker (CL6)
|
||||||
|
|
||||||
|
New scheduler fields live on `plesk_instances`:
|
||||||
|
|
||||||
|
- `auto_sync_enabled`
|
||||||
|
- `auto_sync_frequency_minutes`
|
||||||
|
- `last_auto_sync_at`
|
||||||
|
- `next_auto_sync_at`
|
||||||
|
- `auto_sync_lock_until`
|
||||||
|
- `auto_sync_lock_token`
|
||||||
|
|
||||||
|
Endpoint for periodic worker ticks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST http://localhost:3000/api/cron/autosync \
|
||||||
|
-H "X-CRON-SECRET: <CRON_SECRET>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended system cron (every 5 minutes):
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/5 * * * * curl -sS -X POST https://<your-host>/api/cron/autosync -H "X-CRON-SECRET: ${CRON_SECRET}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or schedule the same call from Jenkins.
|
||||||
|
|
||||||
|
Worker safety behavior:
|
||||||
|
|
||||||
|
- processes at most 5 instances per tick
|
||||||
|
- concurrency limited to 2 at a time
|
||||||
|
- per-instance lock lease 15 minutes
|
||||||
|
- per-instance timeout guard 14 minutes
|
||||||
|
- per-instance failures are isolated and logged to `actions_log`
|
||||||
|
|||||||
38
app/api/cron/autosync/route.ts
Normal file
38
app/api/cron/autosync/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { runAutoSyncTick } from "@/lib/plesk/auto-sync";
|
||||||
|
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = req.headers.get("x-cron-secret");
|
||||||
|
if (!env.cronSharedSecret || !secret || secret !== env.cronSharedSecret) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createSupabaseAdminClient() as any;
|
||||||
|
const summary = await runAutoSyncTick(supabase);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...summary,
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[autosync cron] failed", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Auto-sync job failed",
|
||||||
|
instances_considered: 0,
|
||||||
|
locks_acquired: 0,
|
||||||
|
succeeded: 0,
|
||||||
|
failed: 0,
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/api/cron/health/route.ts
Normal file
38
app/api/cron/health/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { runHealthCheckTick } from "@/lib/plesk/health";
|
||||||
|
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = req.headers.get("x-cron-secret");
|
||||||
|
if (!env.cronSharedSecret || !secret || secret !== env.cronSharedSecret) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createSupabaseAdminClient() as any;
|
||||||
|
const summary = await runHealthCheckTick(supabase);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...summary,
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[health cron] failed", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Health check job failed",
|
||||||
|
checked: 0,
|
||||||
|
ok: 0,
|
||||||
|
degraded: 0,
|
||||||
|
failed: 0,
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/api/health/route.ts
Normal file
13
app/api/health/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
304
app/api/plesk/domains/[id]/action/route.ts
Normal file
304
app/api/plesk/domains/[id]/action/route.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
|
||||||
|
import { assertRateLimit } from "@/lib/api/rate-limit";
|
||||||
|
import { assertSameOrigin } from "@/lib/api/security";
|
||||||
|
import {
|
||||||
|
parseSubscriptionStatusDetailsFromInfo,
|
||||||
|
pleskCliCall,
|
||||||
|
} from "@/lib/plesk/client";
|
||||||
|
import { createSupabaseRouteClient } from "@/lib/supabase/route";
|
||||||
|
|
||||||
|
const actionSchema = z.object({
|
||||||
|
action: z.enum(["suspend", "unsuspend"]),
|
||||||
|
subscription_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const LOG_EXCERPT_LIMIT = 500;
|
||||||
|
|
||||||
|
const DOMAIN_ACTION_EVENTS = {
|
||||||
|
suspend: {
|
||||||
|
requested: "domain_suspend_requested",
|
||||||
|
succeeded: "domain_suspend_succeeded",
|
||||||
|
failed: "domain_suspend_failed",
|
||||||
|
},
|
||||||
|
unsuspend: {
|
||||||
|
requested: "domain_unsuspend_requested",
|
||||||
|
succeeded: "domain_unsuspend_succeeded",
|
||||||
|
failed: "domain_unsuspend_failed",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function excerpt(value: string | null | undefined) {
|
||||||
|
if (!value) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return trimmed.length > LOG_EXCERPT_LIMIT
|
||||||
|
? `${trimmed.slice(0, LOG_EXCERPT_LIMIT)}…`
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertActionLogBestEffort(
|
||||||
|
supabase: any,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.from("actions_log").insert(payload);
|
||||||
|
if (error) {
|
||||||
|
console.error("[actions_log] insert failed", error.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[actions_log] insert threw", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const supabase = createSupabaseRouteClient() as any;
|
||||||
|
let context: Awaited<ReturnType<typeof getRouteAgencyContextOrThrow>> | null =
|
||||||
|
null;
|
||||||
|
let domain: { id: string; subscription_id: string | null } | null = null;
|
||||||
|
let subscription: {
|
||||||
|
id: string;
|
||||||
|
plesk_subscription_id: string;
|
||||||
|
plesk_instance_id: string;
|
||||||
|
} | null = null;
|
||||||
|
let requestedAction: "suspend" | "unsuspend" | null = null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertSameOrigin(req);
|
||||||
|
|
||||||
|
context = await getRouteAgencyContextOrThrow();
|
||||||
|
assertAgencyAdmin(context);
|
||||||
|
assertRateLimit(`plesk-domain-action:${context.userId}`, 10, 60_000);
|
||||||
|
|
||||||
|
const payload = actionSchema.parse(await req.json());
|
||||||
|
const { action } = payload;
|
||||||
|
requestedAction = action;
|
||||||
|
|
||||||
|
const { data: domainRow, error: domainError } = await supabase
|
||||||
|
.from("plesk_domains")
|
||||||
|
.select("id, agency_id, plesk_instance_id, subscription_id")
|
||||||
|
.eq("id", params.id)
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (domainError || !domainRow) {
|
||||||
|
throw new Error("Domain not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSubscriptionId =
|
||||||
|
payload.subscription_id ??
|
||||||
|
(domainRow.subscription_id ? String(domainRow.subscription_id) : null);
|
||||||
|
|
||||||
|
domain = {
|
||||||
|
id: String(domainRow.id),
|
||||||
|
subscription_id: resolvedSubscriptionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!resolvedSubscriptionId) {
|
||||||
|
throw new Error("Domain is not linked to a subscription");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: subscriptionRow, error: subscriptionError } = await supabase
|
||||||
|
.from("plesk_subscriptions")
|
||||||
|
.select("id, plesk_subscription_id, plesk_instance_id")
|
||||||
|
.eq("id", resolvedSubscriptionId)
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (subscriptionError || !subscriptionRow) {
|
||||||
|
throw new Error("Subscription not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription = {
|
||||||
|
id: String(subscriptionRow.id),
|
||||||
|
plesk_subscription_id: String(subscriptionRow.plesk_subscription_id),
|
||||||
|
plesk_instance_id: String(subscriptionRow.plesk_instance_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: instance, error: instanceError } = await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.select("id, base_url, auth_type, encrypted_secret")
|
||||||
|
.eq("id", subscriptionRow.plesk_instance_id)
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (instanceError || !instance) {
|
||||||
|
throw new Error("Plesk instance not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = {
|
||||||
|
baseUrl: instance.base_url,
|
||||||
|
authType: instance.auth_type,
|
||||||
|
encryptedSecret: instance.encrypted_secret,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const domainIdentifier = subscription.plesk_subscription_id;
|
||||||
|
const siteArgs =
|
||||||
|
action === "suspend"
|
||||||
|
? ["--suspend", domainIdentifier]
|
||||||
|
: ["--on", domainIdentifier];
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: context.agencyId,
|
||||||
|
actor_user_id: context.userId,
|
||||||
|
target_type: "plesk_domain",
|
||||||
|
target_id: domain.id,
|
||||||
|
action: DOMAIN_ACTION_EVENTS[action].requested,
|
||||||
|
status: "pending",
|
||||||
|
metadata: {
|
||||||
|
domain_id: domain.id,
|
||||||
|
subscription_id: subscription.id,
|
||||||
|
plesk_instance_id: subscription.plesk_instance_id,
|
||||||
|
plesk_subscription_id: subscription.plesk_subscription_id,
|
||||||
|
cli_command: "site",
|
||||||
|
cli_args: siteArgs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const siteResult = await pleskCliCall(connection, "site", siteArgs);
|
||||||
|
|
||||||
|
const fallbackStatus = action === "suspend" ? "suspended" : "active";
|
||||||
|
let refreshedStatus = fallbackStatus;
|
||||||
|
let refreshedStatusRaw: string | null = null;
|
||||||
|
let refreshErrorMessage: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const infoResult = await pleskCliCall(connection, "subscription", [
|
||||||
|
"--info",
|
||||||
|
domainIdentifier,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const parsed = parseSubscriptionStatusDetailsFromInfo(
|
||||||
|
infoResult.stdout ?? "",
|
||||||
|
);
|
||||||
|
refreshedStatus = parsed.status;
|
||||||
|
refreshedStatusRaw = parsed.statusRaw;
|
||||||
|
|
||||||
|
if (
|
||||||
|
action === "unsuspend" &&
|
||||||
|
refreshedStatus === "suspended" &&
|
||||||
|
(refreshedStatusRaw ?? "").toLowerCase().includes("subscriber")
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Domain remains suspended because its subscriber/customer account is suspended.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
refreshErrorMessage =
|
||||||
|
refreshError instanceof Error
|
||||||
|
? refreshError.message.slice(0, 1000)
|
||||||
|
: "subscription_status_refresh_failed";
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: context.agencyId,
|
||||||
|
actor_user_id: context.userId,
|
||||||
|
target_type: "plesk_subscription",
|
||||||
|
target_id: subscription.id,
|
||||||
|
action: "subscription_status_refresh_failed",
|
||||||
|
status: "failed",
|
||||||
|
error_message: refreshErrorMessage,
|
||||||
|
metadata: {
|
||||||
|
subscription_id: subscription.id,
|
||||||
|
plesk_subscription_id: subscription.plesk_subscription_id,
|
||||||
|
plesk_instance_id: subscription.plesk_instance_id,
|
||||||
|
source: "domain_action",
|
||||||
|
domain_id: domain.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("plesk_subscriptions")
|
||||||
|
.update({
|
||||||
|
status: refreshedStatus,
|
||||||
|
status_raw: refreshedStatusRaw,
|
||||||
|
last_status_sync_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", subscription.id)
|
||||||
|
.eq("agency_id", context.agencyId);
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("plesk_domains")
|
||||||
|
.update({ status: refreshedStatus })
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.eq("subscription_id", subscription.id);
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: context.agencyId,
|
||||||
|
actor_user_id: context.userId,
|
||||||
|
target_type: "plesk_domain",
|
||||||
|
target_id: domain.id,
|
||||||
|
action: DOMAIN_ACTION_EVENTS[action].succeeded,
|
||||||
|
status: "success",
|
||||||
|
metadata: {
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
domain_id: domain.id,
|
||||||
|
subscription_id: subscription.id,
|
||||||
|
plesk_instance_id: subscription.plesk_instance_id,
|
||||||
|
plesk_subscription_id: subscription.plesk_subscription_id,
|
||||||
|
cli_command: "site",
|
||||||
|
cli_args: siteArgs,
|
||||||
|
cli_exit_code: siteResult.code,
|
||||||
|
cli_stdout_excerpt: excerpt(siteResult.stdout),
|
||||||
|
cli_stderr_excerpt: excerpt(siteResult.stderr),
|
||||||
|
refreshed_status: refreshedStatus,
|
||||||
|
refresh_error: refreshErrorMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (context && domain) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Domain action failed";
|
||||||
|
if (requestedAction) {
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: context.agencyId,
|
||||||
|
actor_user_id: context.userId,
|
||||||
|
target_type: "plesk_domain",
|
||||||
|
target_id: domain.id,
|
||||||
|
action: DOMAIN_ACTION_EVENTS[requestedAction].failed,
|
||||||
|
status: "failed",
|
||||||
|
error_message: message,
|
||||||
|
metadata: {
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
domain_id: domain.id,
|
||||||
|
subscription_id: subscription?.id ?? domain.subscription_id,
|
||||||
|
plesk_instance_id: subscription?.plesk_instance_id ?? null,
|
||||||
|
plesk_subscription_id: subscription?.plesk_subscription_id ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: context.agencyId,
|
||||||
|
actor_user_id: context.userId,
|
||||||
|
target_type: "plesk_domain",
|
||||||
|
target_id: domain.id,
|
||||||
|
action: "domain_action_failed",
|
||||||
|
status: "failed",
|
||||||
|
error_message: message,
|
||||||
|
metadata: {
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
domain_id: domain.id,
|
||||||
|
subscription_id: subscription?.id ?? domain.subscription_id,
|
||||||
|
plesk_instance_id: subscription?.plesk_instance_id ?? null,
|
||||||
|
plesk_subscription_id: subscription?.plesk_subscription_id ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : "Domain action failed",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/api/plesk/instances/[id]/autosync/route.ts
Normal file
87
app/api/plesk/instances/[id]/autosync/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
|
||||||
|
import { assertRateLimit } from "@/lib/api/rate-limit";
|
||||||
|
import { assertSameOrigin } from "@/lib/api/security";
|
||||||
|
import { createSupabaseRouteClient } from "@/lib/supabase/route";
|
||||||
|
|
||||||
|
const autosyncSchema = z.object({
|
||||||
|
auto_sync_enabled: z.boolean(),
|
||||||
|
auto_sync_frequency_minutes: z.number().int().min(5).max(10080).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
assertSameOrigin(req);
|
||||||
|
|
||||||
|
const context = await getRouteAgencyContextOrThrow();
|
||||||
|
assertAgencyAdmin(context);
|
||||||
|
assertRateLimit(`plesk-autosync-settings:${context.userId}`, 20, 60_000);
|
||||||
|
|
||||||
|
const input = autosyncSchema.parse(await req.json());
|
||||||
|
const frequency = input.auto_sync_frequency_minutes ?? 60;
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const nextAtIso = new Date(
|
||||||
|
Date.now() + frequency * 60 * 1000,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
const supabase = createSupabaseRouteClient() as any;
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
auto_sync_enabled: input.auto_sync_enabled,
|
||||||
|
auto_sync_frequency_minutes: frequency,
|
||||||
|
next_auto_sync_at: input.auto_sync_enabled ? nextAtIso : null,
|
||||||
|
auto_sync_lock_until: null,
|
||||||
|
auto_sync_lock_token: null,
|
||||||
|
last_auto_sync_at: input.auto_sync_enabled ? undefined : null,
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
|
||||||
|
const { data: instance, error } = await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.update(updates)
|
||||||
|
.eq("id", params.id)
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.select(
|
||||||
|
"id, auto_sync_enabled, auto_sync_frequency_minutes, last_auto_sync_at, next_auto_sync_at, auto_sync_lock_until",
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !instance) {
|
||||||
|
throw new Error(error?.message ?? "Plesk instance not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await supabase.from("actions_log").insert({
|
||||||
|
agency_id: context.agencyId,
|
||||||
|
actor_user_id: context.userId,
|
||||||
|
target_type: "plesk_instance",
|
||||||
|
target_id: params.id,
|
||||||
|
action: "autosync_settings_updated",
|
||||||
|
status: "success",
|
||||||
|
metadata: {
|
||||||
|
auto_sync_enabled: input.auto_sync_enabled,
|
||||||
|
auto_sync_frequency_minutes: frequency,
|
||||||
|
updated_at: nowIso,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best effort logging
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ instance });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to update auto-sync settings",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/plesk/instances/[id]/backfill-domain-links/route.ts
Normal file
51
app/api/plesk/instances/[id]/backfill-domain-links/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
|
||||||
|
import { assertRateLimit } from "@/lib/api/rate-limit";
|
||||||
|
import { assertSameOrigin } from "@/lib/api/security";
|
||||||
|
import { backfillDomainSubscriptionLinks } from "@/lib/plesk/sync";
|
||||||
|
import { createSupabaseRouteClient } from "@/lib/supabase/route";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
assertSameOrigin(req);
|
||||||
|
|
||||||
|
const context = await getRouteAgencyContextOrThrow();
|
||||||
|
assertAgencyAdmin(context);
|
||||||
|
assertRateLimit(`plesk-backfill-links:${context.userId}`, 5, 60_000);
|
||||||
|
|
||||||
|
const supabase = createSupabaseRouteClient() as any;
|
||||||
|
|
||||||
|
const { data: instance, error: instanceError } = await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.select("id")
|
||||||
|
.eq("id", params.id)
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (instanceError || !instance) {
|
||||||
|
throw new Error("Plesk instance not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await backfillDomainSubscriptionLinks(supabase, {
|
||||||
|
agencyId: context.agencyId,
|
||||||
|
instanceId: params.id,
|
||||||
|
actorUserId: context.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, ...result });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Backfill domain links failed",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/api/plesk/instances/[id]/health/route.ts
Normal file
73
app/api/plesk/instances/[id]/health/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
|
||||||
|
import { assertRateLimit } from "@/lib/api/rate-limit";
|
||||||
|
import { assertSameOrigin } from "@/lib/api/security";
|
||||||
|
import { runInstanceHealthCheck } from "@/lib/plesk/health";
|
||||||
|
import { createSupabaseRouteClient } from "@/lib/supabase/route";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
assertSameOrigin(req);
|
||||||
|
|
||||||
|
const context = await getRouteAgencyContextOrThrow();
|
||||||
|
assertAgencyAdmin(context);
|
||||||
|
assertRateLimit(`plesk-health-check:${context.userId}`, 20, 60_000);
|
||||||
|
|
||||||
|
const supabase = createSupabaseRouteClient() as any;
|
||||||
|
|
||||||
|
const { data: instance, error: instanceError } = await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.select(
|
||||||
|
"id, agency_id, base_url, auth_type, encrypted_secret, health_enabled, health_check_frequency_minutes, health_next_check_at, health_lock_until",
|
||||||
|
)
|
||||||
|
.eq("id", params.id)
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (instanceError || !instance) {
|
||||||
|
throw new Error("Plesk instance not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runInstanceHealthCheck(supabase, instance, {
|
||||||
|
actorUserId: context.userId,
|
||||||
|
force: true,
|
||||||
|
source: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.checked) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Health check is already running for this instance" },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: updated } = await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.select(
|
||||||
|
"id, health_enabled, health_status, health_last_checked_at, health_last_ok_at, health_last_error, health_last_latency_ms, health_check_frequency_minutes, health_next_check_at",
|
||||||
|
)
|
||||||
|
.eq("id", params.id)
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
result,
|
||||||
|
instance: updated,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Health check execution failed",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
|
|||||||
import { assertRateLimit } from "@/lib/api/rate-limit";
|
import { assertRateLimit } from "@/lib/api/rate-limit";
|
||||||
import { assertSameOrigin } from "@/lib/api/security";
|
import { assertSameOrigin } from "@/lib/api/security";
|
||||||
import {
|
import {
|
||||||
|
parseSubscriptionStatusDetailsFromInfo,
|
||||||
|
pleskCliCall,
|
||||||
suspendPleskSubscription,
|
suspendPleskSubscription,
|
||||||
unsuspendPleskSubscription,
|
unsuspendPleskSubscription,
|
||||||
} from "@/lib/plesk/client";
|
} from "@/lib/plesk/client";
|
||||||
@@ -14,6 +16,44 @@ const actionSchema = z.object({
|
|||||||
action: z.enum(["suspend", "unsuspend"]),
|
action: z.enum(["suspend", "unsuspend"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const LOG_EXCERPT_LIMIT = 500;
|
||||||
|
|
||||||
|
const SUBSCRIPTION_ACTION_EVENTS = {
|
||||||
|
suspend: {
|
||||||
|
requested: "subscription_suspend_requested",
|
||||||
|
succeeded: "subscription_suspend_succeeded",
|
||||||
|
failed: "subscription_suspend_failed",
|
||||||
|
},
|
||||||
|
unsuspend: {
|
||||||
|
requested: "subscription_unsuspend_requested",
|
||||||
|
succeeded: "subscription_unsuspend_succeeded",
|
||||||
|
failed: "subscription_unsuspend_failed",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function excerpt(value: string | null | undefined) {
|
||||||
|
if (!value) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return trimmed.length > LOG_EXCERPT_LIMIT
|
||||||
|
? `${trimmed.slice(0, LOG_EXCERPT_LIMIT)}…`
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertActionLogBestEffort(
|
||||||
|
supabase: any,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.from("actions_log").insert(payload);
|
||||||
|
if (error) {
|
||||||
|
console.error("[actions_log] insert failed", error.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[actions_log] insert threw", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: Request,
|
req: Request,
|
||||||
{ params }: { params: { id: string } },
|
{ params }: { params: { id: string } },
|
||||||
@@ -27,6 +67,7 @@ export async function POST(
|
|||||||
plesk_subscription_id: string;
|
plesk_subscription_id: string;
|
||||||
plesk_instance_id: string;
|
plesk_instance_id: string;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assertSameOrigin(req);
|
assertSameOrigin(req);
|
||||||
@@ -68,58 +109,153 @@ export async function POST(
|
|||||||
encryptedSecret: instance.encrypted_secret,
|
encryptedSecret: instance.encrypted_secret,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (payload.action === "suspend") {
|
const cliArgs =
|
||||||
await suspendPleskSubscription(
|
payload.action === "suspend"
|
||||||
connection,
|
? ["--webspace-off", subscription.plesk_subscription_id]
|
||||||
subscription.plesk_subscription_id,
|
: ["--unsuspend", subscription.plesk_subscription_id];
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await unsuspendPleskSubscription(
|
|
||||||
connection,
|
|
||||||
subscription.plesk_subscription_id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newStatus = payload.action === "suspend" ? "suspended" : "active";
|
await insertActionLogBestEffort(supabase, {
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from("plesk_subscriptions")
|
|
||||||
.update({ status: newStatus })
|
|
||||||
.eq("id", subscription.id);
|
|
||||||
|
|
||||||
await supabase.from("actions_log").insert({
|
|
||||||
agency_id: context.agencyId,
|
agency_id: context.agencyId,
|
||||||
actor_user_id: context.userId,
|
actor_user_id: context.userId,
|
||||||
target_type: "plesk_subscription",
|
target_type: "plesk_subscription",
|
||||||
target_id: subscription.id,
|
target_id: subscription.id,
|
||||||
action: payload.action,
|
action: SUBSCRIPTION_ACTION_EVENTS[payload.action].requested,
|
||||||
|
status: "pending",
|
||||||
|
metadata: {
|
||||||
|
subscription_id: subscription.id,
|
||||||
|
plesk_instance_id: subscription.plesk_instance_id,
|
||||||
|
plesk_subscription_id: subscription.plesk_subscription_id,
|
||||||
|
cli_command: "subscription",
|
||||||
|
cli_args: cliArgs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionCliResult =
|
||||||
|
payload.action === "suspend"
|
||||||
|
? await suspendPleskSubscription(
|
||||||
|
connection,
|
||||||
|
subscription.plesk_subscription_id,
|
||||||
|
)
|
||||||
|
: await unsuspendPleskSubscription(
|
||||||
|
connection,
|
||||||
|
subscription.plesk_subscription_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallbackStatus =
|
||||||
|
payload.action === "suspend" ? "suspended" : "active";
|
||||||
|
let refreshedStatus = fallbackStatus;
|
||||||
|
let refreshedStatusRaw: string | null = null;
|
||||||
|
let refreshErrorMessage: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const infoResult = await pleskCliCall(connection, "subscription", [
|
||||||
|
"--info",
|
||||||
|
subscription.plesk_subscription_id,
|
||||||
|
]);
|
||||||
|
const parsed = parseSubscriptionStatusDetailsFromInfo(
|
||||||
|
infoResult.stdout ?? "",
|
||||||
|
);
|
||||||
|
refreshedStatus = parsed.status;
|
||||||
|
refreshedStatusRaw = parsed.statusRaw;
|
||||||
|
} catch (refreshError) {
|
||||||
|
refreshErrorMessage =
|
||||||
|
refreshError instanceof Error
|
||||||
|
? refreshError.message.slice(0, 1000)
|
||||||
|
: "subscription_status_refresh_failed";
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: context.agencyId,
|
||||||
|
actor_user_id: context.userId,
|
||||||
|
target_type: "plesk_subscription",
|
||||||
|
target_id: subscription.id,
|
||||||
|
action: "subscription_status_refresh_failed",
|
||||||
|
status: "failed",
|
||||||
|
error_message: refreshErrorMessage,
|
||||||
|
metadata: {
|
||||||
|
subscription_id: subscription.id,
|
||||||
|
plesk_subscription_id: subscription.plesk_subscription_id,
|
||||||
|
plesk_instance_id: subscription.plesk_instance_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("plesk_subscriptions")
|
||||||
|
.update({
|
||||||
|
status: refreshedStatus,
|
||||||
|
status_raw: refreshedStatusRaw,
|
||||||
|
last_status_sync_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", subscription.id);
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("plesk_domains")
|
||||||
|
.update({ status: refreshedStatus })
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.eq("subscription_id", subscription.id);
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: context.agencyId,
|
||||||
|
actor_user_id: context.userId,
|
||||||
|
target_type: "plesk_subscription",
|
||||||
|
target_id: subscription.id,
|
||||||
|
action: SUBSCRIPTION_ACTION_EVENTS[payload.action].succeeded,
|
||||||
status: "success",
|
status: "success",
|
||||||
metadata: {
|
metadata: {
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
cli_command: "subscription",
|
||||||
|
cli_args: cliArgs,
|
||||||
plesk_subscription_id: subscription.plesk_subscription_id,
|
plesk_subscription_id: subscription.plesk_subscription_id,
|
||||||
|
refreshed_status: refreshedStatus,
|
||||||
|
refresh_error: refreshErrorMessage,
|
||||||
|
cli_exit_code: actionCliResult.code,
|
||||||
|
cli_stdout_excerpt: excerpt(actionCliResult.stdout),
|
||||||
|
cli_stderr_excerpt: excerpt(actionCliResult.stderr),
|
||||||
db_status_update_error: updateError?.message ?? null,
|
db_status_update_error: updateError?.message ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: newStatus,
|
status: refreshedStatus,
|
||||||
dbStatusUpdated: !updateError,
|
dbStatusUpdated: !updateError,
|
||||||
|
refreshError: refreshErrorMessage,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (supabase && context && subscription) {
|
if (supabase && context && subscription) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Subscription action failed";
|
error instanceof Error ? error.message : "Subscription action failed";
|
||||||
await supabase.from("actions_log").insert({
|
if (requestedAction) {
|
||||||
agency_id: context.agencyId,
|
await insertActionLogBestEffort(supabase, {
|
||||||
actor_user_id: context.userId,
|
agency_id: context.agencyId,
|
||||||
target_type: "plesk_subscription",
|
actor_user_id: context.userId,
|
||||||
target_id: subscription.id,
|
target_type: "plesk_subscription",
|
||||||
action: requestedAction ?? "subscription_action",
|
target_id: subscription.id,
|
||||||
status: "failed",
|
action: SUBSCRIPTION_ACTION_EVENTS[requestedAction].failed,
|
||||||
error_message: message,
|
status: "failed",
|
||||||
metadata: {
|
error_message: message,
|
||||||
plesk_subscription_id: subscription.plesk_subscription_id,
|
metadata: {
|
||||||
},
|
duration_ms: Date.now() - startedAt,
|
||||||
});
|
plesk_subscription_id: subscription.plesk_subscription_id,
|
||||||
|
plesk_instance_id: subscription.plesk_instance_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: context.agencyId,
|
||||||
|
actor_user_id: context.userId,
|
||||||
|
target_type: "plesk_subscription",
|
||||||
|
target_id: subscription.id,
|
||||||
|
action: "subscription_action_failed",
|
||||||
|
status: "failed",
|
||||||
|
error_message: message,
|
||||||
|
metadata: {
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
plesk_subscription_id: subscription.plesk_subscription_id,
|
||||||
|
plesk_instance_id: subscription.plesk_instance_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import { env } from "@/lib/env";
|
||||||
import type { Database } from "@/lib/types";
|
import type { Database } from "@/lib/types";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const requestUrl = new URL(request.url);
|
const requestUrl = new URL(request.url);
|
||||||
const code = requestUrl.searchParams.get("code");
|
const code = requestUrl.searchParams.get("code");
|
||||||
const next = requestUrl.searchParams.get("next") ?? "/dashboard";
|
const nextParam = requestUrl.searchParams.get("next");
|
||||||
|
const next =
|
||||||
|
nextParam && nextParam.startsWith("/") && !nextParam.startsWith("//")
|
||||||
|
? nextParam
|
||||||
|
: "/dashboard";
|
||||||
|
const redirectOrigin = env.appOrigin || requestUrl.origin;
|
||||||
|
|
||||||
if (code) {
|
if (code) {
|
||||||
const supabase = createRouteHandlerClient<Database>({ cookies });
|
const supabase = createRouteHandlerClient<Database>({ cookies });
|
||||||
@@ -15,18 +21,16 @@ export async function GET(request: Request) {
|
|||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL(`/login?authError=1`, requestUrl.origin),
|
new URL(`/login?authError=1`, redirectOrigin),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
} = await supabase.auth.getUser();
|
} = await supabase.auth.getUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(new URL(`/login?noUser=1`, redirectOrigin));
|
||||||
new URL(`/login?noUser=1`, requestUrl.origin),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.redirect(new URL(next, requestUrl.origin));
|
return NextResponse.redirect(new URL(next, redirectOrigin));
|
||||||
}
|
}
|
||||||
|
|||||||
297
app/dashboard/activity/page.tsx
Normal file
297
app/dashboard/activity/page.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
|
||||||
|
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||||
|
import { formatDateTime } from "@/lib/dates";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
type SearchParams = {
|
||||||
|
page?: string;
|
||||||
|
instance?: string;
|
||||||
|
action?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSingleParam(value: string | string[] | undefined) {
|
||||||
|
if (!value) return "";
|
||||||
|
return Array.isArray(value) ? (value[0] ?? "") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryString(next: Record<string, string | number | undefined>) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(next)) {
|
||||||
|
if (value == null) continue;
|
||||||
|
const normalized = String(value);
|
||||||
|
if (!normalized) continue;
|
||||||
|
params.set(key, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ActivityPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams?: SearchParams;
|
||||||
|
}) {
|
||||||
|
const context = await getServerAgencyContextOrRedirect();
|
||||||
|
const supabase = createSupabaseServerClient() as any;
|
||||||
|
|
||||||
|
const selectedInstance = getSingleParam(searchParams?.instance);
|
||||||
|
const selectedAction = getSingleParam(searchParams?.action);
|
||||||
|
const selectedStatus = getSingleParam(searchParams?.status);
|
||||||
|
const page = Math.max(
|
||||||
|
Number(getSingleParam(searchParams?.page) || "1") || 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: instances } = await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.select("id, name, base_url")
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.order("name", { ascending: true });
|
||||||
|
|
||||||
|
let baseQuery = supabase
|
||||||
|
.from("actions_log")
|
||||||
|
.select(
|
||||||
|
"id, created_at, target_type, target_id, action, status, error_message, metadata",
|
||||||
|
{ count: "exact" },
|
||||||
|
)
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (selectedAction) {
|
||||||
|
baseQuery = baseQuery.eq("action", selectedAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedStatus) {
|
||||||
|
baseQuery = baseQuery.eq("status", selectedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedInstance) {
|
||||||
|
baseQuery = baseQuery.or(
|
||||||
|
`target_id.eq.${selectedInstance},metadata->>plesk_instance_id.eq.${selectedInstance}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = (page - 1) * PAGE_SIZE;
|
||||||
|
const to = from + PAGE_SIZE - 1;
|
||||||
|
|
||||||
|
const { data: logs, count } = await baseQuery.range(from, to);
|
||||||
|
|
||||||
|
const { data: recentForActions } = await supabase
|
||||||
|
.from("actions_log")
|
||||||
|
.select("action")
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(200);
|
||||||
|
|
||||||
|
const actionOptions = Array.from(
|
||||||
|
new Set((recentForActions ?? []).map((entry: any) => String(entry.action))),
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
const instanceNameById = new Map<string, string>(
|
||||||
|
(instances ?? []).map((instance: any) => [
|
||||||
|
String(instance.id),
|
||||||
|
String(instance.name ?? instance.base_url ?? instance.id),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = count ?? 0;
|
||||||
|
const hasPrev = page > 1;
|
||||||
|
const hasNext = to + 1 < total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-6xl space-y-4 px-6 py-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Activity Log</h1>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Recent actions for your agency
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard" className="text-sm text-brand-700 underline">
|
||||||
|
Back to dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="grid gap-2 rounded-xl border border-slate-200 bg-white p-4 md:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-slate-600">Instance</label>
|
||||||
|
<select
|
||||||
|
name="instance"
|
||||||
|
defaultValue={selectedInstance}
|
||||||
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All instances</option>
|
||||||
|
{(instances ?? []).map((instance: any) => (
|
||||||
|
<option key={String(instance.id)} value={String(instance.id)}>
|
||||||
|
{String(instance.name ?? instance.base_url ?? instance.id)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-slate-600">Action</label>
|
||||||
|
<select
|
||||||
|
name="action"
|
||||||
|
defaultValue={selectedAction}
|
||||||
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All actions</option>
|
||||||
|
{actionOptions.map((action) => (
|
||||||
|
<option key={action} value={action}>
|
||||||
|
{action}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-slate-600">Status</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={selectedStatus}
|
||||||
|
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="success">Success</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<button className="rounded border px-3 py-1.5 text-sm">Apply</button>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/activity"
|
||||||
|
className="rounded border px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white">
|
||||||
|
<table className="min-w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 text-xs uppercase text-slate-500">
|
||||||
|
<th className="px-3 py-2">Time</th>
|
||||||
|
<th className="px-3 py-2">Instance</th>
|
||||||
|
<th className="px-3 py-2">Action</th>
|
||||||
|
<th className="px-3 py-2">Target</th>
|
||||||
|
<th className="px-3 py-2">Status</th>
|
||||||
|
<th className="px-3 py-2">Message</th>
|
||||||
|
<th className="px-3 py-2">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(logs ?? []).length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-4 text-slate-500" colSpan={7}>
|
||||||
|
No activity found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
(logs ?? []).map((row: any) => {
|
||||||
|
const metadata =
|
||||||
|
row?.metadata && typeof row.metadata === "object"
|
||||||
|
? (row.metadata as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const instanceId =
|
||||||
|
(metadata?.plesk_instance_id as string | undefined) ??
|
||||||
|
(row.target_type === "plesk_instance"
|
||||||
|
? (row.target_id as string | null)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className="border-b border-slate-100 align-top"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{formatDateTime(row.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{instanceId
|
||||||
|
? (instanceNameById.get(instanceId) ?? instanceId)
|
||||||
|
: "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">{row.action ?? "-"}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{row.target_type ?? "-"}
|
||||||
|
{row.target_id ? ` (${row.target_id})` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">{row.status ?? "-"}</td>
|
||||||
|
<td className="px-3 py-2">{row.error_message ?? "-"}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{metadata ? (
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer text-xs text-brand-700">
|
||||||
|
View
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 max-w-[460px] overflow-auto rounded bg-slate-50 p-2 text-[11px] text-slate-700">
|
||||||
|
{JSON.stringify(metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-slate-500">
|
||||||
|
Showing {Math.min(total, from + 1)} - {Math.min(total, to + 1)} of{" "}
|
||||||
|
{total}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{hasPrev ? (
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/activity?${buildQueryString({
|
||||||
|
page: page - 1,
|
||||||
|
instance: selectedInstance,
|
||||||
|
action: selectedAction,
|
||||||
|
status: selectedStatus,
|
||||||
|
})}`}
|
||||||
|
className="rounded border px-3 py-1.5"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="rounded border px-3 py-1.5 text-slate-400">
|
||||||
|
Previous
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasNext ? (
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/activity?${buildQueryString({
|
||||||
|
page: page + 1,
|
||||||
|
instance: selectedInstance,
|
||||||
|
action: selectedAction,
|
||||||
|
status: selectedStatus,
|
||||||
|
})}`}
|
||||||
|
className="rounded border px-3 py-1.5"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="rounded border px-3 py-1.5 text-slate-400">
|
||||||
|
Next
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { SignOutButton } from "@/components/auth/sign-out-button";
|
import { SignOutButton } from "@/components/auth/sign-out-button";
|
||||||
import { DashboardControls } from "@/components/dashboard/dashboard-controls";
|
import { DashboardControls } from "@/components/dashboard/dashboard-controls";
|
||||||
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
|
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
|
||||||
|
import { formatDateTime } from "@/lib/dates";
|
||||||
import type { Database } from "@/lib/types";
|
import type { Database } from "@/lib/types";
|
||||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||||
import type { PostgrestError } from "@supabase/supabase-js";
|
import Link from "next/link";
|
||||||
|
|
||||||
type AgencySummary = Pick<
|
type AgencySummary = Pick<
|
||||||
Database["public"]["Tables"]["agencies"]["Row"],
|
Database["public"]["Tables"]["agencies"]["Row"],
|
||||||
@@ -17,6 +18,98 @@ type ActionLogSummary = Pick<
|
|||||||
Database["public"]["Tables"]["actions_log"]["Row"],
|
Database["public"]["Tables"]["actions_log"]["Row"],
|
||||||
"target_id" | "status" | "action" | "created_at" | "error_message"
|
"target_id" | "status" | "action" | "created_at" | "error_message"
|
||||||
>;
|
>;
|
||||||
|
type AutosyncLatestLogSummary = Pick<
|
||||||
|
Database["public"]["Tables"]["actions_log"]["Row"],
|
||||||
|
"created_at"
|
||||||
|
>;
|
||||||
|
type HealthLogSummary = Pick<
|
||||||
|
Database["public"]["Tables"]["actions_log"]["Row"],
|
||||||
|
| "id"
|
||||||
|
| "target_id"
|
||||||
|
| "status"
|
||||||
|
| "action"
|
||||||
|
| "created_at"
|
||||||
|
| "error_message"
|
||||||
|
| "metadata"
|
||||||
|
>;
|
||||||
|
|
||||||
|
const RELATIVE_TIME_FORMATTER = new Intl.RelativeTimeFormat("en", {
|
||||||
|
numeric: "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
function getLatestTimestamp(values: Array<string | null | undefined>) {
|
||||||
|
let latestIso: string | null = null;
|
||||||
|
let latestMs = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
if (!value) continue;
|
||||||
|
const parsed = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(parsed)) continue;
|
||||||
|
if (parsed > latestMs) {
|
||||||
|
latestMs = parsed;
|
||||||
|
latestIso = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestIso;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(value?: string | null) {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const targetMs = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(targetMs)) return null;
|
||||||
|
|
||||||
|
const deltaSeconds = Math.round((targetMs - Date.now()) / 1000);
|
||||||
|
const absSeconds = Math.abs(deltaSeconds);
|
||||||
|
|
||||||
|
if (absSeconds < 60) {
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaSeconds, "second");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaMinutes = Math.round(deltaSeconds / 60);
|
||||||
|
if (Math.abs(deltaMinutes) < 60) {
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaMinutes, "minute");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaHours = Math.round(deltaMinutes / 60);
|
||||||
|
if (Math.abs(deltaHours) < 24) {
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaHours, "hour");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaDays = Math.round(deltaHours / 24);
|
||||||
|
if (Math.abs(deltaDays) < 30) {
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaDays, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaMonths = Math.round(deltaDays / 30);
|
||||||
|
if (Math.abs(deltaMonths) < 12) {
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaMonths, "month");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaYears = Math.round(deltaMonths / 12);
|
||||||
|
return RELATIVE_TIME_FORMATTER.format(deltaYears, "year");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomainLike(value: string) {
|
||||||
|
return value.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestSuffixStatusMatch(
|
||||||
|
normalizedDomain: string,
|
||||||
|
candidates: Array<{ normalizedName: string; status: string | null }>,
|
||||||
|
) {
|
||||||
|
let best: { normalizedName: string; status: string | null } | null = null;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!normalizedDomain.endsWith(`.${candidate.normalizedName}`)) continue;
|
||||||
|
if (!best || candidate.normalizedName.length > best.normalizedName.length) {
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best?.status ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const context = await getServerAgencyContextOrRedirect();
|
const context = await getServerAgencyContextOrRedirect();
|
||||||
@@ -29,6 +122,8 @@ export default async function DashboardPage() {
|
|||||||
{ data: domains },
|
{ data: domains },
|
||||||
{ data: billing },
|
{ data: billing },
|
||||||
{ data: autoSyncLogs },
|
{ data: autoSyncLogs },
|
||||||
|
{ data: latestAutoSyncLog },
|
||||||
|
{ data: healthLogs },
|
||||||
] = (await Promise.all([
|
] = (await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.from("agencies")
|
.from("agencies")
|
||||||
@@ -38,7 +133,7 @@ export default async function DashboardPage() {
|
|||||||
supabase
|
supabase
|
||||||
.from("plesk_instances")
|
.from("plesk_instances")
|
||||||
.select(
|
.select(
|
||||||
"id, name, base_url, auth_type, status, error_message, last_connected_at, last_sync_at, last_sync_status, last_sync_error, last_sync_subscriptions, last_sync_domains",
|
"id, name, base_url, auth_type, status, error_message, last_connected_at, last_sync_at, last_sync_status, last_sync_error, last_sync_subscriptions, last_sync_domains, auto_sync_enabled, auto_sync_frequency_minutes, last_auto_sync_at, next_auto_sync_at, auto_sync_lock_until, health_enabled, health_status, health_last_checked_at, health_last_ok_at, health_last_error, health_last_latency_ms, health_check_frequency_minutes, health_next_check_at, health_lock_until",
|
||||||
)
|
)
|
||||||
.eq("agency_id", context.agencyId)
|
.eq("agency_id", context.agencyId)
|
||||||
.order("created_at", { ascending: false }),
|
.order("created_at", { ascending: false }),
|
||||||
@@ -51,7 +146,7 @@ export default async function DashboardPage() {
|
|||||||
supabase
|
supabase
|
||||||
.from("plesk_domains")
|
.from("plesk_domains")
|
||||||
.select(
|
.select(
|
||||||
"id, plesk_instance_id, domain_name, status, hosting_type, source_created_at, aliases_count, updated_at",
|
"id, plesk_instance_id, subscription_id, external_subscription_id, status, domain_name, hosting_type, source_created_at, aliases_count, updated_at",
|
||||||
)
|
)
|
||||||
.eq("agency_id", context.agencyId)
|
.eq("agency_id", context.agencyId)
|
||||||
.order("updated_at", { ascending: false })
|
.order("updated_at", { ascending: false })
|
||||||
@@ -69,6 +164,31 @@ export default async function DashboardPage() {
|
|||||||
.eq("action", "plesk_sync_auto")
|
.eq("action", "plesk_sync_auto")
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
.limit(200),
|
.limit(200),
|
||||||
|
supabase
|
||||||
|
.from("actions_log")
|
||||||
|
.select("created_at")
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.eq("target_type", "plesk_instance")
|
||||||
|
.eq("action", "autosync_completed")
|
||||||
|
.eq("status", "success")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle(),
|
||||||
|
supabase
|
||||||
|
.from("actions_log")
|
||||||
|
.select(
|
||||||
|
"id, target_id, status, action, created_at, error_message, metadata",
|
||||||
|
)
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.eq("target_type", "plesk_instance")
|
||||||
|
.in("action", [
|
||||||
|
"health_check_started",
|
||||||
|
"health_check_ok",
|
||||||
|
"health_check_degraded",
|
||||||
|
"health_check_failed",
|
||||||
|
])
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(400),
|
||||||
])) as [
|
])) as [
|
||||||
{ data: AgencySummary | null },
|
{ data: AgencySummary | null },
|
||||||
{
|
{
|
||||||
@@ -85,6 +205,20 @@ export default async function DashboardPage() {
|
|||||||
last_sync_error: string | null;
|
last_sync_error: string | null;
|
||||||
last_sync_subscriptions: number;
|
last_sync_subscriptions: number;
|
||||||
last_sync_domains: number;
|
last_sync_domains: number;
|
||||||
|
auto_sync_enabled: boolean;
|
||||||
|
auto_sync_frequency_minutes: number;
|
||||||
|
last_auto_sync_at: string | null;
|
||||||
|
next_auto_sync_at: string | null;
|
||||||
|
auto_sync_lock_until: string | null;
|
||||||
|
health_enabled: boolean;
|
||||||
|
health_status: "ok" | "degraded" | "down" | "unknown";
|
||||||
|
health_last_checked_at: string | null;
|
||||||
|
health_last_ok_at: string | null;
|
||||||
|
health_last_error: string | null;
|
||||||
|
health_last_latency_ms: number | null;
|
||||||
|
health_check_frequency_minutes: number;
|
||||||
|
health_next_check_at: string | null;
|
||||||
|
health_lock_until: string | null;
|
||||||
}> | null;
|
}> | null;
|
||||||
error: { message: string } | null;
|
error: { message: string } | null;
|
||||||
},
|
},
|
||||||
@@ -101,6 +235,8 @@ export default async function DashboardPage() {
|
|||||||
data: Array<{
|
data: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
plesk_instance_id: string;
|
plesk_instance_id: string;
|
||||||
|
subscription_id: string | null;
|
||||||
|
external_subscription_id: string | null;
|
||||||
domain_name: string;
|
domain_name: string;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
hosting_type: string | null;
|
hosting_type: string | null;
|
||||||
@@ -111,8 +247,86 @@ export default async function DashboardPage() {
|
|||||||
},
|
},
|
||||||
{ data: BillingSummary | null },
|
{ data: BillingSummary | null },
|
||||||
{ data: ActionLogSummary[] | null },
|
{ data: ActionLogSummary[] | null },
|
||||||
|
{ data: AutosyncLatestLogSummary | null },
|
||||||
|
{ data: HealthLogSummary[] | null },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const domainInstanceIds = Array.from(
|
||||||
|
new Set((domains ?? []).map((domain) => domain.plesk_instance_id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: domainSubscriptions } =
|
||||||
|
domainInstanceIds.length > 0
|
||||||
|
? await supabase
|
||||||
|
.from("plesk_subscriptions")
|
||||||
|
.select("id, plesk_instance_id, plesk_subscription_id, name, status")
|
||||||
|
.eq("agency_id", context.agencyId)
|
||||||
|
.in("plesk_instance_id", domainInstanceIds)
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
|
const subscriptionStatusById = new Map<string, string | null>(
|
||||||
|
(domainSubscriptions ?? []).map((subscription: any) => [
|
||||||
|
String(subscription.id),
|
||||||
|
subscription?.status ? String(subscription.status) : null,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscriptionDisplayNameById = new Map<string, string>(
|
||||||
|
(domainSubscriptions ?? [])
|
||||||
|
.filter((subscription: any) => subscription?.id)
|
||||||
|
.map((subscription: any) => [
|
||||||
|
String(subscription.id),
|
||||||
|
String(
|
||||||
|
subscription?.name ||
|
||||||
|
subscription?.plesk_subscription_id ||
|
||||||
|
subscription?.id,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscriptionStatusByExternalId = new Map<string, string | null>(
|
||||||
|
(domainSubscriptions ?? [])
|
||||||
|
.filter((subscription: any) =>
|
||||||
|
Boolean(
|
||||||
|
subscription?.plesk_instance_id &&
|
||||||
|
subscription?.plesk_subscription_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((subscription: any) => [
|
||||||
|
`${String(subscription.plesk_instance_id)}:${String(subscription.plesk_subscription_id)}`,
|
||||||
|
subscription?.status ? String(subscription.status) : null,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscriptionStatusByInstanceAndName = new Map<string, string | null>(
|
||||||
|
(domainSubscriptions ?? [])
|
||||||
|
.filter((subscription: any) =>
|
||||||
|
Boolean(subscription?.plesk_instance_id && subscription?.name),
|
||||||
|
)
|
||||||
|
.map((subscription: any) => [
|
||||||
|
`${String(subscription.plesk_instance_id)}:${normalizeDomainLike(String(subscription.name))}`,
|
||||||
|
subscription?.status ? String(subscription.status) : null,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscriptionStatusNameCandidatesByInstance = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ normalizedName: string; status: string | null }>
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const subscription of domainSubscriptions ?? []) {
|
||||||
|
if (!subscription?.plesk_instance_id || !subscription?.name) continue;
|
||||||
|
|
||||||
|
const instanceId = String(subscription.plesk_instance_id);
|
||||||
|
const existing =
|
||||||
|
subscriptionStatusNameCandidatesByInstance.get(instanceId) ?? [];
|
||||||
|
existing.push({
|
||||||
|
normalizedName: normalizeDomainLike(String(subscription.name)),
|
||||||
|
status: subscription?.status ? String(subscription.status) : null,
|
||||||
|
});
|
||||||
|
subscriptionStatusNameCandidatesByInstance.set(instanceId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
const autoSyncByInstance = new Map<
|
const autoSyncByInstance = new Map<
|
||||||
string,
|
string,
|
||||||
{ status: string; created_at: string; error_message: string | null }
|
{ status: string; created_at: string; error_message: string | null }
|
||||||
@@ -127,6 +341,67 @@ export default async function DashboardPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const healthEventsByInstance = new Map<
|
||||||
|
string,
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
error_message: string | null;
|
||||||
|
metadata: unknown;
|
||||||
|
}>
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const log of healthLogs ?? []) {
|
||||||
|
if (!log.target_id) continue;
|
||||||
|
const existing = healthEventsByInstance.get(log.target_id) ?? [];
|
||||||
|
if (existing.length >= 10) continue;
|
||||||
|
existing.push({
|
||||||
|
id: log.id,
|
||||||
|
action: log.action,
|
||||||
|
status: log.status,
|
||||||
|
created_at: log.created_at,
|
||||||
|
error_message: log.error_message,
|
||||||
|
metadata: log.metadata,
|
||||||
|
});
|
||||||
|
healthEventsByInstance.set(log.target_id, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthCounts = { ok: 0, degraded: 0, down: 0, unknown: 0 };
|
||||||
|
|
||||||
|
for (const instance of instances ?? []) {
|
||||||
|
const status = (instance.health_status ?? "unknown") as
|
||||||
|
| "ok"
|
||||||
|
| "degraded"
|
||||||
|
| "down"
|
||||||
|
| "unknown";
|
||||||
|
if (status === "ok") healthCounts.ok += 1;
|
||||||
|
else if (status === "degraded") healthCounts.degraded += 1;
|
||||||
|
else if (status === "down") healthCounts.down += 1;
|
||||||
|
else healthCounts.unknown += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestHealthCheckAt = getLatestTimestamp(
|
||||||
|
(instances ?? []).map((instance) => instance.health_last_checked_at),
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoSyncEnabledCount = (instances ?? []).filter(
|
||||||
|
(instance) => instance.auto_sync_enabled,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const latestAutoSyncFromInstances = getLatestTimestamp(
|
||||||
|
(instances ?? []).map((instance) => instance.last_auto_sync_at),
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestAutoSyncAt =
|
||||||
|
latestAutoSyncFromInstances ?? latestAutoSyncLog?.created_at ?? null;
|
||||||
|
|
||||||
|
const downInstanceNames = (instances ?? [])
|
||||||
|
.filter((instance) => instance.health_status === "down")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((instance) => instance.name || instance.base_url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8">
|
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
@@ -136,9 +411,100 @@ export default async function DashboardPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-600">Role: {context.role}</p>
|
<p className="text-sm text-slate-600">Role: {context.role}</p>
|
||||||
</div>
|
</div>
|
||||||
<SignOutButton />
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/activity"
|
||||||
|
className="text-sm text-brand-700 underline"
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</Link>
|
||||||
|
<SignOutButton />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-slate-200 bg-white p-4">
|
||||||
|
<div className="space-y-3 text-sm text-slate-700">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Instance Health
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
<span className="rounded bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||||
|
Ok: {healthCounts.ok}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
|
||||||
|
Degraded: {healthCounts.degraded}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700">
|
||||||
|
Down: {healthCounts.down}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
|
||||||
|
Unknown: {healthCounts.unknown}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{healthCounts.down > 0 ? (
|
||||||
|
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
|
||||||
|
{healthCounts.down} instance{healthCounts.down === 1 ? "" : "s"}{" "}
|
||||||
|
down
|
||||||
|
{downInstanceNames.length > 0
|
||||||
|
? `: ${downInstanceNames.join(", ")}${healthCounts.down > downInstanceNames.length ? ", …" : ""}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-slate-900">
|
||||||
|
Last Health Check:
|
||||||
|
</span>{" "}
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
latestHealthCheckAt
|
||||||
|
? formatDateTime(latestHealthCheckAt)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(latestHealthCheckAt) ?? "Never"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-slate-900">Auto Sync:</span>{" "}
|
||||||
|
Enabled on {autoSyncEnabledCount} instance
|
||||||
|
{autoSyncEnabledCount === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-slate-900">Last run:</span>{" "}
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
latestAutoSyncAt
|
||||||
|
? formatDateTime(latestAutoSyncAt)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(latestAutoSyncAt) ?? "Not yet run"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
<Link
|
||||||
|
href="/dashboard#instances-panel"
|
||||||
|
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
|
||||||
|
>
|
||||||
|
Instances
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/activity"
|
||||||
|
className="rounded border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-3">
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||||
<p className="text-sm text-slate-500">Plesk Instances</p>
|
<p className="text-sm text-slate-500">Plesk Instances</p>
|
||||||
@@ -164,12 +530,49 @@ export default async function DashboardPage() {
|
|||||||
Instances query failed: {instancesError.message}
|
Instances query failed: {instancesError.message}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<DashboardControls
|
<section id="instances-panel">
|
||||||
instances={instances ?? []}
|
<DashboardControls
|
||||||
subscriptions={subscriptions ?? []}
|
instances={instances ?? []}
|
||||||
domains={domains ?? []}
|
subscriptions={subscriptions ?? []}
|
||||||
autoSyncByInstance={Object.fromEntries(autoSyncByInstance.entries())}
|
domains={(domains ?? []).map((domain) => {
|
||||||
/>
|
const joinedStatusByLocalId = domain.subscription_id
|
||||||
|
? subscriptionStatusById.get(domain.subscription_id)
|
||||||
|
: null;
|
||||||
|
const joinedStatusByExternalId = domain.external_subscription_id
|
||||||
|
? subscriptionStatusByExternalId.get(
|
||||||
|
`${domain.plesk_instance_id}:${domain.external_subscription_id}`,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const joinedStatusByName = subscriptionStatusByInstanceAndName.get(
|
||||||
|
`${domain.plesk_instance_id}:${normalizeDomainLike(domain.domain_name)}`,
|
||||||
|
);
|
||||||
|
const joinedStatusBySuffix = findBestSuffixStatusMatch(
|
||||||
|
normalizeDomainLike(domain.domain_name),
|
||||||
|
subscriptionStatusNameCandidatesByInstance.get(
|
||||||
|
domain.plesk_instance_id,
|
||||||
|
) ?? [],
|
||||||
|
);
|
||||||
|
const joinedStatus =
|
||||||
|
joinedStatusByLocalId ??
|
||||||
|
joinedStatusByExternalId ??
|
||||||
|
joinedStatusByName ??
|
||||||
|
joinedStatusBySuffix;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...domain,
|
||||||
|
status: joinedStatus ?? "unknown",
|
||||||
|
subscription_name: domain.subscription_id
|
||||||
|
? (subscriptionDisplayNameById.get(domain.subscription_id) ??
|
||||||
|
null)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
autoSyncByInstance={Object.fromEntries(autoSyncByInstance.entries())}
|
||||||
|
healthEventsByInstance={Object.fromEntries(
|
||||||
|
healthEventsByInstance.entries(),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
24
docker-compose.prod.yml
Normal file
24
docker-compose.prod.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
container_name: plesk-agency-portal
|
||||||
|
image: ${APP_IMAGE:-plesk-agency-portal:latest}
|
||||||
|
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
|
||||||
230
docs/ai-project-context.md
Normal file
230
docs/ai-project-context.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Project: Plesk Agency Portal
|
||||||
|
|
||||||
|
This project is a multi-tenant SaaS platform that allows agencies to manage multiple Plesk servers from a single dashboard.
|
||||||
|
|
||||||
|
The application synchronizes subscriptions and domains from Plesk instances and allows administrators to manage hosting accounts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
Frontend
|
||||||
|
|
||||||
|
- Next.js (App Router)
|
||||||
|
- React
|
||||||
|
- TypeScript
|
||||||
|
|
||||||
|
Backend
|
||||||
|
|
||||||
|
- Next.js API routes
|
||||||
|
- Node runtime
|
||||||
|
|
||||||
|
Database
|
||||||
|
|
||||||
|
- Supabase Postgres
|
||||||
|
|
||||||
|
Authentication
|
||||||
|
|
||||||
|
- Supabase Auth (magic link)
|
||||||
|
|
||||||
|
External Integrations
|
||||||
|
|
||||||
|
- Plesk REST API
|
||||||
|
- Plesk CLI
|
||||||
|
- Stripe (future billing integration)
|
||||||
|
|
||||||
|
Development Tools
|
||||||
|
|
||||||
|
- Git
|
||||||
|
- Docker (for some services)
|
||||||
|
- Jenkins (future CI/CD)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Core Database Tables
|
||||||
|
|
||||||
|
agencies
|
||||||
|
|
||||||
|
agency_members
|
||||||
|
|
||||||
|
plesk_instances
|
||||||
|
|
||||||
|
plesk_subscriptions
|
||||||
|
|
||||||
|
plesk_domains
|
||||||
|
|
||||||
|
actions_log
|
||||||
|
|
||||||
|
billing_accounts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Multi-Tenant Model
|
||||||
|
|
||||||
|
Each user belongs to an agency.
|
||||||
|
|
||||||
|
Agency relationships:
|
||||||
|
|
||||||
|
user
|
||||||
|
→ agency_members
|
||||||
|
→ agencies
|
||||||
|
→ plesk_instances
|
||||||
|
→ plesk_subscriptions
|
||||||
|
→ plesk_domains
|
||||||
|
|
||||||
|
Row Level Security in Supabase ensures users can only access rows belonging to their agency.
|
||||||
|
|
||||||
|
Never bypass or remove RLS policies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plesk Integration
|
||||||
|
|
||||||
|
The system integrates with Plesk using both:
|
||||||
|
|
||||||
|
Plesk REST API
|
||||||
|
Plesk CLI
|
||||||
|
|
||||||
|
Common CLI commands used:
|
||||||
|
|
||||||
|
subscription --info
|
||||||
|
subscription --suspend
|
||||||
|
subscription --unsuspend
|
||||||
|
|
||||||
|
CLI output must always be parsed defensively because formatting can vary between Plesk versions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sync System
|
||||||
|
|
||||||
|
The platform supports manual synchronization of Plesk instances.
|
||||||
|
|
||||||
|
During sync the system:
|
||||||
|
|
||||||
|
1. Connects to a Plesk instance
|
||||||
|
2. Retrieves subscriptions and domains
|
||||||
|
3. Updates records in Supabase
|
||||||
|
4. Updates the dashboard
|
||||||
|
|
||||||
|
Future development will include background sync workers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dashboard Features
|
||||||
|
|
||||||
|
Current features include:
|
||||||
|
|
||||||
|
Connecting Plesk instances
|
||||||
|
Testing connections
|
||||||
|
Manual sync
|
||||||
|
Listing domains and subscriptions
|
||||||
|
Suspending subscriptions
|
||||||
|
Unsuspending subscriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
|
||||||
|
Operational actions are written to:
|
||||||
|
|
||||||
|
actions_log
|
||||||
|
|
||||||
|
Examples include:
|
||||||
|
|
||||||
|
instance_connected
|
||||||
|
sync_started
|
||||||
|
sync_completed
|
||||||
|
subscription_suspended
|
||||||
|
subscription_unsuspended
|
||||||
|
|
||||||
|
Logging should never block primary workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Development Rules
|
||||||
|
|
||||||
|
When implementing features:
|
||||||
|
|
||||||
|
Search the repository before modifying code.
|
||||||
|
|
||||||
|
Extend existing architecture rather than rewriting systems.
|
||||||
|
|
||||||
|
Avoid introducing breaking database schema changes.
|
||||||
|
|
||||||
|
Prefer small modular functions.
|
||||||
|
|
||||||
|
Keep API routes thin.
|
||||||
|
|
||||||
|
Place integration logic inside lib folders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git Workflow
|
||||||
|
|
||||||
|
Default branch:
|
||||||
|
|
||||||
|
master
|
||||||
|
|
||||||
|
All work must be done on feature branches.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
git checkout master
|
||||||
|
git pull
|
||||||
|
git checkout -b feature/<feature-name>
|
||||||
|
|
||||||
|
Never commit directly to master.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Error Handling
|
||||||
|
|
||||||
|
External integrations must always fail gracefully.
|
||||||
|
|
||||||
|
If a single subscription fails during sync, the rest of the sync must continue.
|
||||||
|
|
||||||
|
All failures should be logged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI Safety
|
||||||
|
|
||||||
|
Dashboard components must:
|
||||||
|
|
||||||
|
Handle null values safely
|
||||||
|
Avoid crashing if API responses change
|
||||||
|
Use status badges instead of raw text for statuses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Future Roadmap
|
||||||
|
|
||||||
|
Upcoming development milestones include:
|
||||||
|
|
||||||
|
CL4 – Subscription status ingestion via CLI
|
||||||
|
|
||||||
|
CL5 – Action logging and retry queue
|
||||||
|
|
||||||
|
CL6 – Automatic sync worker
|
||||||
|
|
||||||
|
CL7 – Stripe billing enforcement
|
||||||
|
|
||||||
|
CL8 – Instance health monitoring
|
||||||
|
|
||||||
|
CL9 – Observability and audit improvements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI Agent Instructions
|
||||||
|
|
||||||
|
Before modifying code:
|
||||||
|
|
||||||
|
Search the repository for related implementations.
|
||||||
|
|
||||||
|
Avoid rewriting working systems.
|
||||||
|
|
||||||
|
Implement minimal safe modifications.
|
||||||
|
|
||||||
|
Always verify that existing features continue working.
|
||||||
|
|
||||||
|
If unsure about a change, prefer logging rather than failing.
|
||||||
202
docs/cline-super-prompt.md
Normal file
202
docs/cline-super-prompt.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Cline Development Super Prompt
|
||||||
|
|
||||||
|
You are acting as a senior software engineer working on this repository.
|
||||||
|
|
||||||
|
Before making any modifications you must understand the project structure and current implementation.
|
||||||
|
|
||||||
|
Always read:
|
||||||
|
|
||||||
|
`docs/ai-project-context.md`
|
||||||
|
|
||||||
|
before implementing tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working Method
|
||||||
|
|
||||||
|
When given a task follow this process.
|
||||||
|
|
||||||
|
Step 1
|
||||||
|
Search the repository for relevant code.
|
||||||
|
|
||||||
|
Step 2
|
||||||
|
Identify the correct extension point for the feature.
|
||||||
|
|
||||||
|
Step 3
|
||||||
|
Design the minimal safe implementation.
|
||||||
|
|
||||||
|
Step 4
|
||||||
|
Modify only the required files.
|
||||||
|
|
||||||
|
Step 5
|
||||||
|
Verify existing functionality remains intact.
|
||||||
|
|
||||||
|
Never rewrite large parts of the system unless explicitly instructed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Exploration Rules
|
||||||
|
|
||||||
|
Before editing files:
|
||||||
|
|
||||||
|
Search for related implementations.
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
|
||||||
|
- plesk integration
|
||||||
|
- sync logic
|
||||||
|
- API routes
|
||||||
|
- database queries
|
||||||
|
- dashboard components
|
||||||
|
|
||||||
|
Use existing patterns instead of creating new ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Rules
|
||||||
|
|
||||||
|
The project uses Supabase Postgres.
|
||||||
|
|
||||||
|
Never introduce breaking schema changes.
|
||||||
|
|
||||||
|
All schema updates must be done using safe migrations.
|
||||||
|
|
||||||
|
Never remove columns that may contain production data.
|
||||||
|
|
||||||
|
Always assume the database already contains data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-Tenant Security
|
||||||
|
|
||||||
|
This system is multi-tenant.
|
||||||
|
|
||||||
|
Users belong to agencies.
|
||||||
|
|
||||||
|
All queries must respect agency boundaries.
|
||||||
|
|
||||||
|
Never bypass Supabase Row Level Security.
|
||||||
|
|
||||||
|
Never expose data across agencies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plesk Integration Safety
|
||||||
|
|
||||||
|
The platform interacts with Plesk servers using both REST API and CLI commands.
|
||||||
|
|
||||||
|
CLI output can vary between Plesk versions.
|
||||||
|
|
||||||
|
Always parse CLI output defensively.
|
||||||
|
|
||||||
|
Never assume fixed output formatting.
|
||||||
|
|
||||||
|
External integration failures must not crash the application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Pipeline Rules
|
||||||
|
|
||||||
|
The sync system is critical to the platform.
|
||||||
|
|
||||||
|
Never break existing sync behaviour.
|
||||||
|
|
||||||
|
If one subscription fails during sync:
|
||||||
|
|
||||||
|
Continue processing the rest.
|
||||||
|
|
||||||
|
Log the failure.
|
||||||
|
|
||||||
|
Do not abort the entire sync job.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logging Requirements
|
||||||
|
|
||||||
|
Operational events should be written to:
|
||||||
|
|
||||||
|
`actions_log`
|
||||||
|
|
||||||
|
Examples include:
|
||||||
|
|
||||||
|
- instance_connected
|
||||||
|
- sync_started
|
||||||
|
- sync_completed
|
||||||
|
- subscription_suspended
|
||||||
|
- subscription_unsuspended
|
||||||
|
|
||||||
|
Logging errors must never break primary workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Safety
|
||||||
|
|
||||||
|
Frontend components must:
|
||||||
|
|
||||||
|
Handle missing or null data safely.
|
||||||
|
|
||||||
|
Avoid crashing when API responses change.
|
||||||
|
|
||||||
|
Display statuses using badges instead of raw text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Workflow
|
||||||
|
|
||||||
|
The repository uses:
|
||||||
|
|
||||||
|
`master` as the default branch.
|
||||||
|
|
||||||
|
All development must occur in feature branches.
|
||||||
|
|
||||||
|
Example workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git pull
|
||||||
|
git checkout -b feature/<feature-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit frequently with clear commit messages.
|
||||||
|
|
||||||
|
Never commit directly to master.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Guidelines
|
||||||
|
|
||||||
|
Prefer small modular changes.
|
||||||
|
|
||||||
|
Avoid large refactors unless requested.
|
||||||
|
|
||||||
|
Keep API routes lightweight.
|
||||||
|
|
||||||
|
Place integration logic inside lib folders.
|
||||||
|
|
||||||
|
Reuse existing utilities where possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
External services can fail.
|
||||||
|
|
||||||
|
Always implement safe error handling.
|
||||||
|
|
||||||
|
Prefer returning partial results over failing the entire operation.
|
||||||
|
|
||||||
|
Log unexpected behaviour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expected Output
|
||||||
|
|
||||||
|
When implementing tasks you should:
|
||||||
|
|
||||||
|
Modify only the required files.
|
||||||
|
|
||||||
|
Maintain existing architecture.
|
||||||
|
|
||||||
|
Add clear commit messages.
|
||||||
|
|
||||||
|
Ensure the application remains stable.
|
||||||
149
docs/deployment-production.md
Normal file
149
docs/deployment-production.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Reverse proxy assumptions (Nginx Proxy Manager)
|
||||||
|
|
||||||
|
This app is expected to run behind a reverse proxy in production.
|
||||||
|
|
||||||
|
- TLS is terminated at the proxy.
|
||||||
|
- Proxy forwards standard host/protocol headers (`Host`, `X-Forwarded-Proto`, `X-Forwarded-Host`).
|
||||||
|
- Public origin is configured explicitly with `NEXT_PUBLIC_APP_URL`.
|
||||||
|
|
||||||
|
To keep callback behavior stable behind proxy layers, auth redirect responses use the configured application origin from environment configuration rather than relying only on request host detection.
|
||||||
|
|
||||||
|
## Callback URL expectations
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_APP_URL` must be set to the public HTTPS URL used by users.
|
||||||
|
- Supabase auth redirect/callback configuration must allow:
|
||||||
|
- `<NEXT_PUBLIC_APP_URL>/auth/callback`
|
||||||
|
|
||||||
|
The callback route also validates the `next` parameter as an internal path to prevent unsafe external redirects.
|
||||||
|
|
||||||
|
## Cron protection expectations
|
||||||
|
|
||||||
|
Operational cron endpoints are protected with a shared secret.
|
||||||
|
|
||||||
|
- Required header: `x-cron-secret`
|
||||||
|
- Server secret source: `CRON_SHARED_SECRET`
|
||||||
|
- Backward compatibility fallback: `CRON_SECRET`
|
||||||
|
|
||||||
|
For new production setups, set `CRON_SHARED_SECRET` and use that value for all cron callers.
|
||||||
|
|
||||||
|
## Jenkins pipeline flow
|
||||||
|
|
||||||
|
The repository includes a declarative `Jenkinsfile` with the following stages:
|
||||||
|
|
||||||
|
1. **Checkout**
|
||||||
|
2. **Install dependencies** (`npm ci`)
|
||||||
|
3. **Lint** (runs only when `package.json` contains a `lint` script)
|
||||||
|
4. **Build application** (`npm run build`)
|
||||||
|
5. **Build Docker image** (tags `latest` and `build-${BUILD_NUMBER}`)
|
||||||
|
6. **Deploy** over SSH to the app host
|
||||||
|
7. **Smoke check** against `/api/health`
|
||||||
|
|
||||||
|
## Required Jenkins configuration
|
||||||
|
|
||||||
|
Configure these values in Jenkins job/folder/global environment or credentials-backed environment variables:
|
||||||
|
|
||||||
|
- `DEPLOY_SSH_HOST` - remote app host
|
||||||
|
- `DEPLOY_SSH_USER` - SSH user on remote host
|
||||||
|
- `DEPLOY_SSH_CREDENTIAL_ID` - Jenkins SSH Agent credential ID
|
||||||
|
- `DEPLOY_PATH` - remote deployment path (for example `/opt/plesk-agency-portal`)
|
||||||
|
- `APP_BASE_URL` - externally reachable app URL used by smoke check
|
||||||
|
|
||||||
|
The pipeline assumes Jenkins credentials are managed outside this repository.
|
||||||
|
|
||||||
|
## Deploy flow
|
||||||
|
|
||||||
|
Deployment is intentionally simple and bash-based:
|
||||||
|
|
||||||
|
1. Jenkins builds Docker image tags locally.
|
||||||
|
2. Jenkins syncs deployment files (`docker-compose.prod.yml`, deploy/smoke/rollback scripts) to the remote deploy path.
|
||||||
|
3. Jenkins streams Docker images to the remote host (`docker save | ssh docker load`).
|
||||||
|
4. Jenkins runs `scripts/deploy-prod.sh` remotely.
|
||||||
|
5. Script applies the selected image tag via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_IMAGE=<tag> docker compose -f docker-compose.prod.yml up -d --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Script prints `docker compose ps` status.
|
||||||
|
|
||||||
|
## Rollback flow
|
||||||
|
|
||||||
|
Rollback uses `scripts/rollback-prod.sh` with a previously built image tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEPLOY_PATH=/opt/plesk-agency-portal \
|
||||||
|
APP_BASE_URL=https://your-app.example.com \
|
||||||
|
scripts/rollback-prod.sh plesk-agency-portal:build-123
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback redeploys that image with compose, then immediately reruns smoke checks.
|
||||||
|
|
||||||
|
## Smoke check behavior
|
||||||
|
|
||||||
|
`scripts/smoke-check.sh`:
|
||||||
|
|
||||||
|
- accepts a base URL argument
|
||||||
|
- requests `${BASE_URL}/api/health`
|
||||||
|
- fails on non-200 responses
|
||||||
|
- fails on invalid JSON or missing expected fields (`ok: true`, `service` string)
|
||||||
|
|
||||||
|
This gives a quick post-deploy validation gate for both deploy and rollback operations.
|
||||||
179
docs/system-architecture.md
Normal file
179
docs/system-architecture.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# System Architecture
|
||||||
|
|
||||||
|
This document describes the architecture of the Plesk Agency Portal SaaS platform.
|
||||||
|
|
||||||
|
The platform allows agencies to connect multiple Plesk servers and manage hosting subscriptions from a single dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# High Level Components
|
||||||
|
|
||||||
|
The system consists of the following components:
|
||||||
|
|
||||||
|
Frontend Dashboard
|
||||||
|
Backend API
|
||||||
|
Supabase Database
|
||||||
|
Plesk Servers
|
||||||
|
Future Worker Services
|
||||||
|
Future Billing Integration
|
||||||
|
|
||||||
|
At a high level, users interact with the Frontend Dashboard, which sends authenticated requests to the Backend API (Next.js API routes). The backend reads and writes persistent state in Supabase, and executes remote operations against connected Plesk servers via API/CLI integrations. Future worker services will run asynchronous sync and retry workflows, while future billing integration will enforce plan limits and subscription state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
|
||||||
|
The frontend is a Next.js application using the App Router.
|
||||||
|
|
||||||
|
Responsibilities include:
|
||||||
|
|
||||||
|
User authentication
|
||||||
|
Dashboard interface
|
||||||
|
Displaying domains and subscriptions
|
||||||
|
Triggering sync operations
|
||||||
|
Sending management actions (suspend / unsuspend)
|
||||||
|
|
||||||
|
The frontend communicates with backend API routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
|
||||||
|
The backend uses Next.js API routes.
|
||||||
|
|
||||||
|
Responsibilities include:
|
||||||
|
|
||||||
|
Handling authenticated requests
|
||||||
|
Connecting to Supabase
|
||||||
|
Executing Plesk API and CLI commands
|
||||||
|
Processing sync operations
|
||||||
|
Writing operational logs
|
||||||
|
|
||||||
|
Business logic should be implemented in reusable modules inside the lib directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Database
|
||||||
|
|
||||||
|
Supabase Postgres is used for persistent storage.
|
||||||
|
|
||||||
|
Key tables include:
|
||||||
|
|
||||||
|
agencies
|
||||||
|
agency_members
|
||||||
|
plesk_instances
|
||||||
|
plesk_subscriptions
|
||||||
|
plesk_domains
|
||||||
|
actions_log
|
||||||
|
billing_accounts
|
||||||
|
|
||||||
|
Relationship overview:
|
||||||
|
|
||||||
|
- `agencies` is the tenant root.
|
||||||
|
- `agency_members` associates users to agencies and roles.
|
||||||
|
- `plesk_instances` stores connected remote Plesk endpoints per agency.
|
||||||
|
- `plesk_subscriptions` stores subscription-level records linked to instances.
|
||||||
|
- `plesk_domains` stores domain-level records linked to subscriptions/instances.
|
||||||
|
- `actions_log` stores operational/audit events.
|
||||||
|
- `billing_accounts` stores plan/subscription billing state per agency.
|
||||||
|
|
||||||
|
The multi-tenant model is enforced using Supabase Row Level Security policies so users can only read/write rows that belong to their agency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plesk Integration
|
||||||
|
|
||||||
|
The platform communicates with remote Plesk servers.
|
||||||
|
|
||||||
|
Integration methods:
|
||||||
|
|
||||||
|
Plesk REST API
|
||||||
|
Plesk CLI commands
|
||||||
|
|
||||||
|
Typical operations include:
|
||||||
|
|
||||||
|
Listing subscriptions
|
||||||
|
Retrieving domain data
|
||||||
|
Suspending subscriptions
|
||||||
|
Unsuspending subscriptions
|
||||||
|
|
||||||
|
CLI parsing must be defensive due to formatting differences between Plesk versions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sync System
|
||||||
|
|
||||||
|
The system supports synchronization of Plesk servers.
|
||||||
|
|
||||||
|
Manual sync is triggered from the dashboard.
|
||||||
|
|
||||||
|
During sync:
|
||||||
|
|
||||||
|
The backend connects to a Plesk instance
|
||||||
|
Subscription and domain data are retrieved
|
||||||
|
Records are updated in Supabase
|
||||||
|
The dashboard displays updated data
|
||||||
|
|
||||||
|
Future development will add automated background sync workers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Logging System
|
||||||
|
|
||||||
|
Operational actions are recorded in the actions_log table.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
instance_connected
|
||||||
|
sync_started
|
||||||
|
sync_completed
|
||||||
|
subscription_suspended
|
||||||
|
subscription_unsuspended
|
||||||
|
|
||||||
|
Logging supports debugging and auditability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Future Worker Architecture
|
||||||
|
|
||||||
|
Future versions of the system will include background workers for:
|
||||||
|
|
||||||
|
Automatic instance synchronization
|
||||||
|
Retry queues
|
||||||
|
Monitoring tasks
|
||||||
|
|
||||||
|
Workers may run as:
|
||||||
|
|
||||||
|
Cron jobs
|
||||||
|
Queue processors
|
||||||
|
Dedicated worker services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Billing Architecture (Future)
|
||||||
|
|
||||||
|
Stripe will be integrated for SaaS billing.
|
||||||
|
|
||||||
|
Billing responsibilities will include:
|
||||||
|
|
||||||
|
Agency subscription plans
|
||||||
|
Usage limits
|
||||||
|
Payment handling
|
||||||
|
|
||||||
|
Billing enforcement will eventually control:
|
||||||
|
|
||||||
|
Number of connected Plesk instances
|
||||||
|
Number of managed domains
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Development Philosophy
|
||||||
|
|
||||||
|
The system prioritizes:
|
||||||
|
|
||||||
|
Stability
|
||||||
|
Security
|
||||||
|
Multi-tenant isolation
|
||||||
|
Safe integrations with external systems
|
||||||
|
|
||||||
|
Developers should extend existing systems rather than rewriting them.
|
||||||
18
lib/env.ts
18
lib/env.ts
@@ -3,6 +3,18 @@ const requiredEnvs = [
|
|||||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const configuredAppUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
|
||||||
|
|
||||||
|
function getAppOrigin() {
|
||||||
|
if (!configuredAppUrl) return "http://localhost:3000";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(configuredAppUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return "http://localhost:3000";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of requiredEnvs) {
|
for (const key of requiredEnvs) {
|
||||||
if (!process.env[key]) {
|
if (!process.env[key]) {
|
||||||
// Keep as runtime warning for smoother local setup.
|
// Keep as runtime warning for smoother local setup.
|
||||||
@@ -12,8 +24,12 @@ for (const key of requiredEnvs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
appUrl: configuredAppUrl ?? "http://localhost:3000",
|
||||||
|
appOrigin: getAppOrigin(),
|
||||||
jobSecret: process.env.JOB_SECRET ?? "",
|
jobSecret: process.env.JOB_SECRET ?? "",
|
||||||
|
cronSecret: process.env.CRON_SECRET ?? "",
|
||||||
|
cronSharedSecret:
|
||||||
|
process.env.CRON_SHARED_SECRET ?? process.env.CRON_SECRET ?? "",
|
||||||
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
|
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
|
||||||
pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "",
|
pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "",
|
||||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
|
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||||
|
|||||||
232
lib/plesk/auto-sync.ts
Normal file
232
lib/plesk/auto-sync.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
import { syncPleskInstance } from "@/lib/plesk/sync";
|
||||||
|
|
||||||
|
type SupabaseLike = any;
|
||||||
|
|
||||||
|
type AutoSyncInstance = {
|
||||||
|
id: string;
|
||||||
|
agency_id: string;
|
||||||
|
base_url: string;
|
||||||
|
auth_type: "api_key" | "basic";
|
||||||
|
encrypted_secret: string;
|
||||||
|
last_connected_at: string | null;
|
||||||
|
auto_sync_enabled: boolean;
|
||||||
|
auto_sync_frequency_minutes: number | null;
|
||||||
|
next_auto_sync_at: string | null;
|
||||||
|
auto_sync_lock_until: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoSyncSummary = {
|
||||||
|
instances_considered: number;
|
||||||
|
locks_acquired: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
duration_ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_INSTANCES_PER_TICK = 5;
|
||||||
|
const MAX_CONCURRENCY = 2;
|
||||||
|
const LOCK_LEASE_SECONDS = 15 * 60;
|
||||||
|
const INSTANCE_TIMEOUT_MS = 14 * 60 * 1000;
|
||||||
|
|
||||||
|
function isDue(instance: AutoSyncInstance, now: number) {
|
||||||
|
if (!instance.next_auto_sync_at) return true;
|
||||||
|
const dueAt = new Date(instance.next_auto_sync_at).getTime();
|
||||||
|
return Number.isFinite(dueAt) && dueAt <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnlocked(instance: AutoSyncInstance, now: number) {
|
||||||
|
if (!instance.auto_sync_lock_until) return true;
|
||||||
|
const lockUntil = new Date(instance.auto_sync_lock_until).getTime();
|
||||||
|
return !Number.isFinite(lockUntil) || lockUntil <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertActionLogBestEffort(
|
||||||
|
supabase: SupabaseLike,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.from("actions_log").insert(payload);
|
||||||
|
if (error) {
|
||||||
|
console.error("[autosync] actions_log insert failed", error.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[autosync] actions_log insert threw", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<T> {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
reject(new Error("Auto-sync timed out"));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeoutPromise]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAutoSyncTick(
|
||||||
|
supabase: SupabaseLike,
|
||||||
|
): Promise<AutoSyncSummary> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const { data: rows, error } = await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.select(
|
||||||
|
"id, agency_id, base_url, auth_type, encrypted_secret, last_connected_at, auto_sync_enabled, auto_sync_frequency_minutes, next_auto_sync_at, auto_sync_lock_until",
|
||||||
|
)
|
||||||
|
.eq("status", "connected")
|
||||||
|
.eq("auto_sync_enabled", true)
|
||||||
|
.order("next_auto_sync_at", { ascending: true, nullsFirst: true })
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligible = ((rows ?? []) as AutoSyncInstance[])
|
||||||
|
.filter((instance) => isDue(instance, now) && isUnlocked(instance, now))
|
||||||
|
.slice(0, MAX_INSTANCES_PER_TICK);
|
||||||
|
|
||||||
|
let locksAcquired = 0;
|
||||||
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const queue = [...eligible];
|
||||||
|
|
||||||
|
async function processInstance(instance: AutoSyncInstance) {
|
||||||
|
const lockToken = randomUUID();
|
||||||
|
const { data: lockAcquired, error: lockError } = await supabase.rpc(
|
||||||
|
"acquire_instance_auto_sync_lock",
|
||||||
|
{
|
||||||
|
p_instance_id: instance.id,
|
||||||
|
p_lock_token: lockToken,
|
||||||
|
p_ttl_seconds: LOCK_LEASE_SECONDS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lockError || !lockAcquired) return;
|
||||||
|
locksAcquired += 1;
|
||||||
|
|
||||||
|
const perInstanceStartedAt = Date.now();
|
||||||
|
const frequencyMinutes = Math.max(
|
||||||
|
instance.auto_sync_frequency_minutes ?? 60,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: instance.agency_id,
|
||||||
|
actor_user_id: null,
|
||||||
|
target_type: "plesk_instance",
|
||||||
|
target_id: instance.id,
|
||||||
|
action: "autosync_started",
|
||||||
|
status: "pending",
|
||||||
|
metadata: {
|
||||||
|
instance_id: instance.id,
|
||||||
|
lock_token: lockToken,
|
||||||
|
frequency_minutes: frequencyMinutes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
syncPleskInstance(supabase, instance, null, "autosync"),
|
||||||
|
INSTANCE_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
succeeded += 1;
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: instance.agency_id,
|
||||||
|
actor_user_id: null,
|
||||||
|
target_type: "plesk_instance",
|
||||||
|
target_id: instance.id,
|
||||||
|
action: "autosync_completed",
|
||||||
|
status: "success",
|
||||||
|
metadata: {
|
||||||
|
instance_id: instance.id,
|
||||||
|
lock_token: lockToken,
|
||||||
|
duration_ms: Date.now() - perInstanceStartedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (syncError) {
|
||||||
|
failed += 1;
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
syncError instanceof Error
|
||||||
|
? syncError.message.slice(0, 500)
|
||||||
|
: "autosync_failed";
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: instance.agency_id,
|
||||||
|
actor_user_id: null,
|
||||||
|
target_type: "plesk_instance",
|
||||||
|
target_id: instance.id,
|
||||||
|
action: "autosync_failed",
|
||||||
|
status: "failed",
|
||||||
|
error_message: errorMessage,
|
||||||
|
metadata: {
|
||||||
|
instance_id: instance.id,
|
||||||
|
lock_token: lockToken,
|
||||||
|
duration_ms: Date.now() - perInstanceStartedAt,
|
||||||
|
error_summary: errorMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const nextAutoSyncAtIso = new Date(
|
||||||
|
Date.now() + frequencyMinutes * 60 * 1000,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
const { error: scheduleError } = await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.update({
|
||||||
|
last_auto_sync_at: nowIso,
|
||||||
|
next_auto_sync_at: nextAutoSyncAtIso,
|
||||||
|
auto_sync_lock_until: null,
|
||||||
|
auto_sync_lock_token: null,
|
||||||
|
})
|
||||||
|
.eq("id", instance.id)
|
||||||
|
.eq("auto_sync_lock_token", lockToken);
|
||||||
|
|
||||||
|
if (scheduleError) {
|
||||||
|
console.error(
|
||||||
|
"[autosync] schedule update failed",
|
||||||
|
scheduleError.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(MAX_CONCURRENCY, queue.length) },
|
||||||
|
async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const instance = queue.shift();
|
||||||
|
if (!instance) break;
|
||||||
|
await processInstance(instance);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
instances_considered: eligible.length,
|
||||||
|
locks_acquired: locksAcquired,
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,12 +9,15 @@ export type PleskConnection = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PleskSubscription = {
|
export type PleskSubscription = {
|
||||||
|
plesk_subscription_id?: string;
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
guid?: string;
|
guid?: string;
|
||||||
subscription_id?: string | number;
|
subscription_id?: string | number;
|
||||||
external_id?: string;
|
external_id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
status_raw?: string | null;
|
||||||
|
status_sync_error?: string | null;
|
||||||
owner_login?: string;
|
owner_login?: string;
|
||||||
owner?: { login?: string };
|
owner?: { login?: string };
|
||||||
plan_name?: string;
|
plan_name?: string;
|
||||||
@@ -137,51 +140,95 @@ export async function testPleskConnection(connection: PleskConnection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listPleskSubscriptions(connection: PleskConnection) {
|
export async function listPleskSubscriptions(connection: PleskConnection) {
|
||||||
try {
|
const listResult = await pleskCliCall(connection, "subscription", ["--list"]);
|
||||||
const all: PleskSubscription[] = [];
|
|
||||||
|
|
||||||
for (let page = 1; page <= 20; page += 1) {
|
const subscriptionNames = (listResult.stdout ?? "")
|
||||||
const payload = await pleskRequest<PleskSubscription[]>(
|
.split(/\r?\n/)
|
||||||
connection,
|
.map((line) => line.trim())
|
||||||
`/api/v2/subscriptions?page=${page}&per_page=100`,
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const subscriptions: PleskSubscription[] = [];
|
||||||
|
|
||||||
|
for (const subscriptionName of subscriptionNames.slice(0, 500)) {
|
||||||
|
let status: "active" | "suspended" | "disabled" | "expired" | "unknown" =
|
||||||
|
"unknown";
|
||||||
|
let statusRaw: string | null = null;
|
||||||
|
let statusSyncError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const infoResult = await pleskCliCall(connection, "subscription", [
|
||||||
|
"--info",
|
||||||
|
subscriptionName,
|
||||||
|
]);
|
||||||
|
const parsed = parseSubscriptionStatusDetailsFromInfo(
|
||||||
|
infoResult.stdout ?? "",
|
||||||
);
|
);
|
||||||
const rows = Array.isArray(payload) ? payload : [];
|
status = parsed.status;
|
||||||
all.push(...rows);
|
statusRaw = parsed.statusRaw;
|
||||||
if (rows.length < 100) break;
|
} catch (error) {
|
||||||
|
status = "unknown";
|
||||||
|
statusSyncError =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message.slice(0, 1000)
|
||||||
|
: "status_sync_failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
return all;
|
subscriptions.push({
|
||||||
} catch {
|
plesk_subscription_id: subscriptionName,
|
||||||
const cli = await pleskRequest<PleskCliResult>(
|
subscription_id: subscriptionName,
|
||||||
connection,
|
name: subscriptionName,
|
||||||
"/api/v2/cli/subscription/call",
|
status,
|
||||||
{
|
status_raw: statusRaw,
|
||||||
method: "POST",
|
status_sync_error: statusSyncError,
|
||||||
body: JSON.stringify({ params: ["--list"] }),
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const stdout = cli?.stdout ?? "";
|
|
||||||
if (!stdout.trim()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return stdout
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => {
|
|
||||||
const columns = line.split(/\s+/);
|
|
||||||
const externalId = columns[0] ?? line;
|
|
||||||
return {
|
|
||||||
id: externalId,
|
|
||||||
subscription_id: externalId,
|
|
||||||
external_id: externalId,
|
|
||||||
name: columns.slice(1).join(" ") || externalId,
|
|
||||||
status: null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return subscriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSubscriptionStatusDetailsFromInfo(stdout: string): {
|
||||||
|
status: "active" | "suspended" | "disabled" | "expired" | "unknown";
|
||||||
|
statusRaw: string | null;
|
||||||
|
} {
|
||||||
|
const line = stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.find((entry) => /^domain\s+status\s*:/i.test(entry));
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
return { status: "unknown", statusRaw: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = line.replace(/^domain\s+status\s*:/i, "").trim();
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
|
||||||
|
if (normalized.includes("expired")) {
|
||||||
|
return { status: "expired", statusRaw: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("disabled")) {
|
||||||
|
return { status: "disabled", statusRaw: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes("suspend")) {
|
||||||
|
return { status: "suspended", statusRaw: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes("ok") ||
|
||||||
|
normalized.includes("active") ||
|
||||||
|
normalized.includes("enabled")
|
||||||
|
) {
|
||||||
|
return { status: "active", statusRaw: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "unknown", statusRaw: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSubscriptionStatusFromInfo(
|
||||||
|
stdout: string,
|
||||||
|
): "active" | "suspended" | "disabled" | "expired" | "unknown" {
|
||||||
|
return parseSubscriptionStatusDetailsFromInfo(stdout).status;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pleskCliCall(
|
export async function pleskCliCall(
|
||||||
@@ -242,7 +289,7 @@ export async function unsuspendPleskSubscription(
|
|||||||
subscriptionName: string,
|
subscriptionName: string,
|
||||||
) {
|
) {
|
||||||
return pleskCliCall(connection, "subscription", [
|
return pleskCliCall(connection, "subscription", [
|
||||||
"--webspace-on",
|
"--unsuspend",
|
||||||
subscriptionName,
|
subscriptionName,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
345
lib/plesk/health.ts
Normal file
345
lib/plesk/health.ts
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
import { testPleskConnection } from "@/lib/plesk/client";
|
||||||
|
|
||||||
|
type SupabaseLike = any;
|
||||||
|
|
||||||
|
type HealthStatus = "ok" | "degraded" | "down" | "unknown";
|
||||||
|
|
||||||
|
type HealthInstance = {
|
||||||
|
id: string;
|
||||||
|
agency_id: string;
|
||||||
|
base_url: string;
|
||||||
|
auth_type: "api_key" | "basic";
|
||||||
|
encrypted_secret: string;
|
||||||
|
health_enabled: boolean;
|
||||||
|
health_check_frequency_minutes: number | null;
|
||||||
|
health_next_check_at: string | null;
|
||||||
|
health_lock_until: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HealthCheckResult = {
|
||||||
|
status: Exclude<HealthStatus, "unknown">;
|
||||||
|
latency_ms: number;
|
||||||
|
error_message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunSingleHealthCheckResult = {
|
||||||
|
checked: boolean;
|
||||||
|
status?: Exclude<HealthStatus, "unknown">;
|
||||||
|
latency_ms?: number;
|
||||||
|
error_message?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HealthTickSummary = {
|
||||||
|
checked: number;
|
||||||
|
ok: number;
|
||||||
|
degraded: number;
|
||||||
|
failed: number;
|
||||||
|
duration_ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_INSTANCES_PER_TICK = 10;
|
||||||
|
const MAX_CONCURRENCY = 3;
|
||||||
|
const LOCK_LEASE_SECONDS = 5 * 60;
|
||||||
|
const HEALTH_TIMEOUT_MS = 15_000;
|
||||||
|
const DEGRADED_LATENCY_THRESHOLD_MS = 2_500;
|
||||||
|
|
||||||
|
function isDue(instance: HealthInstance, now: number) {
|
||||||
|
if (!instance.health_next_check_at) return true;
|
||||||
|
const dueAt = new Date(instance.health_next_check_at).getTime();
|
||||||
|
return Number.isFinite(dueAt) && dueAt <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnlocked(instance: HealthInstance, now: number) {
|
||||||
|
if (!instance.health_lock_until) return true;
|
||||||
|
const lockUntil = new Date(instance.health_lock_until).getTime();
|
||||||
|
return !Number.isFinite(lockUntil) || lockUntil <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertActionLogBestEffort(
|
||||||
|
supabase: SupabaseLike,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.from("actions_log").insert(payload);
|
||||||
|
if (error) {
|
||||||
|
console.error("[health] actions_log insert failed", error.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[health] actions_log insert threw", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<T> {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
reject(new Error("Health check timed out"));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeoutPromise]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPleskInstanceHealth(
|
||||||
|
instance: Pick<HealthInstance, "base_url" | "auth_type" | "encrypted_secret">,
|
||||||
|
): Promise<HealthCheckResult> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
testPleskConnection({
|
||||||
|
baseUrl: instance.base_url,
|
||||||
|
authType: instance.auth_type,
|
||||||
|
encryptedSecret: instance.encrypted_secret,
|
||||||
|
}),
|
||||||
|
HEALTH_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const latencyMs = Date.now() - startedAt;
|
||||||
|
const status: "ok" | "degraded" =
|
||||||
|
latencyMs > DEGRADED_LATENCY_THRESHOLD_MS ? "degraded" : "ok";
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
latency_ms: latencyMs,
|
||||||
|
error_message: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: "down",
|
||||||
|
latency_ms: Date.now() - startedAt,
|
||||||
|
error_message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message.slice(0, 1000)
|
||||||
|
: "health_check_failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInstanceHealthCheck(
|
||||||
|
supabase: SupabaseLike,
|
||||||
|
instance: HealthInstance,
|
||||||
|
input?: {
|
||||||
|
actorUserId?: string | null;
|
||||||
|
force?: boolean;
|
||||||
|
source?: "cron" | "manual";
|
||||||
|
},
|
||||||
|
): Promise<RunSingleHealthCheckResult> {
|
||||||
|
const lockToken = randomUUID();
|
||||||
|
const actorUserId = input?.actorUserId ?? null;
|
||||||
|
const source = input?.source ?? "cron";
|
||||||
|
|
||||||
|
const { data: lockAcquired, error: lockError } = await supabase.rpc(
|
||||||
|
"acquire_instance_health_lock",
|
||||||
|
{
|
||||||
|
p_instance_id: instance.id,
|
||||||
|
p_lock_token: lockToken,
|
||||||
|
p_ttl_seconds: LOCK_LEASE_SECONDS,
|
||||||
|
p_force: Boolean(input?.force),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lockError || !lockAcquired) {
|
||||||
|
return { checked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const frequencyMinutes = Math.max(
|
||||||
|
instance.health_check_frequency_minutes ?? 30,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
const nextCheckAtIso = new Date(
|
||||||
|
Date.now() + frequencyMinutes * 60 * 1000,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: instance.agency_id,
|
||||||
|
actor_user_id: actorUserId,
|
||||||
|
target_type: "plesk_instance",
|
||||||
|
target_id: instance.id,
|
||||||
|
action: "health_check_started",
|
||||||
|
status: "pending",
|
||||||
|
metadata: {
|
||||||
|
instance_id: instance.id,
|
||||||
|
lock_token: lockToken,
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await checkPleskInstanceHealth(instance);
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
health_status: result.status,
|
||||||
|
health_last_checked_at: nowIso,
|
||||||
|
health_last_latency_ms: result.latency_ms,
|
||||||
|
health_last_error: result.error_message,
|
||||||
|
health_next_check_at: nextCheckAtIso,
|
||||||
|
health_lock_until: null,
|
||||||
|
health_lock_token: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.status === "ok") {
|
||||||
|
updates.health_last_ok_at = nowIso;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.update(updates)
|
||||||
|
.eq("id", instance.id)
|
||||||
|
.eq("health_lock_token", lockToken);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
throw new Error(updateError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const action =
|
||||||
|
result.status === "ok"
|
||||||
|
? "health_check_ok"
|
||||||
|
: result.status === "degraded"
|
||||||
|
? "health_check_degraded"
|
||||||
|
: "health_check_failed";
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: instance.agency_id,
|
||||||
|
actor_user_id: actorUserId,
|
||||||
|
target_type: "plesk_instance",
|
||||||
|
target_id: instance.id,
|
||||||
|
action,
|
||||||
|
status: result.status === "down" ? "failed" : "success",
|
||||||
|
error_message: result.error_message,
|
||||||
|
metadata: {
|
||||||
|
instance_id: instance.id,
|
||||||
|
lock_token: lockToken,
|
||||||
|
source,
|
||||||
|
latency_ms: result.latency_ms,
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
checked: true,
|
||||||
|
status: result.status,
|
||||||
|
latency_ms: result.latency_ms,
|
||||||
|
error_message: result.error_message,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message.slice(0, 1000)
|
||||||
|
: "health_check_failed";
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.update({
|
||||||
|
health_status: "down",
|
||||||
|
health_last_checked_at: nowIso,
|
||||||
|
health_last_error: errorMessage,
|
||||||
|
health_next_check_at: nextCheckAtIso,
|
||||||
|
})
|
||||||
|
.eq("id", instance.id);
|
||||||
|
|
||||||
|
await insertActionLogBestEffort(supabase, {
|
||||||
|
agency_id: instance.agency_id,
|
||||||
|
actor_user_id: actorUserId,
|
||||||
|
target_type: "plesk_instance",
|
||||||
|
target_id: instance.id,
|
||||||
|
action: "health_check_failed",
|
||||||
|
status: "failed",
|
||||||
|
error_message: errorMessage,
|
||||||
|
metadata: {
|
||||||
|
instance_id: instance.id,
|
||||||
|
lock_token: lockToken,
|
||||||
|
source,
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
checked: true,
|
||||||
|
status: "down",
|
||||||
|
latency_ms: Date.now() - startedAt,
|
||||||
|
error_message: errorMessage,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await supabase.rpc("release_instance_health_lock", {
|
||||||
|
p_instance_id: instance.id,
|
||||||
|
p_lock_token: lockToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHealthCheckTick(
|
||||||
|
supabase: SupabaseLike,
|
||||||
|
): Promise<HealthTickSummary> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const { data: rows, error } = await supabase
|
||||||
|
.from("plesk_instances")
|
||||||
|
.select(
|
||||||
|
"id, agency_id, base_url, auth_type, encrypted_secret, health_enabled, health_check_frequency_minutes, health_next_check_at, health_lock_until",
|
||||||
|
)
|
||||||
|
.eq("health_enabled", true)
|
||||||
|
.order("health_next_check_at", { ascending: true, nullsFirst: true })
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligible = ((rows ?? []) as HealthInstance[])
|
||||||
|
.filter((instance) => isDue(instance, now) && isUnlocked(instance, now))
|
||||||
|
.slice(0, MAX_INSTANCES_PER_TICK);
|
||||||
|
|
||||||
|
let checked = 0;
|
||||||
|
let ok = 0;
|
||||||
|
let degraded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const queue = [...eligible];
|
||||||
|
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(MAX_CONCURRENCY, queue.length) },
|
||||||
|
async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const instance = queue.shift();
|
||||||
|
if (!instance) break;
|
||||||
|
|
||||||
|
const result = await runInstanceHealthCheck(supabase, instance, {
|
||||||
|
actorUserId: null,
|
||||||
|
force: false,
|
||||||
|
source: "cron",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.checked || !result.status) continue;
|
||||||
|
|
||||||
|
checked += 1;
|
||||||
|
if (result.status === "ok") ok += 1;
|
||||||
|
else if (result.status === "degraded") degraded += 1;
|
||||||
|
else failed += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
checked,
|
||||||
|
ok,
|
||||||
|
degraded,
|
||||||
|
failed,
|
||||||
|
duration_ms: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,8 +22,34 @@ type InstanceRow = Pick<
|
|||||||
| "last_connected_at"
|
| "last_connected_at"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type LocalSubscriptionLookupRow = {
|
||||||
|
id: string;
|
||||||
|
plesk_subscription_id: string;
|
||||||
|
name: string | null;
|
||||||
|
status: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const MIN_INTERVAL_MS = 3 * 60 * 1000;
|
const MIN_INTERVAL_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
function normalizeDomainLike(value: string) {
|
||||||
|
return value.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestSuffixSubscriptionMatch<
|
||||||
|
T extends { normalizedName: string; id: string; status: string | null },
|
||||||
|
>(normalizedDomain: string, candidates: T[]) {
|
||||||
|
let best: T | undefined;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!normalizedDomain.endsWith(`.${candidate.normalizedName}`)) continue;
|
||||||
|
if (!best || candidate.normalizedName.length > best.normalizedName.length) {
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncPleskInstance(
|
export async function syncPleskInstance(
|
||||||
supabase: SupabaseLike,
|
supabase: SupabaseLike,
|
||||||
instance: InstanceRow,
|
instance: InstanceRow,
|
||||||
@@ -32,6 +58,31 @@ export async function syncPleskInstance(
|
|||||||
): Promise<SyncResult> {
|
): Promise<SyncResult> {
|
||||||
const startedAtIso = new Date().toISOString();
|
const startedAtIso = new Date().toISOString();
|
||||||
|
|
||||||
|
const logStatusSyncEvent = async (
|
||||||
|
eventAction:
|
||||||
|
| "subscription_status_sync_started"
|
||||||
|
| "subscription_status_sync_completed"
|
||||||
|
| "subscription_status_sync_failed",
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
status: "success" | "failed" = "success",
|
||||||
|
errorMessage?: string | null,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await supabase.from("actions_log").insert({
|
||||||
|
agency_id: instance.agency_id,
|
||||||
|
actor_user_id: actorUserId ?? null,
|
||||||
|
target_type: "plesk_instance",
|
||||||
|
target_id: instance.id,
|
||||||
|
action: eventAction,
|
||||||
|
status,
|
||||||
|
error_message: errorMessage ?? null,
|
||||||
|
metadata: metadata ?? {},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Logging should never block sync flow.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.from("plesk_instances")
|
.from("plesk_instances")
|
||||||
.update({
|
.update({
|
||||||
@@ -67,6 +118,10 @@ export async function syncPleskInstance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await logStatusSyncEvent("subscription_status_sync_started", {
|
||||||
|
started_at: startedAtIso,
|
||||||
|
});
|
||||||
|
|
||||||
const [subscriptionsRaw, domainsRaw] = await Promise.all([
|
const [subscriptionsRaw, domainsRaw] = await Promise.all([
|
||||||
listPleskSubscriptions({
|
listPleskSubscriptions({
|
||||||
baseUrl: instance.base_url,
|
baseUrl: instance.base_url,
|
||||||
@@ -82,14 +137,21 @@ export async function syncPleskInstance(
|
|||||||
|
|
||||||
const subscriptionsForUpsert = subscriptionsRaw
|
const subscriptionsForUpsert = subscriptionsRaw
|
||||||
.map((item: any) => {
|
.map((item: any) => {
|
||||||
const sourceId = item?.id ?? item?.guid ?? item?.subscription_id;
|
const sourceId =
|
||||||
|
item?.plesk_subscription_id ??
|
||||||
|
item?.name ??
|
||||||
|
item?.id ??
|
||||||
|
item?.guid ??
|
||||||
|
item?.subscription_id;
|
||||||
if (!sourceId) return null;
|
if (!sourceId) return null;
|
||||||
return {
|
return {
|
||||||
agency_id: instance.agency_id,
|
agency_id: instance.agency_id,
|
||||||
plesk_instance_id: instance.id,
|
plesk_instance_id: instance.id,
|
||||||
plesk_subscription_id: String(sourceId),
|
plesk_subscription_id: String(sourceId),
|
||||||
name: item?.name ? String(item.name) : null,
|
name: item?.name ? String(item.name) : String(sourceId),
|
||||||
status: item?.status ? String(item.status) : null,
|
status: item?.status ? String(item.status) : "unknown",
|
||||||
|
status_raw: item?.status_raw ? String(item.status_raw) : null,
|
||||||
|
last_status_sync_at: startedAtIso,
|
||||||
owner_login: item?.owner_login
|
owner_login: item?.owner_login
|
||||||
? String(item.owner_login)
|
? String(item.owner_login)
|
||||||
: item?.owner?.login
|
: item?.owner?.login
|
||||||
@@ -104,6 +166,10 @@ export async function syncPleskInstance(
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as Array<Record<string, unknown>>;
|
.filter(Boolean) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const subscriptionStatusSyncFailures = subscriptionsRaw.filter(
|
||||||
|
(item: any) => Boolean(item?.status_sync_error),
|
||||||
|
);
|
||||||
|
|
||||||
if (subscriptionsForUpsert.length > 0) {
|
if (subscriptionsForUpsert.length > 0) {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from("plesk_subscriptions")
|
.from("plesk_subscriptions")
|
||||||
@@ -113,12 +179,127 @@ export async function syncPleskInstance(
|
|||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data: localSubscriptions, error: localSubscriptionsError } =
|
||||||
|
await supabase
|
||||||
|
.from("plesk_subscriptions")
|
||||||
|
.select("id, plesk_subscription_id, name, status")
|
||||||
|
.eq("agency_id", instance.agency_id)
|
||||||
|
.eq("plesk_instance_id", instance.id);
|
||||||
|
|
||||||
|
if (localSubscriptionsError) {
|
||||||
|
throw new Error(localSubscriptionsError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionsIndexed = ((localSubscriptions ?? []) as Array<any>)
|
||||||
|
.filter((row): row is LocalSubscriptionLookupRow =>
|
||||||
|
Boolean(row?.plesk_subscription_id && row?.id),
|
||||||
|
)
|
||||||
|
.map((row) => ({
|
||||||
|
id: String(row.id),
|
||||||
|
pleskSubscriptionId: String(row.plesk_subscription_id),
|
||||||
|
status: row?.status ? String(row.status) : null,
|
||||||
|
name: row?.name ? String(row.name) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const subscriptionByExternalId = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
pleskSubscriptionId: string;
|
||||||
|
status: string | null;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
>(subscriptionsIndexed.map((row) => [row.pleskSubscriptionId, row]));
|
||||||
|
|
||||||
|
const subscriptionByLocalId = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
pleskSubscriptionId: string;
|
||||||
|
status: string | null;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
>(subscriptionsIndexed.map((row) => [row.id, row]));
|
||||||
|
|
||||||
|
const subscriptionByName = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
pleskSubscriptionId: string;
|
||||||
|
status: string | null;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
subscriptionsIndexed
|
||||||
|
.filter((row) => row.name)
|
||||||
|
.map((row) => [normalizeDomainLike(String(row.name)), row]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscriptionNameCandidates: Array<{
|
||||||
|
id: string;
|
||||||
|
pleskSubscriptionId: string;
|
||||||
|
status: string | null;
|
||||||
|
normalizedName: string;
|
||||||
|
}> = subscriptionsIndexed
|
||||||
|
.filter((row) => row.name)
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
pleskSubscriptionId: row.pleskSubscriptionId,
|
||||||
|
status: row.status,
|
||||||
|
normalizedName: normalizeDomainLike(String(row.name)),
|
||||||
|
}));
|
||||||
|
|
||||||
const domainsForUpsert = domainsRaw
|
const domainsForUpsert = domainsRaw
|
||||||
.map((item: any) => {
|
.map((item: any) => {
|
||||||
const domainId = item?.id ?? item?.guid ?? item?.name;
|
const domainId = item?.id ?? item?.guid ?? item?.name;
|
||||||
const domainName = item?.name ?? item?.domain_name ?? item?.domain;
|
const domainName = item?.name ?? item?.domain_name ?? item?.domain;
|
||||||
const externalSubscriptionId =
|
const normalizedDomainName = domainName
|
||||||
|
? normalizeDomainLike(String(domainName))
|
||||||
|
: null;
|
||||||
|
const subExternalId =
|
||||||
item?.subscription?.id ?? item?.subscription_id ?? item?.webspace_id;
|
item?.subscription?.id ?? item?.subscription_id ?? item?.webspace_id;
|
||||||
|
const rawExternalSubscriptionId = subExternalId
|
||||||
|
? String(subExternalId)
|
||||||
|
: null;
|
||||||
|
const subscriptionNameHint =
|
||||||
|
item?.subscription?.name ??
|
||||||
|
item?.subscription_name ??
|
||||||
|
item?.webspace_name ??
|
||||||
|
item?.webspace ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const localSubscriptionByExternal = rawExternalSubscriptionId
|
||||||
|
? subscriptionByExternalId.get(rawExternalSubscriptionId)
|
||||||
|
: undefined;
|
||||||
|
const localSubscriptionByLocalId = rawExternalSubscriptionId
|
||||||
|
? subscriptionByLocalId.get(rawExternalSubscriptionId)
|
||||||
|
: undefined;
|
||||||
|
const localSubscriptionByName = subscriptionNameHint
|
||||||
|
? subscriptionByName.get(
|
||||||
|
normalizeDomainLike(String(subscriptionNameHint)),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const localSubscriptionByDomainName = normalizedDomainName
|
||||||
|
? subscriptionByName.get(normalizedDomainName)
|
||||||
|
: undefined;
|
||||||
|
const localSubscriptionBySuffix = normalizedDomainName
|
||||||
|
? findBestSuffixSubscriptionMatch(
|
||||||
|
normalizedDomainName,
|
||||||
|
subscriptionNameCandidates,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const localSubscription =
|
||||||
|
localSubscriptionByExternal ??
|
||||||
|
localSubscriptionByLocalId ??
|
||||||
|
localSubscriptionByName ??
|
||||||
|
localSubscriptionByDomainName ??
|
||||||
|
localSubscriptionBySuffix;
|
||||||
|
|
||||||
|
const externalSubscriptionId = localSubscription
|
||||||
|
? localSubscription.pleskSubscriptionId
|
||||||
|
: subscriptionNameHint
|
||||||
|
? String(subscriptionNameHint)
|
||||||
|
: rawExternalSubscriptionId;
|
||||||
const rawCreated = item?.created_at ?? item?.created;
|
const rawCreated = item?.created_at ?? item?.created;
|
||||||
const sourceCreatedAt = rawCreated
|
const sourceCreatedAt = rawCreated
|
||||||
? new Date(String(rawCreated)).toISOString()
|
? new Date(String(rawCreated)).toISOString()
|
||||||
@@ -135,24 +316,22 @@ export async function syncPleskInstance(
|
|||||||
return {
|
return {
|
||||||
agency_id: instance.agency_id,
|
agency_id: instance.agency_id,
|
||||||
plesk_instance_id: instance.id,
|
plesk_instance_id: instance.id,
|
||||||
subscription_id: null,
|
subscription_id: localSubscription?.id ?? null,
|
||||||
external_subscription_id: externalSubscriptionId
|
external_subscription_id: externalSubscriptionId,
|
||||||
? String(externalSubscriptionId)
|
|
||||||
: null,
|
|
||||||
plesk_domain_id: String(domainId),
|
plesk_domain_id: String(domainId),
|
||||||
domain_name: String(domainName),
|
domain_name: String(domainName),
|
||||||
status: item?.status ? String(item.status) : null,
|
status: localSubscription?.status ?? "unknown",
|
||||||
hosting_type: item?.hosting_type
|
hosting_type: item?.hosting_type ? String(item.hosting_type) : null,
|
||||||
? String(item.hosting_type)
|
|
||||||
: item?.hosting?.type
|
|
||||||
? String(item.hosting.type)
|
|
||||||
: null,
|
|
||||||
source_created_at: sourceCreatedAt,
|
source_created_at: sourceCreatedAt,
|
||||||
aliases_count: aliasesCount,
|
aliases_count: aliasesCount,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean) as Array<Record<string, unknown>>;
|
.filter(Boolean) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const unlinkedDomains = domainsForUpsert.filter(
|
||||||
|
(domain) => !domain.subscription_id,
|
||||||
|
);
|
||||||
|
|
||||||
if (domainsForUpsert.length > 0) {
|
if (domainsForUpsert.length > 0) {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from("plesk_domains")
|
.from("plesk_domains")
|
||||||
@@ -187,9 +366,47 @@ export async function syncPleskInstance(
|
|||||||
metadata: {
|
metadata: {
|
||||||
subscriptions_seen: subscriptionsRaw.length,
|
subscriptions_seen: subscriptionsRaw.length,
|
||||||
domains_seen: domainsRaw.length,
|
domains_seen: domainsRaw.length,
|
||||||
|
unlinked_domains: unlinkedDomains.length,
|
||||||
|
unlinked_domain_samples: unlinkedDomains
|
||||||
|
.slice(0, 25)
|
||||||
|
.map((domain) => String(domain.domain_name ?? "unknown")),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await logStatusSyncEvent("subscription_status_sync_completed", {
|
||||||
|
synced_subscriptions: subscriptionsRaw.length,
|
||||||
|
failed_subscriptions: subscriptionStatusSyncFailures.length,
|
||||||
|
failed_subscription_ids: subscriptionStatusSyncFailures
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((item: any) =>
|
||||||
|
String(
|
||||||
|
item?.plesk_subscription_id ?? item?.subscription_id ?? "unknown",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
completed_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscriptionStatusSyncFailures.length > 0) {
|
||||||
|
await logStatusSyncEvent(
|
||||||
|
"subscription_status_sync_failed",
|
||||||
|
{
|
||||||
|
failed_subscriptions: subscriptionStatusSyncFailures.length,
|
||||||
|
sample_errors: subscriptionStatusSyncFailures
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((item: any) => ({
|
||||||
|
subscription: String(
|
||||||
|
item?.plesk_subscription_id ??
|
||||||
|
item?.subscription_id ??
|
||||||
|
"unknown",
|
||||||
|
),
|
||||||
|
error: String(item?.status_sync_error ?? "status_sync_failed"),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
"failed",
|
||||||
|
`${subscriptionStatusSyncFailures.length} subscription status sync failures`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
instanceId: instance.id,
|
instanceId: instance.id,
|
||||||
agencyId: instance.agency_id,
|
agencyId: instance.agency_id,
|
||||||
@@ -201,6 +418,15 @@ export async function syncPleskInstance(
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message.slice(0, 1000) : "Sync failed";
|
error instanceof Error ? error.message.slice(0, 1000) : "Sync failed";
|
||||||
|
|
||||||
|
await logStatusSyncEvent(
|
||||||
|
"subscription_status_sync_failed",
|
||||||
|
{
|
||||||
|
failed_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
"failed",
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.from("plesk_instances")
|
.from("plesk_instances")
|
||||||
.update({
|
.update({
|
||||||
@@ -216,3 +442,146 @@ export async function syncPleskInstance(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function backfillDomainSubscriptionLinks(
|
||||||
|
supabase: SupabaseLike,
|
||||||
|
input: {
|
||||||
|
agencyId: string;
|
||||||
|
instanceId: string;
|
||||||
|
actorUserId?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { agencyId, instanceId, actorUserId } = input;
|
||||||
|
|
||||||
|
const { data: subscriptions, error: subscriptionsError } = await supabase
|
||||||
|
.from("plesk_subscriptions")
|
||||||
|
.select("id, plesk_subscription_id, name, status")
|
||||||
|
.eq("agency_id", agencyId)
|
||||||
|
.eq("plesk_instance_id", instanceId);
|
||||||
|
|
||||||
|
if (subscriptionsError) {
|
||||||
|
throw new Error(subscriptionsError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: domains, error: domainsError } = await supabase
|
||||||
|
.from("plesk_domains")
|
||||||
|
.select("id, domain_name, external_subscription_id, subscription_id")
|
||||||
|
.eq("agency_id", agencyId)
|
||||||
|
.eq("plesk_instance_id", instanceId)
|
||||||
|
.is("subscription_id", null);
|
||||||
|
|
||||||
|
if (domainsError) {
|
||||||
|
throw new Error(domainsError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionByExternalId = new Map<
|
||||||
|
string,
|
||||||
|
{ id: string; status: string }
|
||||||
|
>(
|
||||||
|
(subscriptions ?? [])
|
||||||
|
.filter((subscription: any) => subscription?.plesk_subscription_id)
|
||||||
|
.map((subscription: any) => [
|
||||||
|
String(subscription.plesk_subscription_id),
|
||||||
|
{
|
||||||
|
id: String(subscription.id),
|
||||||
|
status: subscription?.status
|
||||||
|
? String(subscription.status)
|
||||||
|
: "unknown",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscriptionByName = new Map<string, { id: string; status: string }>(
|
||||||
|
(subscriptions ?? [])
|
||||||
|
.filter((subscription: any) => subscription?.name)
|
||||||
|
.map((subscription: any) => [
|
||||||
|
normalizeDomainLike(String(subscription.name)),
|
||||||
|
{
|
||||||
|
id: String(subscription.id),
|
||||||
|
status: subscription?.status
|
||||||
|
? String(subscription.status)
|
||||||
|
: "unknown",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscriptionNameCandidates: Array<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
normalizedName: string;
|
||||||
|
}> = (subscriptions ?? [])
|
||||||
|
.filter((subscription: any) => subscription?.name)
|
||||||
|
.map((subscription: any) => ({
|
||||||
|
id: String(subscription.id),
|
||||||
|
status: subscription?.status ? String(subscription.status) : "unknown",
|
||||||
|
normalizedName: normalizeDomainLike(String(subscription.name)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let linkedCount = 0;
|
||||||
|
const failures: Array<{ domain_id: string; error: string }> = [];
|
||||||
|
|
||||||
|
for (const domain of domains ?? []) {
|
||||||
|
const matchByExternal = domain?.external_subscription_id
|
||||||
|
? subscriptionByExternalId.get(String(domain.external_subscription_id))
|
||||||
|
: undefined;
|
||||||
|
const matchByDomainName = domain?.domain_name
|
||||||
|
? subscriptionByName.get(normalizeDomainLike(String(domain.domain_name)))
|
||||||
|
: undefined;
|
||||||
|
const normalizedDomainName = domain?.domain_name
|
||||||
|
? normalizeDomainLike(String(domain.domain_name))
|
||||||
|
: null;
|
||||||
|
const matchBySuffix = normalizedDomainName
|
||||||
|
? findBestSuffixSubscriptionMatch(
|
||||||
|
normalizedDomainName,
|
||||||
|
subscriptionNameCandidates,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const linkedSubscription =
|
||||||
|
matchByExternal ?? matchByDomainName ?? matchBySuffix;
|
||||||
|
|
||||||
|
if (!linkedSubscription) continue;
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("plesk_domains")
|
||||||
|
.update({
|
||||||
|
subscription_id: linkedSubscription.id,
|
||||||
|
status: linkedSubscription.status,
|
||||||
|
})
|
||||||
|
.eq("id", domain.id)
|
||||||
|
.eq("agency_id", agencyId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
failures.push({
|
||||||
|
domain_id: String(domain.id),
|
||||||
|
error: updateError.message,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
linkedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase.from("actions_log").insert({
|
||||||
|
agency_id: agencyId,
|
||||||
|
actor_user_id: actorUserId ?? null,
|
||||||
|
target_type: "plesk_instance",
|
||||||
|
target_id: instanceId,
|
||||||
|
action: "domain_subscription_backfill",
|
||||||
|
status: failures.length > 0 ? "failed" : "success",
|
||||||
|
error_message:
|
||||||
|
failures.length > 0
|
||||||
|
? `${failures.length} domain links failed during backfill`
|
||||||
|
: null,
|
||||||
|
metadata: {
|
||||||
|
attempted: (domains ?? []).length,
|
||||||
|
linked: linkedCount,
|
||||||
|
failures: failures.slice(0, 25),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
attempted: (domains ?? []).length,
|
||||||
|
linked: linkedCount,
|
||||||
|
failed: failures.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
48
lib/types.ts
48
lib/types.ts
@@ -83,6 +83,22 @@ export type Database = {
|
|||||||
last_sync_error: string | null;
|
last_sync_error: string | null;
|
||||||
last_sync_subscriptions: number;
|
last_sync_subscriptions: number;
|
||||||
last_sync_domains: number;
|
last_sync_domains: number;
|
||||||
|
auto_sync_enabled: boolean;
|
||||||
|
auto_sync_frequency_minutes: number;
|
||||||
|
last_auto_sync_at: string | null;
|
||||||
|
next_auto_sync_at: string | null;
|
||||||
|
auto_sync_lock_until: string | null;
|
||||||
|
auto_sync_lock_token: string | null;
|
||||||
|
health_enabled: boolean;
|
||||||
|
health_status: "ok" | "degraded" | "down" | "unknown";
|
||||||
|
health_last_checked_at: string | null;
|
||||||
|
health_last_ok_at: string | null;
|
||||||
|
health_last_error: string | null;
|
||||||
|
health_last_latency_ms: number | null;
|
||||||
|
health_check_frequency_minutes: number;
|
||||||
|
health_next_check_at: string | null;
|
||||||
|
health_lock_until: string | null;
|
||||||
|
health_lock_token: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
@@ -101,6 +117,22 @@ export type Database = {
|
|||||||
last_sync_error?: string | null;
|
last_sync_error?: string | null;
|
||||||
last_sync_subscriptions?: number;
|
last_sync_subscriptions?: number;
|
||||||
last_sync_domains?: number;
|
last_sync_domains?: number;
|
||||||
|
auto_sync_enabled?: boolean;
|
||||||
|
auto_sync_frequency_minutes?: number;
|
||||||
|
last_auto_sync_at?: string | null;
|
||||||
|
next_auto_sync_at?: string | null;
|
||||||
|
auto_sync_lock_until?: string | null;
|
||||||
|
auto_sync_lock_token?: string | null;
|
||||||
|
health_enabled?: boolean;
|
||||||
|
health_status?: "ok" | "degraded" | "down" | "unknown";
|
||||||
|
health_last_checked_at?: string | null;
|
||||||
|
health_last_ok_at?: string | null;
|
||||||
|
health_last_error?: string | null;
|
||||||
|
health_last_latency_ms?: number | null;
|
||||||
|
health_check_frequency_minutes?: number;
|
||||||
|
health_next_check_at?: string | null;
|
||||||
|
health_lock_until?: string | null;
|
||||||
|
health_lock_token?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
@@ -119,6 +151,22 @@ export type Database = {
|
|||||||
last_sync_error?: string | null;
|
last_sync_error?: string | null;
|
||||||
last_sync_subscriptions?: number;
|
last_sync_subscriptions?: number;
|
||||||
last_sync_domains?: number;
|
last_sync_domains?: number;
|
||||||
|
auto_sync_enabled?: boolean;
|
||||||
|
auto_sync_frequency_minutes?: number;
|
||||||
|
last_auto_sync_at?: string | null;
|
||||||
|
next_auto_sync_at?: string | null;
|
||||||
|
auto_sync_lock_until?: string | null;
|
||||||
|
auto_sync_lock_token?: string | null;
|
||||||
|
health_enabled?: boolean;
|
||||||
|
health_status?: "ok" | "degraded" | "down" | "unknown";
|
||||||
|
health_last_checked_at?: string | null;
|
||||||
|
health_last_ok_at?: string | null;
|
||||||
|
health_last_error?: string | null;
|
||||||
|
health_last_latency_ms?: number | null;
|
||||||
|
health_check_frequency_minutes?: number;
|
||||||
|
health_next_check_at?: string | null;
|
||||||
|
health_lock_until?: string | null;
|
||||||
|
health_lock_token?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
30
scripts/deploy-prod.sh
Executable file
30
scripts/deploy-prod.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
|
||||||
|
APP_IMAGE="${APP_IMAGE:-plesk-agency-portal:latest}"
|
||||||
|
SOURCE_PATH="${SOURCE_PATH:-${DEPLOY_PATH}}"
|
||||||
|
|
||||||
|
mkdir -p "${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
for file in docker-compose.prod.yml deploy-prod.sh smoke-check.sh rollback-prod.sh; do
|
||||||
|
if [ -f "${SOURCE_PATH}/${file}" ]; then
|
||||||
|
cp "${SOURCE_PATH}/${file}" "${DEPLOY_PATH}/${file}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
chmod +x "${DEPLOY_PATH}/deploy-prod.sh" \
|
||||||
|
"${DEPLOY_PATH}/smoke-check.sh" \
|
||||||
|
"${DEPLOY_PATH}/rollback-prod.sh" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
|
||||||
|
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
APP_IMAGE="${APP_IMAGE}" docker compose -f docker-compose.prod.yml up -d --no-build
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
30
scripts/rollback-prod.sh
Executable file
30
scripts/rollback-prod.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PREVIOUS_IMAGE_TAG="${1:-}"
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
|
||||||
|
APP_BASE_URL="${APP_BASE_URL:-}"
|
||||||
|
|
||||||
|
if [ -z "${PREVIOUS_IMAGE_TAG}" ]; then
|
||||||
|
echo "Usage: $0 <previous-image-tag>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${APP_BASE_URL}" ]; then
|
||||||
|
echo "APP_BASE_URL is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
|
||||||
|
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
APP_IMAGE="${PREVIOUS_IMAGE_TAG}" docker compose -f docker-compose.prod.yml up -d --no-build
|
||||||
|
|
||||||
|
"${DEPLOY_PATH}/smoke-check.sh" "${APP_BASE_URL}"
|
||||||
|
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
27
scripts/smoke-check.sh
Executable file
27
scripts/smoke-check.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${1:-}"
|
||||||
|
|
||||||
|
if [ -z "${BASE_URL}" ]; then
|
||||||
|
echo "Usage: $0 <base-url>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
URL="${BASE_URL%/}/api/health"
|
||||||
|
TMP_FILE="$(mktemp)"
|
||||||
|
|
||||||
|
trap 'rm -f "${TMP_FILE}"' EXIT
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl -sS -o "${TMP_FILE}" -w "%{http_code}" "${URL}")
|
||||||
|
|
||||||
|
if [ "${HTTP_CODE}" != "200" ]; then
|
||||||
|
echo "Smoke check failed: ${URL} returned ${HTTP_CODE}" >&2
|
||||||
|
cat "${TMP_FILE}" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
node -e "const fs=require('fs');const d=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));if(d?.ok!==true||typeof d?.service!=='string'){process.exit(1)}" "${TMP_FILE}"
|
||||||
|
|
||||||
|
echo "Smoke check passed: ${URL}"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- One-time backfill: align existing domain status with linked subscription status
|
||||||
|
update public.plesk_domains d
|
||||||
|
set status = coalesce(s.status, 'unknown')
|
||||||
|
from public.plesk_subscriptions s
|
||||||
|
where d.subscription_id = s.id
|
||||||
|
and d.agency_id = s.agency_id;
|
||||||
|
|
||||||
|
update public.plesk_subscriptions
|
||||||
|
set status = 'unknown'
|
||||||
|
where status is null;
|
||||||
78
supabase/migrations/CL38_instance_health_monitoring.sql
Normal file
78
supabase/migrations/CL38_instance_health_monitoring.sql
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
-- CL8: instance health monitoring fields + lock helpers
|
||||||
|
|
||||||
|
alter table public.plesk_instances
|
||||||
|
add column if not exists health_enabled boolean not null default true,
|
||||||
|
add column if not exists health_status text not null default 'unknown',
|
||||||
|
add column if not exists health_last_checked_at timestamptz,
|
||||||
|
add column if not exists health_last_ok_at timestamptz,
|
||||||
|
add column if not exists health_last_error text,
|
||||||
|
add column if not exists health_last_latency_ms integer,
|
||||||
|
add column if not exists health_check_frequency_minutes integer not null default 30,
|
||||||
|
add column if not exists health_next_check_at timestamptz,
|
||||||
|
add column if not exists health_lock_until timestamptz,
|
||||||
|
add column if not exists health_lock_token text;
|
||||||
|
|
||||||
|
alter table public.plesk_instances
|
||||||
|
drop constraint if exists plesk_instances_health_status_check;
|
||||||
|
|
||||||
|
alter table public.plesk_instances
|
||||||
|
add constraint plesk_instances_health_status_check
|
||||||
|
check (health_status in ('ok', 'degraded', 'down', 'unknown'));
|
||||||
|
|
||||||
|
alter table public.plesk_instances
|
||||||
|
drop constraint if exists plesk_instances_health_check_frequency_minutes_check;
|
||||||
|
|
||||||
|
alter table public.plesk_instances
|
||||||
|
add constraint plesk_instances_health_check_frequency_minutes_check
|
||||||
|
check (health_check_frequency_minutes between 5 and 10080);
|
||||||
|
|
||||||
|
create index if not exists idx_plesk_instances_health_due
|
||||||
|
on public.plesk_instances (health_enabled, health_next_check_at);
|
||||||
|
|
||||||
|
create or replace function public.acquire_instance_health_lock(
|
||||||
|
p_instance_id uuid,
|
||||||
|
p_lock_token text,
|
||||||
|
p_ttl_seconds integer default 300,
|
||||||
|
p_force boolean default false
|
||||||
|
)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_acquired boolean := false;
|
||||||
|
begin
|
||||||
|
update public.plesk_instances
|
||||||
|
set
|
||||||
|
health_lock_until = now() + make_interval(secs => p_ttl_seconds),
|
||||||
|
health_lock_token = p_lock_token
|
||||||
|
where id = p_instance_id
|
||||||
|
and health_enabled = true
|
||||||
|
and (
|
||||||
|
p_force = true
|
||||||
|
or health_next_check_at is null
|
||||||
|
or health_next_check_at <= now()
|
||||||
|
)
|
||||||
|
and (health_lock_until is null or health_lock_until < now());
|
||||||
|
|
||||||
|
get diagnostics v_acquired = row_count;
|
||||||
|
return v_acquired;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.release_instance_health_lock(
|
||||||
|
p_instance_id uuid,
|
||||||
|
p_lock_token text
|
||||||
|
)
|
||||||
|
returns void
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
update public.plesk_instances
|
||||||
|
set health_lock_until = null,
|
||||||
|
health_lock_token = null
|
||||||
|
where id = p_instance_id
|
||||||
|
and health_lock_token = p_lock_token;
|
||||||
|
$$;
|
||||||
8
supabase/migrations/CL4_subscription_status_fields.sql
Normal file
8
supabase/migrations/CL4_subscription_status_fields.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
alter table public.plesk_subscriptions
|
||||||
|
add column if not exists status text default 'unknown',
|
||||||
|
add column if not exists status_raw text,
|
||||||
|
add column if not exists last_status_sync_at timestamptz;
|
||||||
|
|
||||||
|
update public.plesk_subscriptions
|
||||||
|
set status = 'unknown'
|
||||||
|
where status is null;
|
||||||
62
supabase/migrations/CL6_auto_sync_worker.sql
Normal file
62
supabase/migrations/CL6_auto_sync_worker.sql
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
-- CL6: auto-sync worker scheduling fields + per-instance lock helpers
|
||||||
|
|
||||||
|
alter table public.plesk_instances
|
||||||
|
add column if not exists auto_sync_enabled boolean not null default false,
|
||||||
|
add column if not exists auto_sync_frequency_minutes integer not null default 60,
|
||||||
|
add column if not exists last_auto_sync_at timestamptz,
|
||||||
|
add column if not exists next_auto_sync_at timestamptz,
|
||||||
|
add column if not exists auto_sync_lock_until timestamptz,
|
||||||
|
add column if not exists auto_sync_lock_token text;
|
||||||
|
|
||||||
|
alter table public.plesk_instances
|
||||||
|
drop constraint if exists plesk_instances_auto_sync_frequency_minutes_check;
|
||||||
|
|
||||||
|
alter table public.plesk_instances
|
||||||
|
add constraint plesk_instances_auto_sync_frequency_minutes_check
|
||||||
|
check (auto_sync_frequency_minutes between 5 and 10080);
|
||||||
|
|
||||||
|
create index if not exists idx_plesk_instances_auto_sync_due
|
||||||
|
on public.plesk_instances (auto_sync_enabled, next_auto_sync_at);
|
||||||
|
|
||||||
|
create or replace function public.acquire_instance_auto_sync_lock(
|
||||||
|
p_instance_id uuid,
|
||||||
|
p_lock_token text,
|
||||||
|
p_ttl_seconds integer default 900
|
||||||
|
)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_acquired boolean := false;
|
||||||
|
begin
|
||||||
|
update public.plesk_instances
|
||||||
|
set
|
||||||
|
auto_sync_lock_until = now() + make_interval(secs => p_ttl_seconds),
|
||||||
|
auto_sync_lock_token = p_lock_token
|
||||||
|
where id = p_instance_id
|
||||||
|
and auto_sync_enabled = true
|
||||||
|
and (next_auto_sync_at is null or next_auto_sync_at <= now())
|
||||||
|
and (auto_sync_lock_until is null or auto_sync_lock_until < now());
|
||||||
|
|
||||||
|
get diagnostics v_acquired = row_count;
|
||||||
|
return v_acquired;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.release_instance_auto_sync_lock(
|
||||||
|
p_instance_id uuid,
|
||||||
|
p_lock_token text
|
||||||
|
)
|
||||||
|
returns void
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
update public.plesk_instances
|
||||||
|
set auto_sync_lock_until = null,
|
||||||
|
auto_sync_lock_token = null
|
||||||
|
where id = p_instance_id
|
||||||
|
and auto_sync_lock_token = p_lock_token;
|
||||||
|
$$;
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user