Compare commits

..

1 Commits

Author SHA1 Message Date
e5f9da6c26 feat(ui): add sortable column headers to domains table 2026-03-06 06:58:29 +00:00
31 changed files with 290 additions and 1403 deletions

View File

@@ -1,36 +0,0 @@
# Project AI Rules
This repository contains the Plesk Agency Portal SaaS project.
Before making changes, read the following documentation files:
docs/ai-bootstrap.md
docs/ai-project-context.md
docs/system-architecture.md
docs/GUARDRAILS.md
docs/next-steps.md
docs/handovers/latest.md
docs/current-focus.md
Project guidelines:
- Treat master branch as production-ready.
- Preserve the working Jenkins CI/CD deployment pipeline.
- Avoid breaking Supabase Row Level Security policies.
- Prefer minimal changes over large rewrites.
- Follow existing Next.js App Router structure.
Deployment architecture:
Jenkins builds Docker image.
Image transferred using:
docker save → scp → docker load
Application host path:
/opt/plesk-agency-portal
Public domain:
https://portal.rdbcloud.co.uk
Supabase domain
https://supabase.rdbcloud.co.uk

View File

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

View File

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

View File

@@ -1,6 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-explicit-any": "off"
}
"extends": ["next/core-web-vitals", "next/typescript"]
}

View File

@@ -1,45 +0,0 @@
# AI Start Here
Before making changes, read these files in order:
1. docs/ai-project-context.md
2. docs/system-architecture.md
3. docs/next-steps.md
4. docs/handovers/latest.md (or the newest dated handover file)
## Project
Plesk Agency Portal
## Rules
- Make minimal safe changes
- Do not weaken Supabase RLS
- Preserve working deployment pipeline
- Prefer extending existing patterns over rewrites
- Treat `master` as production-ready
## Current Focus
- Fix Supabase HTTPS/public URL
- Validate auth flow
- Keep CI/CD stable
## Deployment Summary
- Jenkins builds Docker image
- Image transferred via `docker save -> scp -> docker load`
- App runs on `/opt/plesk-agency-portal`
- Public app URL: `https://portal.rdbcloud.co.uk`
- supabase URL: `https://supabase.rdbcloud.co.uk`
## Notes
Use the docs folder as source of truth for current state and handover history.
## When updating the project
- update docs/ai-project-context.md for current state
- add a new file in docs/handovers/
- update docs/next-steps.md
- keep this file short

View File

@@ -1,49 +0,0 @@
FROM node:20-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-bookworm-slim AS builder
WORKDIR /app
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG SUPABASE_URL
ARG NEXT_PUBLIC_APP_URL
ARG BUILD_CACHE_BUSTER
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
ENV SUPABASE_URL=${SUPABASE_URL}
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
ENV BUILD_CACHE_BUSTER=${BUILD_CACHE_BUSTER}
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ARG NEXT_PUBLIC_SUPABASE_URL
ARG SUPABASE_URL
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
ENV SUPABASE_URL=${SUPABASE_URL}
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
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"]

179
Jenkinsfile vendored
View File

@@ -1,179 +0,0 @@
pipeline {
// Build + deploy stages require Docker CLI/daemon access on the Jenkins node.
// Ensure this label maps to an agent with Docker installed and usable.
//update
agent any
environment {
REGISTRY_IMAGE = "plesk-agency-portal"
IMAGE_LATEST = "${REGISTRY_IMAGE}:latest"
IMAGE_BUILD = "${REGISTRY_IMAGE}:build-${BUILD_NUMBER}"
DEPLOY_SSH_CREDENTIAL_ID = "${env.DEPLOY_SSH_CREDENTIAL_ID ?: 'plesk-agency-deploy-key'}"
APP_HOST = "${env.APP_HOST ?: '192.168.68.89'}"
APP_USER = "${env.APP_USER ?: 'deploy'}"
DEPLOY_PATH = "${env.DEPLOY_PATH ?: '/opt/plesk-agency-portal'}"
APP_BASE_URL = "${env.APP_BASE_URL ?: 'https://portal.rdbcloud.co.uk'}"
NEXT_PUBLIC_APP_URL = "${env.NEXT_PUBLIC_APP_URL ?: 'https://portal.rdbcloud.co.uk'}"
}
stages {
// stage('Preflight') {
// steps {
// sh '''
// set -euo pipefail
// if ! command -v docker >/dev/null 2>&1; then
// echo "Docker CLI is not available on this Jenkins agent."
// echo "Run this pipeline on a Docker-capable node or install Docker on the current agent."
// exit 1
// fi
// if ! docker version >/dev/null 2>&1; then
// echo "Docker is installed but not usable by Jenkins (daemon/socket/permissions issue)."
// exit 1
// fi
// '''
// }
// }
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 {
withCredentials([
string(credentialsId: 'supabase-public-url', variable: 'SUPABASE_PUBLIC_URL'),
string(credentialsId: 'supabase-public-anon-key', variable: 'NEXT_PUBLIC_SUPABASE_ANON_KEY')
]) {
sh '''
ANON_PREFIX="$(printf '%s' "$NEXT_PUBLIC_SUPABASE_ANON_KEY" | cut -c1-6)"
echo "ENV DEBUG:"
echo "NEXT_PUBLIC_SUPABASE_URL=$SUPABASE_PUBLIC_URL"
echo "NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL"
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${ANON_PREFIX}***"
echo "SUPABASE_URL=$SUPABASE_PUBLIC_URL"
NEXT_PUBLIC_SUPABASE_URL="$SUPABASE_PUBLIC_URL" \
SUPABASE_URL="$SUPABASE_PUBLIC_URL" \
NEXT_PUBLIC_APP_URL="$NEXT_PUBLIC_APP_URL" \
npm run build
'''
}
}
}
stage('Build Docker image') {
steps {
withCredentials([
string(credentialsId: 'supabase-public-url', variable: 'SUPABASE_PUBLIC_URL'),
string(credentialsId: 'supabase-public-anon-key', variable: 'NEXT_PUBLIC_SUPABASE_ANON_KEY')
]) {
sh 'docker build --build-arg NEXT_PUBLIC_SUPABASE_URL="$SUPABASE_PUBLIC_URL" --build-arg SUPABASE_URL="$SUPABASE_PUBLIC_URL" --build-arg NEXT_PUBLIC_APP_URL="$NEXT_PUBLIC_APP_URL" --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="$NEXT_PUBLIC_SUPABASE_ANON_KEY" --build-arg BUILD_CACHE_BUSTER="${BUILD_NUMBER}" -t ${IMAGE_LATEST} -t ${IMAGE_BUILD} .'
}
}
}
stage('Deploy') {
steps {
sh '''
bash <<'EOF'
set -euxo pipefail
SSH_KEY="/var/lib/jenkins/.ssh/plesk_agency_deploy"
SSH_USER="deploy"
SSH_COMMON_OPTS="-i ${SSH_KEY} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
SSH_OPTS="-n ${SSH_COMMON_OPTS}"
RSYNC_SSH="ssh ${SSH_COMMON_OPTS}"
echo "SSH user from configured deploy key: ${SSH_USER}"
echo "SSH key file path: ${SSH_KEY}"
ls -l "${SSH_KEY}"
ssh-keygen -y -f "${SSH_KEY}"
echo "Testing remote SSH connectivity"
ssh -n ${SSH_OPTS} "${SSH_USER}@${APP_HOST}" "whoami && hostname && pwd"
echo "Deploying image tag: ${IMAGE_BUILD}"
docker image inspect "${IMAGE_BUILD}" --format='{{.Id}} {{.RepoTags}}'
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "mkdir -p $DEPLOY_PATH/scripts"
rsync -az \
-e "${RSYNC_SSH}" \
docker-compose.prod.yml \
"$SSH_USER@$APP_HOST:$DEPLOY_PATH/"
rsync -az \
-e "${RSYNC_SSH}" \
scripts/ \
"$SSH_USER@$APP_HOST:$DEPLOY_PATH/scripts/"
IMAGE_TAR="plesk-agency-portal-build.tar"
echo "Creating image archive: ${IMAGE_TAR}"
docker save -o "${IMAGE_TAR}" "${IMAGE_BUILD}"
ls -lh "${IMAGE_TAR}"
sha256sum "${IMAGE_TAR}"
echo "Copying image archive to remote host via scp"
scp ${SSH_COMMON_OPTS} "${IMAGE_TAR}" "$SSH_USER@$APP_HOST:$DEPLOY_PATH/"
echo "Verifying image archive exists on remote host"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "ls -lh $DEPLOY_PATH/plesk-agency-portal-build.tar"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "sha256sum $DEPLOY_PATH/plesk-agency-portal-build.tar"
echo "Loading image archive on remote host"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "docker load -i $DEPLOY_PATH/plesk-agency-portal-build.tar"
echo "Verifying exact image tag is available on remote host"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "docker image inspect ${IMAGE_BUILD} --format='{{.Id}} {{.RepoTags}}'"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" \
"cd $DEPLOY_PATH && chmod +x ./scripts/deploy-prod.sh ./scripts/smoke-check.sh ./scripts/rollback-prod.sh"
echo "Running remote deploy script with exact image tag"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" \
"cd $DEPLOY_PATH && APP_IMAGE='${IMAGE_BUILD}' ./scripts/deploy-prod.sh"
echo "Printing final running container image metadata"
ssh -n ${SSH_OPTS} "$SSH_USER@$APP_HOST" "docker inspect plesk-agency-portal --format='{{.Image}} {{.Created}}'"
EOF
'''
}
}
stage('Smoke check') {
steps {
sh '''
bash -eo pipefail << EOF
set -euo pipefail
chmod +x scripts/smoke-check.sh
scripts/smoke-check.sh "${APP_BASE_URL}"
EOF
'''
}
}
}
}

View File

@@ -9,7 +9,7 @@ export async function POST(req: Request) {
try {
const secret = req.headers.get("x-cron-secret");
if (!env.cronSharedSecret || !secret || secret !== env.cronSharedSecret) {
if (!env.cronSecret || !secret || secret !== env.cronSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -9,7 +9,7 @@ export async function POST(req: Request) {
try {
const secret = req.headers.get("x-cron-secret");
if (!env.cronSharedSecret || !secret || secret !== env.cronSharedSecret) {
if (!env.cronSecret || !secret || secret !== env.cronSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

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

View File

@@ -15,7 +15,7 @@ export async function POST() {
}
const stripe = getStripeClient();
const supabase = createSupabaseRouteClient() as any;
const supabase = createSupabaseRouteClient();
const { data: billing, error: billingError } = await supabase
.from("billing_accounts")
@@ -42,10 +42,7 @@ export async function POST() {
};
if (billing?.id) {
await supabase
.from("billing_accounts")
.update(upsertPayload)
.eq("id", billing.id);
await supabase.from("billing_accounts").update(upsertPayload).eq("id", billing.id);
} else {
await supabase.from("billing_accounts").insert(upsertPayload);
}
@@ -65,12 +62,7 @@ export async function POST() {
return NextResponse.json({ url: session.url });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to create checkout session",
},
{ error: error instanceof Error ? error.message : "Failed to create checkout session" },
{ status: 400 },
);
}

View File

@@ -10,7 +10,7 @@ export async function POST() {
const context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
const supabase = createSupabaseRouteClient() as any;
const supabase = createSupabaseRouteClient();
const stripe = getStripeClient();
const { data: billing, error } = await supabase
@@ -32,12 +32,7 @@ export async function POST() {
return NextResponse.json({ url: session.url });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to open billing portal",
},
{ error: error instanceof Error ? error.message : "Failed to open billing portal" },
{ status: 400 },
);
}

View File

@@ -19,7 +19,7 @@ async function upsertBillingByCustomer(
current_period_end?: string | null;
},
) {
const supabase = createSupabaseAdminClient() as any;
const supabase = createSupabaseAdminClient();
const { data: existing } = await supabase
.from("billing_accounts")
@@ -28,33 +28,22 @@ async function upsertBillingByCustomer(
.maybeSingle();
if (existing?.id) {
await supabase
.from("billing_accounts")
.update(updates)
.eq("id", existing.id);
await supabase.from("billing_accounts").update(updates).eq("id", existing.id);
}
}
async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session,
) {
const agencyId =
typeof session.metadata?.agency_id === "string"
? session.metadata.agency_id
: null;
const customerId =
typeof session.customer === "string" ? session.customer : null;
async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) {
const agencyId = typeof session.metadata?.agency_id === "string" ? session.metadata.agency_id : null;
const customerId = typeof session.customer === "string" ? session.customer : null;
if (!agencyId || !customerId) return;
const supabase = createSupabaseAdminClient() as any;
const supabase = createSupabaseAdminClient();
const payload = {
agency_id: agencyId,
stripe_customer_id: customerId,
stripe_subscription_id:
typeof session.subscription === "string"
? (session.subscription as string)
: null,
typeof session.subscription === "string" ? (session.subscription as string) : null,
subscription_status: "active",
};
@@ -65,10 +54,7 @@ async function handleCheckoutSessionCompleted(
.maybeSingle();
if (existing?.id) {
await supabase
.from("billing_accounts")
.update(payload)
.eq("id", existing.id);
await supabase.from("billing_accounts").update(payload).eq("id", existing.id);
} else {
await supabase.from("billing_accounts").insert(payload);
}
@@ -76,14 +62,7 @@ async function handleCheckoutSessionCompleted(
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer?.id;
const currentPeriodEnd =
"current_period_end" in subscription
? (subscription as { current_period_end?: number | null })
.current_period_end
: null;
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
if (!customerId) return;
@@ -91,27 +70,20 @@ async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
stripe_subscription_id: subscription.id,
plan_id: subscription.items.data[0]?.price.id ?? null,
subscription_status: subscription.status,
current_period_end: toIsoOrNull(currentPeriodEnd),
current_period_end: toIsoOrNull(subscription.current_period_end),
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer?.id;
const currentPeriodEnd =
"current_period_end" in subscription
? (subscription as { current_period_end?: number | null })
.current_period_end
: null;
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
if (!customerId) return;
await upsertBillingByCustomer(customerId, {
stripe_subscription_id: subscription.id,
subscription_status: "canceled",
current_period_end: toIsoOrNull(currentPeriodEnd),
current_period_end: toIsoOrNull(subscription.current_period_end),
});
}
@@ -123,35 +95,22 @@ export async function POST(req: Request) {
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 },
);
return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 });
}
const stripe = getStripeClient();
const body = await req.text();
const event = stripe.webhooks.constructEvent(
body,
signature,
env.stripeWebhookSecret,
);
const event = stripe.webhooks.constructEvent(body, signature, env.stripeWebhookSecret);
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutSessionCompleted(
event.data.object as Stripe.Checkout.Session,
);
await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription,
);
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(
event.data.object as Stripe.Subscription,
);
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
default:
break;
@@ -160,10 +119,7 @@ export async function POST(req: Request) {
return NextResponse.json({ received: true });
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Webhook handling failed",
},
{ error: error instanceof Error ? error.message : "Webhook handling failed" },
{ status: 400 },
);
}

View File

@@ -2,18 +2,12 @@ import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { env } from "@/lib/env";
import type { Database } from "@/lib/types";
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
const nextParam = requestUrl.searchParams.get("next");
const next =
nextParam && nextParam.startsWith("/") && !nextParam.startsWith("//")
? nextParam
: "/dashboard";
const redirectOrigin = env.appOrigin || requestUrl.origin;
const next = requestUrl.searchParams.get("next") ?? "/dashboard";
if (code) {
const supabase = createRouteHandlerClient<Database>({ cookies });
@@ -21,16 +15,18 @@ export async function GET(request: Request) {
if (error) {
console.error(error);
return NextResponse.redirect(
new URL(`/login?authError=1`, redirectOrigin),
new URL(`/login?authError=1`, requestUrl.origin),
);
}
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.redirect(new URL(`/login?noUser=1`, redirectOrigin));
return NextResponse.redirect(
new URL(`/login?noUser=1`, requestUrl.origin),
);
}
}
return NextResponse.redirect(new URL(next, redirectOrigin));
return NextResponse.redirect(new URL(next, requestUrl.origin));
}

View File

@@ -4,8 +4,6 @@ import { getServerAgencyContextOrRedirect } from "@/lib/agency";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { formatDateTime } from "@/lib/dates";
export const dynamic = "force-dynamic";
const PAGE_SIZE = 50;
type SearchParams = {
@@ -90,11 +88,9 @@ export default async function ActivityPage({
.order("created_at", { ascending: false })
.limit(200);
const actionOptions: string[] = Array.from(
new Set<string>(
(recentForActions ?? []).map((entry: any) => String(entry.action)),
),
).sort((a, b) => a.localeCompare(b));
const actionOptions = Array.from(
new Set((recentForActions ?? []).map((entry: any) => String(entry.action))),
).sort();
const instanceNameById = new Map<string, string>(
(instances ?? []).map((instance: any) => [

View File

@@ -6,8 +6,6 @@ import type { Database } from "@/lib/types";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import Link from "next/link";
export const dynamic = "force-dynamic";
type AgencySummary = Pick<
Database["public"]["Tables"]["agencies"]["Row"],
"id" | "name"

View File

@@ -55,6 +55,15 @@ type Domain = {
updated_at: string;
};
type DomainSortKey =
| "domain"
| "status"
| "hosting"
| "created"
| "aliases"
| "last_seen"
| "actions";
type Props = {
instances: Instance[];
subscriptions: Subscription[];
@@ -198,6 +207,10 @@ export function DashboardControls({
);
const [domainSearch, setDomainSearch] = useState("");
const [domainStatus, setDomainStatus] = useState("all");
const [domainSort, setDomainSort] = useState<{
key: DomainSortKey;
direction: "asc" | "desc";
}>({ key: "domain", direction: "asc" });
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [isConnectPanelOpen, setIsConnectPanelOpen] = useState(!hasInstances);
@@ -295,6 +308,112 @@ export function DashboardControls({
return statusMatches && searchMatches;
});
const sortedDomains = filteredDomains
.map((domain, index) => ({ domain, index }))
.sort((a, b) => {
function compareNullable<T>(
left: T | null | undefined,
right: T | null | undefined,
compare: (leftValue: T, rightValue: T) => number,
) {
const leftMissing = left == null;
const rightMissing = right == null;
if (leftMissing && rightMissing) return 0;
if (leftMissing) return 1;
if (rightMissing) return -1;
return compare(left, right);
}
function compareStrings(
left: string | null | undefined,
right: string | null | undefined,
) {
return compareNullable(left, right, (l, r) =>
l.localeCompare(r, undefined, { sensitivity: "base" }),
);
}
function compareNumbers(
left: number | null | undefined,
right: number | null | undefined,
) {
return compareNullable(left, right, (l, r) => l - r);
}
function toTime(value: string | null | undefined) {
if (!value) return null;
const time = new Date(value).getTime();
return Number.isNaN(time) ? null : time;
}
const leftDomain = a.domain;
const rightDomain = b.domain;
let comparison = 0;
if (domainSort.key === "domain") {
comparison = compareStrings(
leftDomain.domain_name,
rightDomain.domain_name,
);
} else if (domainSort.key === "status") {
comparison = compareStrings(
formatDomainStatusLabel(leftDomain.status),
formatDomainStatusLabel(rightDomain.status),
);
} else if (domainSort.key === "hosting") {
comparison = compareStrings(
leftDomain.hosting_type,
rightDomain.hosting_type,
);
} else if (domainSort.key === "created") {
comparison = compareNumbers(
toTime(leftDomain.source_created_at),
toTime(rightDomain.source_created_at),
);
} else if (domainSort.key === "aliases") {
comparison = compareNumbers(
leftDomain.aliases_count,
rightDomain.aliases_count,
);
} else if (domainSort.key === "last_seen") {
comparison = compareNumbers(
toTime(leftDomain.updated_at),
toTime(rightDomain.updated_at),
);
} else if (domainSort.key === "actions") {
comparison = compareNumbers(
leftDomain.subscription_id ? 1 : 0,
rightDomain.subscription_id ? 1 : 0,
);
}
if (comparison === 0) {
comparison = a.index - b.index;
}
return domainSort.direction === "asc" ? comparison : comparison * -1;
})
.map((entry) => entry.domain);
function toggleDomainSort(key: DomainSortKey) {
setDomainSort((current) => {
if (current.key === key) {
return {
key,
direction: current.direction === "asc" ? "desc" : "asc",
};
}
return { key, direction: "asc" };
});
}
function getSortIndicator(key: DomainSortKey) {
if (domainSort.key !== key) return "↕";
return domainSort.direction === "asc" ? "↑" : "↓";
}
const instanceHealthSummary = instances.reduce(
(acc, instance) => {
const status = (instance.health_status ?? "unknown").toLowerCase();
@@ -1094,24 +1213,143 @@ export function DashboardControls({
<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-2 py-2">Domain</th>
<th className="px-2 py-2">Subscription Status</th>
<th className="px-2 py-2">Hosting</th>
<th className="px-2 py-2">Created</th>
<th className="px-2 py-2">Aliases</th>
<th className="px-2 py-2">Last seen</th>
<th className="px-2 py-2">Actions</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("domain")}
className="inline-flex items-center gap-1"
>
Domain
<span
className={
domainSort.key === "domain"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("domain")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("status")}
className="inline-flex items-center gap-1"
>
Subscription Status
<span
className={
domainSort.key === "status"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("status")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("hosting")}
className="inline-flex items-center gap-1"
>
Hosting
<span
className={
domainSort.key === "hosting"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("hosting")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("created")}
className="inline-flex items-center gap-1"
>
Created
<span
className={
domainSort.key === "created"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("created")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("aliases")}
className="inline-flex items-center gap-1"
>
Aliases
<span
className={
domainSort.key === "aliases"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("aliases")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("last_seen")}
className="inline-flex items-center gap-1"
>
Last seen
<span
className={
domainSort.key === "last_seen"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("last_seen")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("actions")}
className="inline-flex items-center gap-1"
>
Actions
<span
className={
domainSort.key === "actions"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("actions")}
</span>
</button>
</th>
</tr>
</thead>
<tbody>
{filteredDomains.length === 0 ? (
{sortedDomains.length === 0 ? (
<tr>
<td className="px-2 py-3 text-slate-500" colSpan={7}>
No domains found.
</td>
</tr>
) : (
filteredDomains.map((domain) => (
sortedDomains.map((domain) => (
<tr key={domain.id} className="border-b border-slate-100">
<td className="px-2 py-2 font-medium text-slate-900">
<a

View File

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

View File

@@ -1,245 +0,0 @@
# GUARDRAILS
These systems are **currently working** and must **not be broken by AI refactors**.
AI tools must **prefer minimal changes** and avoid architecture changes unless explicitly instructed.
---
# Core Principle
If a task can be solved with a **small change**, the AI must **not perform a large rewrite**.
---
# Git Workflow Rules
The AI must **NEVER commit directly to `master` or `main`.**
All changes must be made via a branch.
## Branch naming conventions
Feature work:
feature/<short-description>
Bug fixes:
fix/<short-description>
Examples:
feature/supabase-public-endpoint
fix/smoke-check-url
fix/magic-link-auth
## Required workflow
1. Create a branch from `master`
2. Make the required changes
3. Commit changes to that branch
4. Show the diff
5. Do **not merge automatically**
6. Push the branch and create a pull request unless explicitly told not to.
7. User performs merge after review
---
# Infrastructure Protection
The AI must **not modify infrastructure** unless explicitly instructed.
This includes:
- DNS records
- reverse proxy configuration
- SSL certificates
- server networking
- firewall rules
- Proxmox configuration
- VM/LXC creation
- Nginx Proxy Manager configuration
- Supabase server configuration
These systems are **managed manually**.
---
# CI/CD
The Jenkins pipeline currently **builds and deploys the application**.
Deployment process:
docker save → scp → docker load
The AI must **not change the deployment architecture** without instruction.
Allowed CI changes:
- fixing pipeline bugs
- improving reliability
- adding environment variables
- improving logs
Not allowed:
- replacing Jenkins
- replacing Docker deployment
- changing deployment host
- introducing Kubernetes
- major CI architecture changes
---
# Container
Container name:
plesk-agency-portal
Deployment path:
/opt/plesk-agency-portal
The AI must **not rename containers or paths** without explicit approval.
---
# Reverse Proxy
Reverse proxy is managed by:
Nginx Proxy Manager
Public application URL:
https://portal.rdbcloud.co.uk
Supabase public endpoint:
https://supabase.rdbcloud.co.uk
The AI must **not change reverse proxy configuration**.
---
# Authentication
Authentication system:
Supabase Magic Link
Required environment variables:
NEXT_PUBLIC_SUPABASE_URL
SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY
The AI must **not replace the authentication system**.
---
# Environment Variables
Secrets must **never be committed to the repository**.
Secrets must come from:
- Jenkins credentials
- environment variables
- deployment configuration
Never commit:
API keys
private tokens
database passwords
service keys
---
# Docker
Docker is used for **build and deployment**.
The AI may:
- update Dockerfile
- fix build issues
- improve caching
The AI must **not introduce complex container orchestration**.
Not allowed:
kubernetes
docker swarm
nomad
terraform
Unless explicitly requested.
---
# Database
Database system:
Supabase Postgres
The AI must **not perform destructive database changes**.
Not allowed automatically:
DROP TABLE
DROP COLUMN
DELETE large datasets
schema rewrites
Schema changes must be **reviewed first**.
---
# Safe Refactoring Rules
Allowed:
- small code improvements
- bug fixes
- performance improvements
- logging improvements
- type fixes
- lint fixes
Avoid:
- large rewrites
- framework changes
- directory restructuring
- renaming large parts of the codebase
Unless specifically requested.
---
# When AI Is Unsure
If the AI is unsure:
1. Explain the risk
2. Ask for clarification
3. Do **not proceed with risky changes**
---
# Priority Order
When making decisions, the AI must prioritise:
1. **Do not break production**
2. **Respect guardrails**
3. **Make minimal safe changes**
4. **Preserve deployment pipeline**
5. **Follow branch workflow**

View File

@@ -1,43 +0,0 @@
# PROJECT-STATE
## Stable Systems
The following are stable and working:
CI/CD pipeline
Docker deployment
Reverse proxy routing
Application container startup
Smoke test endpoint
## Deployment
Jenkins → Build → Docker image → docker save → SCP → docker load → container recreate
App path:
/opt/plesk-agency-portal
Container:
plesk-agency-portal
## Public Endpoint
https://portal.rdbcloud.co.uk
## Current Work
Fix Supabase HTTPS endpoint and authentication flow.
## Known Risk
Supabase currently exposed via HTTP internally causing browser mixed content blocking.
## Update Rules
When system changes:
1. Update PROJECT-STATE.md
2. Update ai-project-context.md
3. Add a new handover file

View File

@@ -1,58 +0,0 @@
# RUNBOOK
## Production App
Host:
192.168.68.89
Deploy path:
/opt/plesk-agency-portal
Container:
plesk-agency-portal
Public URL:
https://portal.rdbcloud.co.uk
---
## Health Check
Public:
curl https://portal.rdbcloud.co.uk/api/health
Local:
curl http://127.0.0.1:3000/api/health
---
## Check Running Container
docker ps
docker inspect plesk-agency-portal --format '{{.Image}} {{.Created}}'
docker images | grep plesk-agency-portal
---
## View Logs
cd /opt/plesk-agency-portal
docker compose -f docker-compose.prod.yml logs --tail=200
Follow logs:
docker logs -f plesk-agency-portal
---
## Rollback
cd /opt/plesk-agency-portal
APP_IMAGE=plesk-agency-portal:build-37 ./scripts/rollback-prod.sh
Verify:
docker inspect plesk-agency-portal --format '{{.Image}} {{.Created}}'
curl http://127.0.0.1:3000/api/health

View File

@@ -1,191 +0,0 @@
# AI Bootstrap Context -- Plesk Agency Portal
This document is intended to be uploaded into a new AI conversation so
the assistant can quickly understand the project state.
------------------------------------------------------------------------
# Project Overview
The **Plesk Agency Portal** is a multi-tenant SaaS platform that allows
agencies to manage multiple Plesk servers, hosting subscriptions, and
domains from a single interface.
Goals:
- Connect multiple Plesk servers
- Sync subscriptions and domains
- Manage hosting environments
- Provide SaaS billing integration
- Enforce tenant isolation using Supabase Row Level Security (RLS)
------------------------------------------------------------------------
# Technology Stack
Frontend Next.js (App Router)
Backend Next.js API routes
Authentication Supabase Auth (Magic Link)
Database Supabase PostgreSQL
CI/CD Jenkins
Containerisation Docker
Reverse Proxy Nginx Proxy Manager
Infrastructure Self-hosted Linux servers
------------------------------------------------------------------------
# Current Deployment Architecture
CI/CD Pipeline:
1. Code pushed to repository
2. Jenkins pipeline runs
3. Next.js application builds
4. Docker image builds
5. Image exported via `docker save`
6. Image transferred via SSH/SCP
7. Image loaded on application host
8. Container recreated
9. Smoke test verifies service
Images are transferred using:
docker save → scp → docker load
This avoids requiring a Docker registry during early development.
------------------------------------------------------------------------
# Infrastructure
Jenkins Host Responsible for:
- building Docker images
- running CI/CD pipeline
- deploying containers
Application Host
Deployment path:
/opt/plesk-agency-portal
Docker container:
plesk-agency-portal
Application port:
3000
Reverse Proxy:
Nginx Proxy Manager
Public domain:
https://portal.rdbcloud.co.uk
------------------------------------------------------------------------
# Database & Auth
Supabase is self-hosted on an internal server.
Current API endpoint:
http://192.168.68.52:54321
Because the portal runs via HTTPS, this causes a browser mixed-content
error.
------------------------------------------------------------------------
# Current Blocking Issue
Supabase authentication fails because the frontend attempts to call:
http://192.168.68.52:54321
while the portal itself is served via HTTPS.
Browsers block the request.
------------------------------------------------------------------------
# Planned Fix
Expose Supabase through HTTPS using Nginx Proxy Manager.
Target domain:
https://supabase.rdbcloud.co.uk
Proxy configuration:
Forward host: 192.168.68.52\
Forward port: 54321
After that update environment variables:
NEXT_PUBLIC_SUPABASE_URL SUPABASE_URL
Then redeploy the portal.
------------------------------------------------------------------------
# Deployment Verification
Example deployed image:
plesk-agency-portal:build-62
Verification command:
docker inspect plesk-agency-portal --format '{{.Image}} {{.Created}}'
Health endpoint:
/api/health
------------------------------------------------------------------------
# Repository Documentation
Important docs:
docs/ai-project-context.md\
docs/system-architecture.md\
docs/next-steps.md\
docs/handovers/\*
------------------------------------------------------------------------
# Typical AI Assistance Areas
- CI/CD debugging
- Docker deployment
- Next.js development
- Supabase integration
- SaaS multi-tenant architecture
- Stripe billing integration
- Plesk API integration
------------------------------------------------------------------------
# Current Development Stage
Infrastructure and deployment pipeline are working.
Next priorities:
1. Fix Supabase HTTPS endpoint
2. Verify authentication flow
3. Continue SaaS portal feature development

View File

@@ -1,7 +0,0 @@
# Current Focus
Immediate tasks:
1. Fix Supabase HTTPS endpoint
2. Verify authentication flow
3. Continue SaaS portal feature development

View File

@@ -1,157 +0,0 @@
# 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.
Jenkins runtime requirements:
- The pipeline must run on a Jenkins agent with the `docker` label.
- That agent must have Docker CLI installed and permission to access Docker daemon/socket.
- `rsync` and `ssh` must also be available on the Jenkins agent.
If Docker is missing or inaccessible, the pipeline now fails early in a dedicated preflight stage with an explicit message.
## 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.

View File

@@ -1,100 +0,0 @@
# Handover 2026-03-07 CI/CD Deploy Working
## Status
The Plesk Agency Portal deployment pipeline is now working endtoend.
### Jenkins Pipeline
Pipeline successfully performs:
1. Build Next.js application
2. Build Docker image
3. Save image archive
4. Transfer archive to app host
5. Load image on remote server
6. Recreate container
7. Run smoke test
Example deployed image:
plesk-agency-portal:build-62
Verification command:
docker inspect plesk-agency-portal --format '{{.Image}} {{.Created}}'
---
## Infrastructure
### Jenkins Host
Responsibilities:
- Build Docker images
- Execute CI/CD pipeline
- Transfer deployment artifacts
### Application Host
Deployment path:
/opt/plesk-agency-portal
Container:
plesk-agency-portal
Application port:
3000
---
## Reverse Proxy
Managed using:
Nginx Proxy Manager
Public domain:
https://portal.rdbcloud.co.uk
---
## Known Issue
Supabase authentication blocked due to mixed content.
Frontend attempts:
http://192.168.68.52:54321
while the site runs via HTTPS.
---
## Next Task
Expose Supabase API through HTTPS:
https://supabase.rdbcloud.co.uk
Then update environment variables:
NEXT_PUBLIC_SUPABASE_URL
SUPABASE_URL
and redeploy the application.
---
## Deployment Strategy
Docker registry intentionally avoided.
Images transferred using:
docker save → scp → docker load

View File

@@ -3,18 +3,6 @@ const requiredEnvs = [
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
] 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) {
if (!process.env[key]) {
// Keep as runtime warning for smoother local setup.
@@ -24,19 +12,15 @@ for (const key of requiredEnvs) {
}
export const env = {
appUrl: configuredAppUrl ?? "http://localhost:3000",
appOrigin: getAppOrigin(),
appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
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 ?? "",
pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "",
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "",
stripePriceId: process.env.STRIPE_PRICE_ID ?? "",
supabaseUrl:
process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
};

View File

@@ -11,7 +11,7 @@ export function getStripeClient() {
if (!stripeClient) {
stripeClient = new Stripe(env.stripeSecretKey, {
apiVersion: "2025-08-27.basil",
apiVersion: "2025-02-24.acacia",
});
}

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
APP_IMAGE="${APP_IMAGE:-plesk-agency-portal:latest}"
mkdir -p "${DEPLOY_PATH}"
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
exit 1
fi
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
echo "Missing .env 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 --force-recreate
APP_IMAGE="${APP_IMAGE}" docker compose -f docker-compose.prod.yml ps

View File

@@ -1,40 +0,0 @@
#!/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
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
echo "Missing .env in ${DEPLOY_PATH}" >&2
exit 1
fi
if [ ! -f "${DEPLOY_PATH}/scripts/smoke-check.sh" ]; then
echo "Missing scripts/smoke-check.sh 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 --force-recreate
"${DEPLOY_PATH}/scripts/smoke-check.sh" "${APP_BASE_URL}"
docker compose -f docker-compose.prod.yml ps

View File

@@ -1,36 +0,0 @@
#!/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)"
MAX_ATTEMPTS=20
SLEEP_SECONDS=3
trap 'rm -f "${TMP_FILE}"' EXIT
for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do
HTTP_CODE=$(curl -sS -o "${TMP_FILE}" -w "%{http_code}" "${URL}" || echo "000")
if [ "${HTTP_CODE}" = "200" ]; then
if 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}"; then
echo "Smoke check passed: ${URL} (attempt ${attempt}/${MAX_ATTEMPTS})"
exit 0
fi
fi
if [ "${attempt}" -lt "${MAX_ATTEMPTS}" ]; then
sleep "${SLEEP_SECONDS}"
fi
done
echo "Smoke check failed after ${MAX_ATTEMPTS} attempts: ${URL}" >&2
cat "${TMP_FILE}" >&2 || true
exit 1