Compare commits

..

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

View File

@@ -31,6 +31,7 @@ type Subscription = {
type Domain = { type Domain = {
id: string; id: string;
plesk_instance_id: string; plesk_instance_id: string;
subscription_id: string | null;
domain_name: string; domain_name: string;
status: string | null; status: string | null;
hosting_type: string | null; hosting_type: string | null;
@@ -52,23 +53,41 @@ type Props = {
function getDomainStatusBadge(status: string | null) { function getDomainStatusBadge(status: string | null) {
const normalized = (status ?? "unknown").toLowerCase(); const normalized = (status ?? "unknown").toLowerCase();
if ( if (normalized.includes("suspend") || normalized.includes("disabled")) {
normalized.includes("active") ||
normalized.includes("ok") ||
normalized.includes("enabled")
) {
return "bg-emerald-100 text-emerald-700";
}
if (normalized.includes("suspended") || normalized.includes("disabled")) {
return "bg-rose-100 text-rose-700"; return "bg-rose-100 text-rose-700";
} }
if (normalized.includes("inactive")) return "bg-amber-100 text-amber-700"; if (normalized.includes("active") || normalized.includes("enabled")) {
return "bg-emerald-100 text-emerald-700";
}
if (normalized.includes("ok")) {
return "bg-emerald-100 text-emerald-700";
}
return "bg-slate-100 text-slate-600"; 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({ export function DashboardControls({
instances, instances,
subscriptions, subscriptions,
@@ -215,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() { async function onCheckout() {
setLoading(true); setLoading(true);
try { try {
@@ -501,7 +539,7 @@ export function DashboardControls({
{message ? <p className="text-sm text-slate-600">{message}</p> : null} {message ? <p className="text-sm text-slate-600">{message}</p> : null}
</div> </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 flex-wrap items-end gap-2">
<div className="flex-1 min-w-[220px]"> <div className="flex-1 min-w-[220px]">
<label className="text-sm font-medium">Search domains</label> <label className="text-sm font-medium">Search domains</label>
@@ -543,20 +581,33 @@ export function DashboardControls({
<th className="px-2 py-2">Created</th> <th className="px-2 py-2">Created</th>
<th className="px-2 py-2">Aliases</th> <th className="px-2 py-2">Aliases</th>
<th className="px-2 py-2">Last seen</th> <th className="px-2 py-2">Last seen</th>
<th className="px-2 py-2">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredDomains.length === 0 ? ( {filteredDomains.length === 0 ? (
<tr> <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. No domains found.
</td> </td>
</tr> </tr>
) : ( ) : (
filteredDomains.map((domain) => ( filteredDomains.map((domain) => (
<tr key={domain.id} className="border-b border-slate-100"> <tr key={domain.id} className="border-b border-slate-100">
<td className="font-medium text-slate-900"> <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} {domain.domain_name}
</a>
{domain.subscription_id ? (
<p className="text-xs font-normal text-slate-500">
Subscription: {domain.subscription_id}
</p>
) : null}
</td> </td>
<td className="px-2 py-2"> <td className="px-2 py-2">
<span <span
@@ -575,6 +626,28 @@ export function DashboardControls({
<td className="px-2 py-2"> <td className="px-2 py-2">
{formatDateTime(domain.updated_at)} {formatDateTime(domain.updated_at)}
</td> </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> </tr>
)) ))
)} )}

View File

@@ -9,6 +9,7 @@ export type PleskConnection = {
}; };
export type PleskSubscription = { export type PleskSubscription = {
plesk_subscription_id?: string;
id?: string | number; id?: string | number;
guid?: string; guid?: string;
subscription_id?: string | number; subscription_id?: string | number;
@@ -137,51 +138,60 @@ export async function testPleskConnection(connection: PleskConnection) {
} }
export async function listPleskSubscriptions(connection: PleskConnection) { export async function listPleskSubscriptions(connection: PleskConnection) {
try { const listResult = await pleskCliCall(connection, "subscription", ["--list"]);
const all: PleskSubscription[] = [];
for (let page = 1; page <= 20; page += 1) { const subscriptionNames = (listResult.stdout ?? "")
const payload = await pleskRequest<PleskSubscription[]>(
connection,
`/api/v2/subscriptions?page=${page}&per_page=100`,
);
const rows = Array.isArray(payload) ? payload : [];
all.push(...rows);
if (rows.length < 100) break;
}
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/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean);
.map((line) => {
const columns = line.split(/\s+/); const subscriptions: PleskSubscription[] = [];
const externalId = columns[0] ?? line;
return { for (const subscriptionName of subscriptionNames.slice(0, 500)) {
id: externalId, let status: "active" | "suspended" | "unknown" = "unknown";
subscription_id: externalId,
external_id: externalId, try {
name: columns.slice(1).join(" ") || externalId, const infoResult = await pleskCliCall(connection, "subscription", [
status: null, "--info",
}; subscriptionName,
]);
status = parseSubscriptionStatusFromInfo(infoResult.stdout ?? "");
} catch {
status = "unknown";
}
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( export async function pleskCliCall(

View File

@@ -82,14 +82,19 @@ export async function syncPleskInstance(
const subscriptionsForUpsert = subscriptionsRaw const subscriptionsForUpsert = subscriptionsRaw
.map((item: any) => { .map((item: any) => {
const sourceId = item?.id ?? item?.guid ?? item?.subscription_id; const sourceId =
item?.plesk_subscription_id ??
item?.name ??
item?.id ??
item?.guid ??
item?.subscription_id;
if (!sourceId) return null; if (!sourceId) return null;
return { return {
agency_id: instance.agency_id, agency_id: instance.agency_id,
plesk_instance_id: instance.id, plesk_instance_id: instance.id,
plesk_subscription_id: String(sourceId), plesk_subscription_id: String(sourceId),
name: item?.name ? String(item.name) : null, name: item?.name ? String(item.name) : String(sourceId),
status: item?.status ? String(item.status) : null, status: item?.status ? String(item.status) : "unknown",
owner_login: item?.owner_login owner_login: item?.owner_login
? String(item.owner_login) ? String(item.owner_login)
: item?.owner?.login : item?.owner?.login

View File

@@ -1,6 +1,10 @@
-- One-time backfill: align existing domain status with linked subscription status -- One-time backfill: align existing domain status with linked subscription status
update public.plesk_domains d update public.plesk_domains d
set status = s.status set status = coalesce(s.status, 'unknown')
from public.plesk_subscriptions s from public.plesk_subscriptions s
where d.subscription_id = s.id where d.subscription_id = s.id
and d.agency_id = s.agency_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