Compare commits

...

32 Commits

Author SHA1 Message Date
627a34728e feat(ui): add dashboard health and autosync overview widget 2026-03-05 18:14:25 +00:00
826e7da895 Merge pull request 'feature/cl8-instance-health' (#25) from feature/cl8-instance-health into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/25
2026-03-05 18:04:40 +00:00
8d86378341 feat(ui): show instance health badges and health panel 2026-03-05 18:03:38 +00:00
fb385b59c4 feat(api): add cron and manual health check endpoints 2026-03-05 18:03:26 +00:00
9411f292a8 feat(worker): add health check runner 2026-03-05 18:02:57 +00:00
953b62ef6e feat(db): add instance health monitoring fields 2026-03-05 18:02:32 +00:00
464dccedea docs: add cline super prompt for safe AI development 2026-03-05 17:48:38 +00:00
699454f183 Merge pull request 'feat(ui): add activity log page backed by actions_log' (#24) from feature/activity-log-page into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/24
2026-03-05 17:47:59 +00:00
03dfa6a5ae feat(ui): add activity log page backed by actions_log 2026-03-05 17:42:03 +00:00
4f2e0c8192 Merge pull request 'feat(worker): add autosync scheduler, endpoint, locking and UI controls' (#23) from feature/cl6-auto-sync-worker into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/23
2026-03-05 17:37:15 +00:00
5e5fb3221a feat(worker): add autosync scheduler, endpoint, locking and UI controls 2026-03-05 17:33:14 +00:00
34aa17a74a Merge pull request 'feat(logging): add best-effort requested/succeeded/failed action logs' (#22) from feature/cl5-action-logging into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/22
2026-03-05 17:19:05 +00:00
c943d2cbf6 feat(logging): add best-effort requested/succeeded/failed action logs 2026-03-05 17:13:18 +00:00
c11fcd01a4 Merge pull request 'feat(ui): polish domains list subscription/status display' (#21) from feature/ui-domains-polish into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/21
2026-03-05 17:07:48 +00:00
710227a49a feat(ui): polish domains list subscription/status display 2026-03-05 17:06:29 +00:00
b72176b276 Merge pull request 'fix(actions): use plesk bin site for suspend/unsuspend domain state' (#20) from fix/domain-actions-use-site-cli into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/20
2026-03-05 17:03:55 +00:00
81b0617390 fix(actions): use plesk bin site for suspend/unsuspend domain state 2026-03-05 16:59:40 +00:00
757e8953be Merge pull request 'fix(plesk): use subscription --unsuspend for unsuspend action' (#19) from fix/plesk-unsuspend-command into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/19
2026-03-05 16:04:42 +00:00
c528392682 fix(plesk): use subscription --unsuspend for unsuspend action 2026-03-05 16:04:11 +00:00
a678713d1d Merge pull request 'fix(actions): unsuspend using canonical subscription identifier + refresh status' (#18) from fix/unsuspend-verify-and-refresh into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/18
2026-03-05 14:27:56 +00:00
033d816d42 fix(actions): unsuspend using canonical subscription identifier + refresh status 2026-03-05 14:26:58 +00:00
a22fc2c4d2 Merge pull request 'fix(actions): send and use subscription_id for domain suspend/unsuspend' (#17) from fix/actions-use-subscription-id into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/17
2026-03-05 14:20:34 +00:00
825bf8dc84 fix(actions): send and use subscription_id for domain suspend/unsuspend 2026-03-05 14:20:07 +00:00
2c52d89dc9 Merge pull request 'fix(linking): link subdomains to parent subscription via suffix match' (#16) from fix/subdomain-inherit-subscription into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/16
2026-03-05 14:14:52 +00:00
cc7e30092d fix(linking): link subdomains to parent subscription via suffix match 2026-03-05 14:14:20 +00:00
ce8b04285a Merge pull request 'fix(actions): refresh subscription status after suspend/unsuspend' (#15) from fix/action-refresh-status into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/15
2026-03-05 14:10:56 +00:00
ed31598a1d fix(actions): refresh subscription status after suspend/unsuspend 2026-03-05 14:10:27 +00:00
a49448c04b Merge pull request 'fix(sync): link domains to subscriptions and add backfill endpoint' (#14) from fix/link-domains-to-subscriptions into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/14
2026-03-05 14:09:56 +00:00
dceccecf0c fix(sync): link domains to subscriptions and add backfill endpoint 2026-03-05 14:03:03 +00:00
8e5d9865ac Merge pull request 'fix(domains): correctly match subscriptions to domains for status' (#13) from fix/cl4-domain-status-merge into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/13
2026-03-05 13:46:31 +00:00
ee9b90e3d1 fix(domains): correctly match subscriptions to domains for status 2026-03-05 13:46:03 +00:00
ea671336d3 Merge pull request 'fix(domains): prevent status enrichment from filtering domains' (#12) from hotfix/domains-list-server-join-empty into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/12
2026-03-05 13:36:34 +00:00
21 changed files with 2997 additions and 198 deletions

View File

@@ -4,6 +4,7 @@ SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_APP_URL=http://localhost:3000
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
PLESK_CRED_ENC_KEY=

View File

@@ -63,7 +63,9 @@ values ('<agency_uuid>', '<auth_user_uuid>', 'owner');
- `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/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/cron/autosync` — scheduled auto-sync tick (requires `X-CRON-SECRET`)
- `POST /api/plesk/subscriptions/:id/action` — suspend or unsuspend
- `POST /api/stripe/checkout` — create Stripe Checkout 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 `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 `CRON_SECRET` for `/api/cron/autosync` auth.
- `/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.
@@ -97,3 +100,37 @@ Notes:
- Job uses DB lock (`job_locks`) to avoid overlapping runs.
- Job processes at most 10 connected instances per run.
- 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`

View 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.cronSecret || !secret || secret !== env.cronSecret) {
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 },
);
}
}

View 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.cronSecret || !secret || secret !== env.cronSecret) {
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 },
);
}
}

View File

@@ -5,15 +5,54 @@ import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
import { assertRateLimit } from "@/lib/api/rate-limit";
import { assertSameOrigin } from "@/lib/api/security";
import {
suspendPleskSubscription,
unsuspendPleskSubscription,
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 } },
@@ -22,8 +61,13 @@ export async function POST(
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 } | 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);
@@ -32,7 +76,8 @@ export async function POST(
assertAgencyAdmin(context);
assertRateLimit(`plesk-domain-action:${context.userId}`, 10, 60_000);
const { action } = actionSchema.parse(await req.json());
const payload = actionSchema.parse(await req.json());
const { action } = payload;
requestedAction = action;
const { data: domainRow, error: domainError } = await supabase
@@ -46,21 +91,23 @@ export async function POST(
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: domainRow.subscription_id
? String(domainRow.subscription_id)
: null,
subscription_id: resolvedSubscriptionId,
};
if (!domain.subscription_id) {
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", domain.subscription_id)
.eq("id", resolvedSubscriptionId)
.eq("agency_id", context.agencyId)
.single();
@@ -71,6 +118,7 @@ export async function POST(
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
@@ -90,29 +138,117 @@ export async function POST(
encryptedSecret: instance.encrypted_secret,
} as const;
if (action === "suspend") {
await suspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
} else {
await unsuspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
}
const domainIdentifier = subscription.plesk_subscription_id;
const siteArgs =
action === "suspend"
? ["--suspend", domainIdentifier]
: ["--on", domainIdentifier];
await supabase.from("actions_log").insert({
await insertActionLogBestEffort(supabase, {
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_domain",
target_id: domain.id,
action: `domain_${action}`,
status: "success",
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,
},
});
@@ -121,20 +257,41 @@ export async function POST(
if (context && domain) {
const message =
error instanceof Error ? error.message : "Domain action failed";
await supabase.from("actions_log").insert({
if (requestedAction) {
await insertActionLogBestEffort(supabase, {
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_domain",
target_id: domain.id,
action: requestedAction ? `domain_${requestedAction}` : "domain_action",
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(

View 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 },
);
}
}

View 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 },
);
}
}

View 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 },
);
}
}

View File

@@ -5,6 +5,8 @@ import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
import { assertRateLimit } from "@/lib/api/rate-limit";
import { assertSameOrigin } from "@/lib/api/security";
import {
parseSubscriptionStatusDetailsFromInfo,
pleskCliCall,
suspendPleskSubscription,
unsuspendPleskSubscription,
} from "@/lib/plesk/client";
@@ -14,6 +16,44 @@ const actionSchema = z.object({
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(
req: Request,
{ params }: { params: { id: string } },
@@ -27,6 +67,7 @@ export async function POST(
plesk_subscription_id: string;
plesk_instance_id: string;
} | null = null;
const startedAt = Date.now();
try {
assertSameOrigin(req);
@@ -68,58 +109,153 @@ export async function POST(
encryptedSecret: instance.encrypted_secret,
} as const;
if (payload.action === "suspend") {
await suspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
} else {
await unsuspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
}
const cliArgs =
payload.action === "suspend"
? ["--webspace-off", subscription.plesk_subscription_id]
: ["--unsuspend", subscription.plesk_subscription_id];
const newStatus = payload.action === "suspend" ? "suspended" : "active";
const { error: updateError } = await supabase
.from("plesk_subscriptions")
.update({ status: newStatus })
.eq("id", subscription.id);
await supabase.from("actions_log").insert({
await insertActionLogBestEffort(supabase, {
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_subscription",
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",
metadata: {
duration_ms: Date.now() - startedAt,
cli_command: "subscription",
cli_args: cliArgs,
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,
},
});
return NextResponse.json({
ok: true,
status: newStatus,
status: refreshedStatus,
dbStatusUpdated: !updateError,
refreshError: refreshErrorMessage,
});
} catch (error) {
if (supabase && context && subscription) {
const message =
error instanceof Error ? error.message : "Subscription action failed";
await supabase.from("actions_log").insert({
if (requestedAction) {
await insertActionLogBestEffort(supabase, {
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_subscription",
target_id: subscription.id,
action: requestedAction ?? "subscription_action",
action: SUBSCRIPTION_ACTION_EVENTS[requestedAction].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,
},
});
} 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(

View 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>
);
}

View File

@@ -1,8 +1,10 @@
import { SignOutButton } from "@/components/auth/sign-out-button";
import { DashboardControls } from "@/components/dashboard/dashboard-controls";
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
import { formatDateTime } from "@/lib/dates";
import type { Database } from "@/lib/types";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import Link from "next/link";
type AgencySummary = Pick<
Database["public"]["Tables"]["agencies"]["Row"],
@@ -16,6 +18,98 @@ type ActionLogSummary = Pick<
Database["public"]["Tables"]["actions_log"]["Row"],
"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() {
const context = await getServerAgencyContextOrRedirect();
@@ -28,6 +122,8 @@ export default async function DashboardPage() {
{ data: domains },
{ data: billing },
{ data: autoSyncLogs },
{ data: latestAutoSyncLog },
{ data: healthLogs },
] = (await Promise.all([
supabase
.from("agencies")
@@ -37,7 +133,7 @@ export default async function DashboardPage() {
supabase
.from("plesk_instances")
.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)
.order("created_at", { ascending: false }),
@@ -50,7 +146,7 @@ export default async function DashboardPage() {
supabase
.from("plesk_domains")
.select(
"id, plesk_instance_id, subscription_id, status, domain_name, 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)
.order("updated_at", { ascending: false })
@@ -68,6 +164,31 @@ export default async function DashboardPage() {
.eq("action", "plesk_sync_auto")
.order("created_at", { ascending: false })
.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 [
{ data: AgencySummary | null },
{
@@ -84,6 +205,20 @@ export default async function DashboardPage() {
last_sync_error: string | null;
last_sync_subscriptions: 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;
error: { message: string } | null;
},
@@ -101,6 +236,7 @@ export default async function DashboardPage() {
id: string;
plesk_instance_id: string;
subscription_id: string | null;
external_subscription_id: string | null;
domain_name: string;
status: string | null;
hosting_type: string | null;
@@ -111,23 +247,21 @@ export default async function DashboardPage() {
},
{ data: BillingSummary | null },
{ data: ActionLogSummary[] | null },
{ data: AutosyncLatestLogSummary | null },
{ data: HealthLogSummary[] | null },
];
const domainSubscriptionIds = Array.from(
new Set(
(domains ?? [])
.map((domain) => domain.subscription_id)
.filter((id): id is string => Boolean(id)),
),
const domainInstanceIds = Array.from(
new Set((domains ?? []).map((domain) => domain.plesk_instance_id)),
);
const { data: domainSubscriptions } =
domainSubscriptionIds.length > 0
domainInstanceIds.length > 0
? await supabase
.from("plesk_subscriptions")
.select("id, status")
.select("id, plesk_instance_id, plesk_subscription_id, name, status")
.eq("agency_id", context.agencyId)
.in("id", domainSubscriptionIds)
.in("plesk_instance_id", domainInstanceIds)
: { data: [] };
const subscriptionStatusById = new Map<string, string | null>(
@@ -137,6 +271,62 @@ export default async function DashboardPage() {
]),
);
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<
string,
{ status: string; created_at: string; error_message: string | null }
@@ -151,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 (
<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">
@@ -160,9 +411,100 @@ export default async function DashboardPage() {
</h1>
<p className="text-sm text-slate-600">Role: {context.role}</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/dashboard/activity"
className="text-sm text-brand-700 underline"
>
Activity
</Link>
<SignOutButton />
</div>
</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">
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">Plesk Instances</p>
@@ -188,21 +530,49 @@ export default async function DashboardPage() {
Instances query failed: {instancesError.message}
</div>
) : null}
<section id="instances-panel">
<DashboardControls
instances={instances ?? []}
subscriptions={subscriptions ?? []}
domains={(domains ?? []).map((domain) => {
const joinedStatus = domain.subscription_id
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>
);
}

View File

@@ -18,6 +18,20 @@ type Instance = {
last_sync_error: string | null;
last_sync_subscriptions: 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;
};
type Subscription = {
@@ -32,6 +46,7 @@ type Domain = {
id: string;
plesk_instance_id: string;
subscription_id: string | null;
subscription_name?: string | null;
domain_name: string;
status: string | null;
hosting_type: string | null;
@@ -48,8 +63,44 @@ type Props = {
string,
{ status: string; created_at: string; error_message: string | null }
>;
healthEventsByInstance: Record<
string,
Array<{
id: string;
action: string;
status: string;
created_at: string;
error_message: string | null;
metadata: unknown;
}>
>;
};
function getHealthBadge(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
if (normalized === "ok") {
return { label: "OK", className: "bg-emerald-100 text-emerald-700" };
}
if (normalized === "degraded") {
return { label: "Degraded", className: "bg-amber-100 text-amber-700" };
}
if (normalized === "down") {
return { label: "Down", className: "bg-rose-100 text-rose-700" };
}
return { label: "Unknown", className: "bg-slate-100 text-slate-600" };
}
function mapHealthResult(action: string) {
if (action === "health_check_ok") return "ok";
if (action === "health_check_degraded") return "degraded";
if (action === "health_check_failed") return "failed";
return "started";
}
function getDomainStatusBadge(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
@@ -72,6 +123,23 @@ function getDomainStatusBadge(status: string | null) {
return "bg-slate-100 text-slate-600";
}
function formatDomainStatusLabel(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
if (normalized.includes("suspend")) return "Suspended";
if (normalized.includes("disabled")) return "Disabled";
if (normalized.includes("expired")) return "Expired";
if (
normalized.includes("active") ||
normalized.includes("ok") ||
normalized.includes("enabled")
) {
return "Active";
}
return "Unknown";
}
function isDomainSuspended(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
return normalized.includes("suspend") || normalized.includes("disabled");
@@ -97,7 +165,9 @@ export function DashboardControls({
subscriptions,
domains,
autoSyncByInstance,
healthEventsByInstance,
}: Props) {
const autoSyncFrequencyOptions = [15, 30, 60, 180, 360, 1440];
const [instanceName, setInstanceName] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [authType, setAuthType] = useState<"api_key" | "basic">("api_key");
@@ -221,6 +291,41 @@ export function DashboardControls({
}
}
async function onRunHealthCheck(instanceId: string) {
setLoading(true);
setMessage(null);
try {
await runRequest(`/api/plesk/instances/${instanceId}/health`);
setMessage("Health check completed.");
window.location.reload();
} catch (error) {
setMessage(
error instanceof Error ? error.message : "Health check failed",
);
} finally {
setLoading(false);
}
}
async function onUpdateAutoSync(
instanceId: string,
input: { auto_sync_enabled: boolean; auto_sync_frequency_minutes: number },
) {
setLoading(true);
setMessage(null);
try {
await runRequest(`/api/plesk/instances/${instanceId}/autosync`, input);
setMessage("Auto-sync settings updated.");
window.location.reload();
} catch (error) {
setMessage(
error instanceof Error ? error.message : "Auto-sync update failed",
);
} finally {
setLoading(false);
}
}
async function onSubscriptionAction() {
if (!actionSubId) return;
setLoading(true);
@@ -239,13 +344,23 @@ export function DashboardControls({
}
async function onDomainAction(
domainId: string,
domain: Domain,
action: "suspend" | "unsuspend",
) {
if (!domain.subscription_id) {
setMessage(
"Domain is not linked to a subscription yet. Run Sync Now (or backfill links) and try again.",
);
return;
}
setLoading(true);
setMessage(null);
try {
await runRequest(`/api/plesk/domains/${domainId}/action`, { action });
await runRequest(`/api/plesk/domains/${domain.id}/action`, {
action,
subscription_id: domain.subscription_id,
});
setMessage("Domain action completed.");
window.location.reload();
} catch (error) {
@@ -389,12 +504,17 @@ export function DashboardControls({
key={instance.id}
className="rounded border border-slate-200 p-3"
>
{(() => {
const health = getHealthBadge(instance.health_status);
return (
<div className="flex items-start justify-between gap-2">
<div>
{autoSyncByInstance[instance.id] ? (
<span
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${
autoSyncByInstance[instance.id].status === "success"
autoSyncByInstance[instance.id].status ===
"success"
? "bg-emerald-100 text-emerald-700"
: "bg-rose-100 text-rose-700"
}`}
@@ -410,10 +530,32 @@ export function DashboardControls({
</p>
<p className="mt-1 text-xs text-slate-600">
Status:{" "}
<span className="font-medium">{instance.status}</span>
<span className="font-medium">
{instance.status}
</span>
</p>
<p className="text-xs text-slate-500">
Subscriptions: {instance.last_sync_subscriptions ?? 0}
Health:{" "}
<span
title={instance.health_last_error ?? undefined}
className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${health.className}`}
>
{health.label}
</span>
</p>
<p className="text-xs text-slate-500">
Health last checked:{" "}
{formatDateTime(instance.health_last_checked_at)}
</p>
<p className="text-xs text-slate-500">
Health latency:{" "}
{instance.health_last_latency_ms != null
? `${instance.health_last_latency_ms} ms`
: "—"}
</p>
<p className="text-xs text-slate-500">
Subscriptions:{" "}
{instance.last_sync_subscriptions ?? 0}
</p>
<p className="text-xs text-slate-500">
Domains: {instance.last_sync_domains ?? 0}
@@ -434,7 +576,8 @@ export function DashboardControls({
</span>
</p>
<p className="text-xs text-slate-500">
Last sync time: {formatDateTime(instance.last_sync_at)}
Last sync time:{" "}
{formatDateTime(instance.last_sync_at)}
</p>
<p className="text-xs text-slate-500">
Last connected:{" "}
@@ -442,6 +585,32 @@ export function DashboardControls({
? formatDateTime(instance.last_connected_at)
: "never"}
</p>
<p className="text-xs text-slate-500">
Auto-sync:{" "}
{instance.auto_sync_enabled
? "Enabled"
: "Disabled"}
</p>
<p className="text-xs text-slate-500">
Auto-sync frequency:{" "}
{instance.auto_sync_frequency_minutes ?? 60} min
</p>
<p className="text-xs text-slate-500">
Last auto-sync:{" "}
{formatDateTime(instance.last_auto_sync_at)}
</p>
<p className="text-xs text-slate-500">
Next auto-sync:{" "}
{formatDateTime(instance.next_auto_sync_at)}
</p>
<p className="text-xs text-slate-500">
Lock status:{" "}
{instance.auto_sync_lock_until &&
new Date(instance.auto_sync_lock_until).getTime() >
Date.now()
? "Sync in progress"
: "Idle"}
</p>
{autoSyncByInstance[instance.id] ? (
<p className="text-xs text-slate-500">
Last auto-sync:{" "}
@@ -466,8 +635,54 @@ export function DashboardControls({
{autoSyncByInstance[instance.id].error_message}
</p>
) : null}
{instance.health_last_error ? (
<p
className="mt-1 text-xs text-rose-600"
title={instance.health_last_error}
>
Health error: {instance.health_last_error}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-600">
Auto-sync
</label>
<input
type="checkbox"
checked={instance.auto_sync_enabled}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
onUpdateAutoSync(instance.id, {
auto_sync_enabled: e.target.checked,
auto_sync_frequency_minutes:
instance.auto_sync_frequency_minutes ?? 60,
})
}
disabled={loading}
/>
</div>
<select
value={String(
instance.auto_sync_frequency_minutes ?? 60,
)}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onUpdateAutoSync(instance.id, {
auto_sync_enabled: instance.auto_sync_enabled,
auto_sync_frequency_minutes: Number(
e.target.value,
),
})
}
disabled={loading}
className="rounded border px-2 py-1 text-xs"
>
{autoSyncFrequencyOptions.map((minutes) => (
<option key={minutes} value={minutes}>
Every {minutes} min
</option>
))}
</select>
<button
onClick={() => onTestConnection(instance.id)}
disabled={loading}
@@ -475,6 +690,13 @@ export function DashboardControls({
>
Test Connection
</button>
<button
onClick={() => onRunHealthCheck(instance.id)}
disabled={loading}
className="rounded border px-3 py-1.5 text-xs"
>
Run Health Check
</button>
<button
onClick={() => onSync(instance.id)}
disabled={loading}
@@ -484,6 +706,108 @@ export function DashboardControls({
</button>
</div>
</div>
);
})()}
<div className="mt-3 rounded border border-slate-100 bg-slate-50 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-600">
Health Panel
</p>
<div className="mt-2 grid gap-1 text-xs text-slate-600 sm:grid-cols-2">
<p>
Current Status:{" "}
<span className="font-medium">
{getHealthBadge(instance.health_status).label}
</span>
</p>
<p>
Last Checked:{" "}
<span className="font-medium">
{formatDateTime(instance.health_last_checked_at)}
</span>
</p>
<p>
Last Successful Check:{" "}
<span className="font-medium">
{formatDateTime(instance.health_last_ok_at)}
</span>
</p>
<p>
Latency:{" "}
<span className="font-medium">
{instance.health_last_latency_ms != null
? `${instance.health_last_latency_ms} ms`
: "—"}
</span>
</p>
<p className="sm:col-span-2">
Last Error:{" "}
<span className="font-medium">
{instance.health_last_error ?? "—"}
</span>
</p>
</div>
<div className="mt-3 overflow-x-auto">
<table className="min-w-full text-left text-xs">
<thead>
<tr className="border-b border-slate-200 text-slate-500">
<th className="px-2 py-1">Time</th>
<th className="px-2 py-1">Event</th>
<th className="px-2 py-1">Result</th>
<th className="px-2 py-1">Message</th>
</tr>
</thead>
<tbody>
{(healthEventsByInstance[instance.id] ?? [])
.length === 0 ? (
<tr>
<td
className="px-2 py-2 text-slate-500"
colSpan={4}
>
No health events yet.
</td>
</tr>
) : (
(healthEventsByInstance[instance.id] ?? []).map(
(event) => (
<tr
key={event.id}
className="border-b border-slate-100 align-top"
>
<td className="px-2 py-1">
{formatDateTime(event.created_at)}
</td>
<td className="px-2 py-1">{event.action}</td>
<td className="px-2 py-1">
{mapHealthResult(event.action)}
</td>
<td className="px-2 py-1">
{event.error_message ?? "—"}
{event.metadata ? (
<details>
<summary className="cursor-pointer text-[11px] text-brand-700">
metadata
</summary>
<pre className="mt-1 max-w-[440px] overflow-auto rounded bg-white p-2 text-[10px] text-slate-700">
{JSON.stringify(
event.metadata,
null,
2,
)}
</pre>
</details>
) : null}
</td>
</tr>
),
)
)}
</tbody>
</table>
</div>
</div>
</div>
))
)}
@@ -608,16 +932,26 @@ export function DashboardControls({
{domain.domain_name}
</a>
{domain.subscription_id ? (
<p className="text-xs font-normal text-slate-500">
Subscription: {domain.subscription_id}
<p
className="text-xs font-normal text-slate-500"
title={`Subscription UUID: ${domain.subscription_id}`}
>
Subscription: {domain.subscription_name ?? "Linked"}
</p>
) : null}
) : (
<p
className="text-xs font-normal text-amber-700"
title="Run sync to link this domain to a subscription"
>
Subscription: Unlinked
</p>
)}
</td>
<td className="px-2 py-2">
<span
className={`inline-flex rounded px-2 py-0.5 text-xs font-medium ${getDomainStatusBadge(domain.status)}`}
>
{domain.status ?? "unknown"}
{formatDomainStatusLabel(domain.status)}
</span>
</td>
<td className="px-2 py-2">
@@ -633,19 +967,33 @@ export function DashboardControls({
<td className="px-2 py-2">
<div className="flex gap-2">
<button
onClick={() => onDomainAction(domain.id, "suspend")}
onClick={() => onDomainAction(domain, "suspend")}
disabled={
loading || isDomainSuspended(domain.status)
loading ||
!domain.subscription_id ||
isDomainSuspended(domain.status)
}
title={
!domain.subscription_id
? "Not linked to a subscription yet — run sync"
: undefined
}
className="rounded border border-rose-300 px-2 py-1 text-xs text-rose-700 disabled:cursor-not-allowed disabled:opacity-60"
>
Suspend
</button>
<button
onClick={() =>
onDomainAction(domain.id, "unsuspend")
onClick={() => onDomainAction(domain, "unsuspend")}
disabled={
loading ||
!domain.subscription_id ||
isDomainActive(domain.status)
}
title={
!domain.subscription_id
? "Not linked to a subscription yet — run sync"
: undefined
}
disabled={loading || isDomainActive(domain.status)}
className="rounded border border-emerald-300 px-2 py-1 text-xs text-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
>
Unsuspend

202
docs/cline-super-prompt.md Normal file
View 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.

View File

@@ -14,6 +14,7 @@ for (const key of requiredEnvs) {
export const env = {
appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
jobSecret: process.env.JOB_SECRET ?? "",
cronSecret: process.env.CRON_SECRET ?? "",
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "",
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",

232
lib/plesk/auto-sync.ts Normal file
View 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,
};
}

View File

@@ -289,7 +289,7 @@ export async function unsuspendPleskSubscription(
subscriptionName: string,
) {
return pleskCliCall(connection, "subscription", [
"--webspace-on",
"--unsuspend",
subscriptionName,
]);
}

345
lib/plesk/health.ts Normal file
View 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,
};
}

View File

@@ -31,6 +31,25 @@ type LocalSubscriptionLookupRow = {
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(
supabase: SupabaseLike,
instance: InstanceRow,
@@ -213,13 +232,30 @@ export async function syncPleskInstance(
>(
subscriptionsIndexed
.filter((row) => row.name)
.map((row) => [String(row.name).trim().toLowerCase(), row]),
.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
.map((item: any) => {
const domainId = item?.id ?? item?.guid ?? item?.name;
const domainName = item?.name ?? item?.domain_name ?? item?.domain;
const normalizedDomainName = domainName
? normalizeDomainLike(String(domainName))
: null;
const subExternalId =
item?.subscription?.id ?? item?.subscription_id ?? item?.webspace_id;
const rawExternalSubscriptionId = subExternalId
@@ -240,13 +276,24 @@ export async function syncPleskInstance(
: undefined;
const localSubscriptionByName = subscriptionNameHint
? subscriptionByName.get(
String(subscriptionNameHint).trim().toLowerCase(),
normalizeDomainLike(String(subscriptionNameHint)),
)
: undefined;
const localSubscriptionByDomainName = normalizedDomainName
? subscriptionByName.get(normalizedDomainName)
: undefined;
const localSubscriptionBySuffix = normalizedDomainName
? findBestSuffixSubscriptionMatch(
normalizedDomainName,
subscriptionNameCandidates,
)
: undefined;
const localSubscription =
localSubscriptionByExternal ??
localSubscriptionByLocalId ??
localSubscriptionByName;
localSubscriptionByName ??
localSubscriptionByDomainName ??
localSubscriptionBySuffix;
const externalSubscriptionId = localSubscription
? localSubscription.pleskSubscriptionId
@@ -281,6 +328,10 @@ export async function syncPleskInstance(
})
.filter(Boolean) as Array<Record<string, unknown>>;
const unlinkedDomains = domainsForUpsert.filter(
(domain) => !domain.subscription_id,
);
if (domainsForUpsert.length > 0) {
const { error } = await supabase
.from("plesk_domains")
@@ -315,6 +366,10 @@ export async function syncPleskInstance(
metadata: {
subscriptions_seen: subscriptionsRaw.length,
domains_seen: domainsRaw.length,
unlinked_domains: unlinkedDomains.length,
unlinked_domain_samples: unlinkedDomains
.slice(0, 25)
.map((domain) => String(domain.domain_name ?? "unknown")),
},
});
@@ -387,3 +442,146 @@ export async function syncPleskInstance(
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,
};
}

View File

@@ -83,6 +83,22 @@ export type Database = {
last_sync_error: string | null;
last_sync_subscriptions: 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;
};
Insert: {
@@ -101,6 +117,22 @@ export type Database = {
last_sync_error?: string | null;
last_sync_subscriptions?: 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;
};
Update: {
@@ -119,6 +151,22 @@ export type Database = {
last_sync_error?: string | null;
last_sync_subscriptions?: 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;
};
};

View 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;
$$;

View 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;
$$;