Compare commits

...
Author SHA1 Message Date
robbond 84a1396265 feat(ui): show subscription status in dashboard 2026-03-05 12:42:56 +00:00
robbond fec7c682f0 feat(sync): ingest subscription status during sync 2026-03-05 12:42:51 +00:00
robbond 5adc9f0923 feat(plesk): parse subscription status from CLI 2026-03-05 12:42:44 +00:00
robbond d87808af23 feat(db): add subscription status fields 2026-03-05 12:42:34 +00:00
robbond 57005859c3 Merge pull request 'Dashboard: make domains clickable links opening in new tab' (#8) from feature/domain-links into master
Reviewed-on: http://gitea.lan:3000/robbond/pleskSaas/pulls/8
2026-03-05 11:56:14 +00:00
robbond b221ad45fa Dashboard: make domains clickable links opening in new tab 2026-03-05 11:53:29 +00:00
robbond 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
robbond 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
robbond f2ad5c9d7d Merge branch 'master' into feature/plesk-status-parsing 2026-03-05 10:22:52 +00:00
robbond 3bc2cf09c3 Parse Plesk subscription status via CLI; domains inherit; UI badges 2026-03-05 10:22:18 +00:00
robbond 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
robbond a64de5aba3 Use Plesk CLI subscription info to populate status; domains inherit status; UI badges 2026-03-05 10:17:28 +00:00
5 changed files with 176 additions and 27 deletions
+19 -2
View File
@@ -57,6 +57,10 @@ function getDomainStatusBadge(status: string | null) {
return "bg-rose-100 text-rose-700"; return "bg-rose-100 text-rose-700";
} }
if (normalized.includes("expired")) {
return "bg-amber-100 text-amber-700";
}
if (normalized.includes("active") || normalized.includes("enabled")) { if (normalized.includes("active") || normalized.includes("enabled")) {
return "bg-emerald-100 text-emerald-700"; return "bg-emerald-100 text-emerald-700";
} }
@@ -82,6 +86,12 @@ function isDomainActive(status: string | null) {
); );
} }
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,
@@ -570,7 +580,7 @@ export function DashboardControls({
<thead> <thead>
<tr className="border-b border-slate-200 text-xs uppercase text-slate-500"> <tr className="border-b border-slate-200 text-xs uppercase text-slate-500">
<th className="px-2 py-2">Domain</th> <th className="px-2 py-2">Domain</th>
<th className="px-2 py-2">Status</th> <th className="px-2 py-2">Subscription Status</th>
<th className="px-2 py-2">Hosting</th> <th className="px-2 py-2">Hosting</th>
<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>
@@ -589,7 +599,14 @@ export function DashboardControls({
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="px-2 py-2 font-medium text-slate-900"> <td className="px-2 py-2 font-medium text-slate-900">
<p>{domain.domain_name}</p> <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 ? ( {domain.subscription_id ? (
<p className="text-xs font-normal text-slate-500"> <p className="text-xs font-normal text-slate-500">
Subscription: {domain.subscription_id} Subscription: {domain.subscription_id}
+68 -21
View File
@@ -16,6 +16,8 @@ export type PleskSubscription = {
external_id?: string; external_id?: string;
name?: string; name?: string;
status?: string; status?: string;
status_raw?: string | null;
status_sync_error?: string | null;
owner_login?: string; owner_login?: string;
owner?: { login?: string }; owner?: { login?: string };
plan_name?: string; plan_name?: string;
@@ -138,39 +140,37 @@ export async function testPleskConnection(connection: PleskConnection) {
} }
export async function listPleskSubscriptions(connection: PleskConnection) { export async function listPleskSubscriptions(connection: PleskConnection) {
const listOutput = await pleskCliCall(connection, "subscription", ["--list"]); const listResult = await pleskCliCall(connection, "subscription", ["--list"]);
const subscriptionNames = listOutput const subscriptionNames = (listResult.stdout ?? "")
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean); .filter(Boolean);
const subscriptions: PleskSubscription[] = []; const subscriptions: PleskSubscription[] = [];
for (const subscriptionName of subscriptionNames) { for (const subscriptionName of subscriptionNames.slice(0, 500)) {
let status = "unknown"; let status: "active" | "suspended" | "disabled" | "expired" | "unknown" =
"unknown";
let statusRaw: string | null = null;
let statusSyncError: string | null = null;
try { try {
const infoOutput = await pleskCliCall(connection, "subscription", [ const infoResult = await pleskCliCall(connection, "subscription", [
"--info", "--info",
subscriptionName, subscriptionName,
]); ]);
const normalizedInfo = infoOutput.toLowerCase(); const parsed = parseSubscriptionStatusDetailsFromInfo(
infoResult.stdout ?? "",
const hasSuspendedMarker = );
normalizedInfo.includes("suspended") || status = parsed.status;
normalizedInfo.includes("webspace-off") || statusRaw = parsed.statusRaw;
normalizedInfo.includes("disabled"); } catch (error) {
const hasActiveMarker =
normalizedInfo.includes("active") ||
normalizedInfo.includes("enabled") ||
normalizedInfo.includes("ok");
if (hasSuspendedMarker) status = "suspended";
else if (hasActiveMarker || !hasSuspendedMarker) status = "active";
} catch {
status = "unknown"; status = "unknown";
statusSyncError =
error instanceof Error
? error.message.slice(0, 1000)
: "status_sync_failed";
} }
subscriptions.push({ subscriptions.push({
@@ -178,12 +178,59 @@ export async function listPleskSubscriptions(connection: PleskConnection) {
subscription_id: subscriptionName, subscription_id: subscriptionName,
name: subscriptionName, name: subscriptionName,
status, status,
status_raw: statusRaw,
status_sync_error: statusSyncError,
}); });
} }
return subscriptions; return subscriptions;
} }
export function parseSubscriptionStatusDetailsFromInfo(stdout: string): {
status: "active" | "suspended" | "disabled" | "expired" | "unknown";
statusRaw: string | null;
} {
const line = stdout
.split(/\r?\n/)
.map((entry) => entry.trim())
.find((entry) => /^domain\s+status\s*:/i.test(entry));
if (!line) {
return { status: "unknown", statusRaw: null };
}
const value = line.replace(/^domain\s+status\s*:/i, "").trim();
const normalized = value.toLowerCase();
if (normalized.includes("expired")) {
return { status: "expired", statusRaw: value };
}
if (normalized.includes("disabled")) {
return { status: "disabled", statusRaw: value };
}
if (normalized.includes("suspend")) {
return { status: "suspended", statusRaw: value };
}
if (
normalized.includes("ok") ||
normalized.includes("active") ||
normalized.includes("enabled")
) {
return { status: "active", statusRaw: value };
}
return { status: "unknown", statusRaw: value };
}
export function parseSubscriptionStatusFromInfo(
stdout: string,
): "active" | "suspended" | "disabled" | "expired" | "unknown" {
return parseSubscriptionStatusDetailsFromInfo(stdout).status;
}
export async function pleskCliCall( export async function pleskCliCall(
connection: PleskConnection, connection: PleskConnection,
command: string, command: string,
@@ -208,7 +255,7 @@ export async function pleskCliCall(
); );
} }
return stdout; return { code, stdout, stderr };
} }
export async function listPleskDomains(connection: PleskConnection) { export async function listPleskDomains(connection: PleskConnection) {
+79 -1
View File
@@ -32,6 +32,31 @@ export async function syncPleskInstance(
): Promise<SyncResult> { ): Promise<SyncResult> {
const startedAtIso = new Date().toISOString(); const startedAtIso = new Date().toISOString();
const logStatusSyncEvent = async (
eventAction:
| "subscription_status_sync_started"
| "subscription_status_sync_completed"
| "subscription_status_sync_failed",
metadata?: Record<string, unknown>,
status: "success" | "failed" = "success",
errorMessage?: string | null,
) => {
try {
await supabase.from("actions_log").insert({
agency_id: instance.agency_id,
actor_user_id: actorUserId ?? null,
target_type: "plesk_instance",
target_id: instance.id,
action: eventAction,
status,
error_message: errorMessage ?? null,
metadata: metadata ?? {},
});
} catch {
// Logging should never block sync flow.
}
};
await supabase await supabase
.from("plesk_instances") .from("plesk_instances")
.update({ .update({
@@ -67,6 +92,10 @@ export async function syncPleskInstance(
} }
try { try {
await logStatusSyncEvent("subscription_status_sync_started", {
started_at: startedAtIso,
});
const [subscriptionsRaw, domainsRaw] = await Promise.all([ const [subscriptionsRaw, domainsRaw] = await Promise.all([
listPleskSubscriptions({ listPleskSubscriptions({
baseUrl: instance.base_url, baseUrl: instance.base_url,
@@ -94,7 +123,9 @@ export async function syncPleskInstance(
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) : String(sourceId), name: item?.name ? String(item.name) : String(sourceId),
status: item?.status ? String(item.status) : null, status: item?.status ? String(item.status) : "unknown",
status_raw: item?.status_raw ? String(item.status_raw) : null,
last_status_sync_at: startedAtIso,
owner_login: item?.owner_login owner_login: item?.owner_login
? String(item.owner_login) ? String(item.owner_login)
: item?.owner?.login : item?.owner?.login
@@ -109,6 +140,10 @@ export async function syncPleskInstance(
}) })
.filter(Boolean) as Array<Record<string, unknown>>; .filter(Boolean) as Array<Record<string, unknown>>;
const subscriptionStatusSyncFailures = subscriptionsRaw.filter(
(item: any) => Boolean(item?.status_sync_error),
);
if (subscriptionsForUpsert.length > 0) { if (subscriptionsForUpsert.length > 0) {
const { error } = await supabase const { error } = await supabase
.from("plesk_subscriptions") .from("plesk_subscriptions")
@@ -221,6 +256,40 @@ export async function syncPleskInstance(
}, },
}); });
await logStatusSyncEvent("subscription_status_sync_completed", {
synced_subscriptions: subscriptionsRaw.length,
failed_subscriptions: subscriptionStatusSyncFailures.length,
failed_subscription_ids: subscriptionStatusSyncFailures
.slice(0, 50)
.map((item: any) =>
String(
item?.plesk_subscription_id ?? item?.subscription_id ?? "unknown",
),
),
completed_at: new Date().toISOString(),
});
if (subscriptionStatusSyncFailures.length > 0) {
await logStatusSyncEvent(
"subscription_status_sync_failed",
{
failed_subscriptions: subscriptionStatusSyncFailures.length,
sample_errors: subscriptionStatusSyncFailures
.slice(0, 20)
.map((item: any) => ({
subscription: String(
item?.plesk_subscription_id ??
item?.subscription_id ??
"unknown",
),
error: String(item?.status_sync_error ?? "status_sync_failed"),
})),
},
"failed",
`${subscriptionStatusSyncFailures.length} subscription status sync failures`,
);
}
return { return {
instanceId: instance.id, instanceId: instance.id,
agencyId: instance.agency_id, agencyId: instance.agency_id,
@@ -232,6 +301,15 @@ export async function syncPleskInstance(
const message = const message =
error instanceof Error ? error.message.slice(0, 1000) : "Sync failed"; error instanceof Error ? error.message.slice(0, 1000) : "Sync failed";
await logStatusSyncEvent(
"subscription_status_sync_failed",
{
failed_at: new Date().toISOString(),
},
"failed",
message,
);
await supabase await supabase
.from("plesk_instances") .from("plesk_instances")
.update({ .update({
@@ -1,10 +1,9 @@
-- 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;
and (d.status is null or d.status = 'unknown');
update public.plesk_subscriptions update public.plesk_subscriptions
set status = 'unknown' set status = 'unknown'
@@ -0,0 +1,8 @@
alter table public.plesk_subscriptions
add column if not exists status text default 'unknown',
add column if not exists status_raw text,
add column if not exists last_status_sync_at timestamptz;
update public.plesk_subscriptions
set status = 'unknown'
where status is null;