Compare commits

..

16 Commits

Author SHA1 Message Date
b221ad45fa Dashboard: make domains clickable links opening in new tab 2026-03-05 11:53:29 +00:00
9ec1259403 Merge pull request 'feature/domain-actions' (#7) from feature/domain-actions into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/7
2026-03-05 11:44:16 +00:00
84165f47ff Dashboard: make domains table span full width 2026-03-05 10:44:15 +00:00
067e0d9080 CL3.8 domain actions: suspend/unsuspend subscription from domains table 2026-03-05 10:38:05 +00:00
e90b34e6ca Merge pull request 'Parse Plesk subscription status via CLI; domains inherit; UI badges' (#6) from feature/plesk-status-parsing into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/6
2026-03-05 10:22:59 +00:00
f2ad5c9d7d Merge branch 'master' into feature/plesk-status-parsing 2026-03-05 10:22:52 +00:00
3bc2cf09c3 Parse Plesk subscription status via CLI; domains inherit; UI badges 2026-03-05 10:22:18 +00:00
e619c12c35 Merge pull request 'Use Plesk CLI subscription info to populate status; domains inherit status; UI badges' (#5) from feature/subscription-status into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/5
2026-03-05 10:20:01 +00:00
a64de5aba3 Use Plesk CLI subscription info to populate status; domains inherit status; UI badges 2026-03-05 10:17:28 +00:00
b80b3797fe Merge pull request 'fix/subscription-status' (#4) from fix/subscription-status into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/4
2026-03-05 10:12:43 +00:00
fa43428153 Fix subscription status via CLI; derive domain status + badges 2026-03-05 10:10:17 +00:00
37a70cafdd checkpoint before subscription/domain status fix 2026-03-05 10:07:28 +00:00
878ef7555d Merge pull request 'fix/domain-status-from-subscription' (#3) from fix/domain-status-from-subscription into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/3
2026-03-05 10:03:52 +00:00
3599ccbb39 Derive domain status from subscription status and add backfill 2026-03-05 10:01:08 +00:00
09d66ea601 checkpoint before domain status = subscription status 2026-03-05 09:58:50 +00:00
698bd85c7d Merge pull request 'feature/domain-health-dashboard' (#2) from feature/domain-health-dashboard into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/2
2026-03-05 09:53:46 +00:00
7 changed files with 343 additions and 63 deletions

View File

@@ -0,0 +1,147 @@
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 {
suspendPleskSubscription,
unsuspendPleskSubscription,
} from "@/lib/plesk/client";
import { createSupabaseRouteClient } from "@/lib/supabase/route";
const actionSchema = z.object({
action: z.enum(["suspend", "unsuspend"]),
});
export async function POST(
req: Request,
{ params }: { params: { id: string } },
) {
const supabase = createSupabaseRouteClient() as any;
let context: Awaited<ReturnType<typeof getRouteAgencyContextOrThrow>> | null =
null;
let domain: { id: string; subscription_id: string | null } | null = null;
let subscription: { id: string; plesk_subscription_id: string } | null = null;
let requestedAction: "suspend" | "unsuspend" | null = null;
try {
assertSameOrigin(req);
context = await getRouteAgencyContextOrThrow();
assertAgencyAdmin(context);
assertRateLimit(`plesk-domain-action:${context.userId}`, 10, 60_000);
const { action } = actionSchema.parse(await req.json());
requestedAction = action;
const { data: domainRow, error: domainError } = await supabase
.from("plesk_domains")
.select("id, agency_id, plesk_instance_id, subscription_id")
.eq("id", params.id)
.eq("agency_id", context.agencyId)
.single();
if (domainError || !domainRow) {
throw new Error("Domain not found");
}
domain = {
id: String(domainRow.id),
subscription_id: domainRow.subscription_id
? String(domainRow.subscription_id)
: null,
};
if (!domain.subscription_id) {
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("agency_id", context.agencyId)
.single();
if (subscriptionError || !subscriptionRow) {
throw new Error("Subscription not found");
}
subscription = {
id: String(subscriptionRow.id),
plesk_subscription_id: String(subscriptionRow.plesk_subscription_id),
};
const { data: instance, error: instanceError } = await supabase
.from("plesk_instances")
.select("id, base_url, auth_type, encrypted_secret")
.eq("id", subscriptionRow.plesk_instance_id)
.eq("agency_id", context.agencyId)
.single();
if (instanceError || !instance) {
throw new Error("Plesk instance not found");
}
const connection = {
baseUrl: instance.base_url,
authType: instance.auth_type,
encryptedSecret: instance.encrypted_secret,
} as const;
if (action === "suspend") {
await suspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
} else {
await unsuspendPleskSubscription(
connection,
subscription.plesk_subscription_id,
);
}
await supabase.from("actions_log").insert({
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_domain",
target_id: domain.id,
action: `domain_${action}`,
status: "success",
metadata: {
domain_id: domain.id,
subscription_id: subscription.id,
plesk_subscription_id: subscription.plesk_subscription_id,
},
});
return NextResponse.json({ ok: true });
} catch (error) {
if (context && domain) {
const message =
error instanceof Error ? error.message : "Domain action failed";
await supabase.from("actions_log").insert({
agency_id: context.agencyId,
actor_user_id: context.userId,
target_type: "plesk_domain",
target_id: domain.id,
action: requestedAction ? `domain_${requestedAction}` : "domain_action",
status: "failed",
error_message: message,
metadata: {
domain_id: domain.id,
subscription_id: subscription?.id ?? domain.subscription_id,
plesk_subscription_id: subscription?.plesk_subscription_id ?? null,
},
});
}
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Domain action failed",
},
{ status: 400 },
);
}
}

View File

@@ -3,7 +3,6 @@ import { DashboardControls } from "@/components/dashboard/dashboard-controls";
import { getServerAgencyContextOrRedirect } from "@/lib/agency";
import type { Database } from "@/lib/types";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import type { PostgrestError } from "@supabase/supabase-js";
type AgencySummary = Pick<
Database["public"]["Tables"]["agencies"]["Row"],
@@ -51,7 +50,7 @@ export default async function DashboardPage() {
supabase
.from("plesk_domains")
.select(
"id, plesk_instance_id, domain_name, status, hosting_type, source_created_at, aliases_count, updated_at",
"id, plesk_instance_id, subscription_id, domain_name, status, hosting_type, source_created_at, aliases_count, updated_at",
)
.eq("agency_id", context.agencyId)
.order("updated_at", { ascending: false })
@@ -101,6 +100,7 @@ export default async function DashboardPage() {
data: Array<{
id: string;
plesk_instance_id: string;
subscription_id: string | null;
domain_name: string;
status: string | null;
hosting_type: string | null;

View File

@@ -31,6 +31,7 @@ type Subscription = {
type Domain = {
id: string;
plesk_instance_id: string;
subscription_id: string | null;
domain_name: string;
status: string | null;
hosting_type: string | null;
@@ -52,15 +53,41 @@ type Props = {
function getDomainStatusBadge(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
if (normalized === "active") return "bg-emerald-100 text-emerald-700";
if (normalized.includes("suspend") || normalized.includes("disabled")) {
return "bg-rose-100 text-rose-700";
}
if (normalized === "suspended") return "bg-rose-100 text-rose-700";
if (normalized.includes("active") || normalized.includes("enabled")) {
return "bg-emerald-100 text-emerald-700";
}
if (normalized === "inactive") return "bg-amber-100 text-amber-700";
if (normalized.includes("ok")) {
return "bg-emerald-100 text-emerald-700";
}
return "bg-slate-100 text-slate-600";
}
function isDomainSuspended(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
return normalized.includes("suspend") || normalized.includes("disabled");
}
function isDomainActive(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase();
return (
normalized.includes("active") ||
normalized.includes("ok") ||
normalized.includes("enabled")
);
}
function getDomainHref(domainName: string) {
const normalized = domainName.trim();
if (/^https?:\/\//i.test(normalized)) return normalized;
return `https://${normalized}`;
}
export function DashboardControls({
instances,
subscriptions,
@@ -207,6 +234,25 @@ export function DashboardControls({
}
}
async function onDomainAction(
domainId: string,
action: "suspend" | "unsuspend",
) {
setLoading(true);
setMessage(null);
try {
await runRequest(`/api/plesk/domains/${domainId}/action`, { action });
setMessage("Domain action completed.");
window.location.reload();
} catch (error) {
setMessage(
error instanceof Error ? error.message : "Domain action failed",
);
} finally {
setLoading(false);
}
}
async function onCheckout() {
setLoading(true);
try {
@@ -493,7 +539,7 @@ export function DashboardControls({
{message ? <p className="text-sm text-slate-600">{message}</p> : null}
</div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
<div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 lg:col-span-2">
<div className="flex flex-wrap items-end gap-2">
<div className="flex-1 min-w-[220px]">
<label className="text-sm font-medium">Search domains</label>
@@ -535,20 +581,33 @@ export function DashboardControls({
<th className="px-2 py-2">Created</th>
<th className="px-2 py-2">Aliases</th>
<th className="px-2 py-2">Last seen</th>
<th className="px-2 py-2">Actions</th>
</tr>
</thead>
<tbody>
{filteredDomains.length === 0 ? (
<tr>
<td className="px-2 py-3 text-slate-500" colSpan={6}>
<td className="px-2 py-3 text-slate-500" colSpan={7}>
No domains found.
</td>
</tr>
) : (
filteredDomains.map((domain) => (
<tr key={domain.id} className="border-b border-slate-100">
<td className="font-medium text-slate-900">
{domain.domain_name}
<td className="px-2 py-2 font-medium text-slate-900">
<a
href={getDomainHref(domain.domain_name)}
target="_blank"
rel="noopener noreferrer"
className="underline decoration-slate-300 underline-offset-2 hover:text-brand-700"
>
{domain.domain_name}
</a>
{domain.subscription_id ? (
<p className="text-xs font-normal text-slate-500">
Subscription: {domain.subscription_id}
</p>
) : null}
</td>
<td className="px-2 py-2">
<span
@@ -567,6 +626,28 @@ export function DashboardControls({
<td className="px-2 py-2">
{formatDateTime(domain.updated_at)}
</td>
<td className="px-2 py-2">
<div className="flex gap-2">
<button
onClick={() => onDomainAction(domain.id, "suspend")}
disabled={
loading || isDomainSuspended(domain.status)
}
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")
}
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
</button>
</div>
</td>
</tr>
))
)}

View File

@@ -9,6 +9,7 @@ export type PleskConnection = {
};
export type PleskSubscription = {
plesk_subscription_id?: string;
id?: string | number;
guid?: string;
subscription_id?: string | number;
@@ -137,51 +138,60 @@ export async function testPleskConnection(connection: PleskConnection) {
}
export async function listPleskSubscriptions(connection: PleskConnection) {
try {
const all: PleskSubscription[] = [];
const listResult = await pleskCliCall(connection, "subscription", ["--list"]);
for (let page = 1; page <= 20; page += 1) {
const payload = await pleskRequest<PleskSubscription[]>(
connection,
`/api/v2/subscriptions?page=${page}&per_page=100`,
);
const rows = Array.isArray(payload) ? payload : [];
all.push(...rows);
if (rows.length < 100) break;
const subscriptionNames = (listResult.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const subscriptions: PleskSubscription[] = [];
for (const subscriptionName of subscriptionNames.slice(0, 500)) {
let status: "active" | "suspended" | "unknown" = "unknown";
try {
const infoResult = await pleskCliCall(connection, "subscription", [
"--info",
subscriptionName,
]);
status = parseSubscriptionStatusFromInfo(infoResult.stdout ?? "");
} catch {
status = "unknown";
}
return all;
} catch {
const cli = await pleskRequest<PleskCliResult>(
connection,
"/api/v2/cli/subscription/call",
{
method: "POST",
body: JSON.stringify({ params: ["--list"] }),
},
);
const stdout = cli?.stdout ?? "";
if (!stdout.trim()) {
return [];
}
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const columns = line.split(/\s+/);
const externalId = columns[0] ?? line;
return {
id: externalId,
subscription_id: externalId,
external_id: externalId,
name: columns.slice(1).join(" ") || externalId,
status: null,
};
});
subscriptions.push({
plesk_subscription_id: subscriptionName,
subscription_id: subscriptionName,
name: subscriptionName,
status,
});
}
return subscriptions;
}
export function parseSubscriptionStatusFromInfo(
stdout: string,
): "active" | "suspended" | "unknown" {
const line = stdout
.split(/\r?\n/)
.map((entry) => entry.trim())
.find((entry) => /^domain\s+status\s*:/i.test(entry));
if (!line) return "unknown";
const value = line
.replace(/^domain\s+status\s*:/i, "")
.trim()
.toLowerCase();
if (value.includes("ok")) return "active";
if (value.includes("suspend") || value.includes("disabled")) {
return "suspended";
}
return "unknown";
}
export async function pleskCliCall(

View File

@@ -82,14 +82,19 @@ export async function syncPleskInstance(
const subscriptionsForUpsert = subscriptionsRaw
.map((item: any) => {
const sourceId = item?.id ?? item?.guid ?? item?.subscription_id;
const sourceId =
item?.plesk_subscription_id ??
item?.name ??
item?.id ??
item?.guid ??
item?.subscription_id;
if (!sourceId) return null;
return {
agency_id: instance.agency_id,
plesk_instance_id: instance.id,
plesk_subscription_id: String(sourceId),
name: item?.name ? String(item.name) : null,
status: item?.status ? String(item.status) : null,
name: item?.name ? String(item.name) : String(sourceId),
status: item?.status ? String(item.status) : "unknown",
owner_login: item?.owner_login
? String(item.owner_login)
: item?.owner?.login
@@ -113,15 +118,44 @@ export async function syncPleskInstance(
if (error) throw new Error(error.message);
}
const { data: localSubscriptions, error: localSubscriptionsError } =
await supabase
.from("plesk_subscriptions")
.select("id, plesk_subscription_id, status")
.eq("agency_id", instance.agency_id)
.eq("plesk_instance_id", instance.id);
if (localSubscriptionsError) {
throw new Error(localSubscriptionsError.message);
}
const subscriptionByExternalId = new Map<
string,
{ id: string; status: string | null }
>(
(localSubscriptions ?? [])
.filter((row: any) => row?.plesk_subscription_id && row?.id)
.map((row: any) => [
String(row.plesk_subscription_id),
{
id: String(row.id),
status: row?.status ? String(row.status) : null,
},
]),
);
const domainsForUpsert = domainsRaw
.map((item: any) => {
const domainId = item?.id ?? item?.guid ?? item?.name;
const domainName = item?.name ?? item?.domain_name ?? item?.domain;
const status =
item?.status ??
(item?.hosting_type === "virtual" ? "active" : "inactive");
const externalSubscriptionId =
const subExternalId =
item?.subscription?.id ?? item?.subscription_id ?? item?.webspace_id;
const externalSubscriptionId = subExternalId
? String(subExternalId)
: null;
const localSubscription = externalSubscriptionId
? subscriptionByExternalId.get(externalSubscriptionId)
: undefined;
const rawCreated = item?.created_at ?? item?.created;
const sourceCreatedAt = rawCreated
? new Date(String(rawCreated)).toISOString()
@@ -138,13 +172,11 @@ export async function syncPleskInstance(
return {
agency_id: instance.agency_id,
plesk_instance_id: instance.id,
subscription_id: null,
external_subscription_id: externalSubscriptionId
? String(externalSubscriptionId)
: null,
subscription_id: localSubscription?.id ?? null,
external_subscription_id: externalSubscriptionId,
plesk_domain_id: String(domainId),
domain_name: String(domainName),
status: status ? String(status) : "unknown",
status: localSubscription?.status ?? "unknown",
hosting_type: item?.hosting_type ? String(item.hosting_type) : null,
source_created_at: sourceCreatedAt,
aliases_count: aliasesCount,

View File

@@ -0,0 +1,10 @@
-- One-time backfill: align existing domain status with linked subscription status
update public.plesk_domains d
set status = coalesce(s.status, 'unknown')
from public.plesk_subscriptions s
where d.subscription_id = s.id
and d.agency_id = s.agency_id;
update public.plesk_subscriptions
set status = 'unknown'
where status is null;

File diff suppressed because one or more lines are too long