Skip to content

Commit 519536b

Browse files
ralyodioclaude
andcommitted
feat: premium tab shows AI engine siblings + scan-run progress detail
- /audits/[id] Premium tab renders pro-scan sibling reports when the logged-in owner has them in the same scan_run_id; falls back to the existing PerformancePreview CTA otherwise. /r/[token] unchanged. - Scan-run page (/projects/[id]/runs/[runId]) now shows live elapsed time, friendly per-status copy ("Waiting for a worker…" / "Auditing your site…"), and duration on complete/failed cards. - OA-compat engine (Qwen, Kimi) gets a 4-minute per-request timeout with maxRetries=1 plus call-start/end logs so the worker can't hang indefinitely on a stalled DashScope/Moonshot completion. - runScanForProject: skip the rule-engine free-quota gate when any paid engine is in the selection — credit holders shouldn't be blocked by the daily ceiling on a free engine they didn't ask for. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ca687de commit 519536b

7 files changed

Lines changed: 288 additions & 29 deletions

File tree

‎app/(app)/audits/[id]/page.tsx‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { ReportView, type AuditRow } from "@/components/report/report-view";
55
import { MarkdownView } from "@/components/report/markdown-view";
66
import { ViewTabs } from "@/components/report/view-tabs";
77
import { PerformancePreview } from "@/components/report/performance-preview";
8+
import {
9+
PremiumEngines,
10+
type PremiumSibling,
11+
} from "@/components/report/premium-engines";
812
import { LivePoller } from "@/components/report/live-poller";
913
import { DiffView } from "@/components/report/diff-view";
1014
import { CopyLink } from "@/components/copy-link";
@@ -30,12 +34,26 @@ export default async function AuditPage({
3034

3135
const { data: audit } = await supabase
3236
.from("audits")
33-
.select("id, target_url, status, score, summary, report_markdown, completed_at, created_at, share_token, owner_id, project_id")
37+
.select("id, target_url, status, score, summary, report_markdown, completed_at, created_at, share_token, owner_id, project_id, scan_run_id, engine")
3438
.eq("id", id)
3539
.maybeSingle();
3640
if (!audit) notFound();
3741
if (audit.owner_id !== user!.id) notFound();
3842

43+
const { data: siblingsData } = audit.scan_run_id
44+
? await supabase
45+
.from("audits")
46+
.select(
47+
"id, engine, status, score, share_token, failed_reason, completed_at, summary",
48+
)
49+
.eq("scan_run_id", audit.scan_run_id)
50+
.neq("id", audit.id)
51+
.neq("engine", "rule")
52+
.eq("owner_id", user!.id)
53+
.order("created_at", { ascending: true })
54+
: { data: null };
55+
const premiumSiblings = (siblingsData ?? []) as unknown as PremiumSibling[];
56+
3957
const { data: findingsData } = await supabase
4058
.from("audit_findings")
4159
.select("section, check_key, status, title, detail, evidence, priority")
@@ -99,7 +117,16 @@ export default async function AuditPage({
99117
ownerActions={ownerActions}
100118
/>
101119
}
102-
performanceView={<PerformancePreview />}
120+
performanceView={
121+
premiumSiblings.length > 0 ? (
122+
<PremiumEngines siblings={premiumSiblings} />
123+
) : (
124+
<PerformancePreview />
125+
)
126+
}
127+
performanceLabel={
128+
premiumSiblings.length > 0 ? "AI Engines" : "Performance"
129+
}
103130
/>
104131
) : (
105132
<ReportView

‎app/(app)/projects/[id]/runs/[runId]/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type RunAudit = {
1212
share_token: string | null;
1313
failed_reason: string | null;
1414
completed_at: string | null;
15+
created_at: string;
1516
summary: { pass?: number; warn?: number; fail?: number } | null;
1617
};
1718

@@ -39,7 +40,7 @@ export default async function ScanRunPage({
3940
.eq("project_id", projectId)
4041
.order("created_at", { ascending: true });
4142

42-
const rows = (audits ?? []) as (RunAudit & { created_at: string; target_url: string })[];
43+
const rows = (audits ?? []) as (RunAudit & { target_url: string })[];
4344
if (rows.length === 0) notFound();
4445

4546
const first = rows[0];

‎app/actions/runAudit.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,11 +193,13 @@ export async function runScanForProject(input: {
193193
const hasRule = engines.includes("rule");
194194
const cost = selectionCost(engines);
195195

196-
// Rule engine: free quota check (only if rule is among the picks).
197-
if (hasRule) {
196+
// Rule engine quota gate. Only enforced when the scan is rule-only —
197+
// if the user is paying for at least one engine, the rule engine rides
198+
// along for free without consulting the daily ceiling.
199+
if (hasRule && paidEngines.length === 0) {
198200
const quota = await checkFreeManualQuota(user.id, target);
199201
if (!quota.free) {
200-
return { ok: false, error: `Free quota (${quota.cap}/day on this URL) used. Deselect 'Rule-based' or wait for the daily reset.` };
202+
return { ok: false, error: `Free quota (${quota.cap}/day on this URL) used. Pick a paid engine or wait for the daily reset.` };
201203
}
202204
}
203205

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import Link from "next/link";
2+
import { ENGINES, type Engine } from "@/lib/credits";
3+
import { ScoreBadge } from "@/components/score-badge";
4+
5+
export type PremiumSibling = {
6+
id: string;
7+
engine: Engine;
8+
status: string;
9+
score: number | null;
10+
share_token: string | null;
11+
failed_reason: string | null;
12+
completed_at: string | null;
13+
summary: { pass?: number; warn?: number; fail?: number } | null;
14+
};
15+
16+
export function PremiumEngines({ siblings }: { siblings: PremiumSibling[] }) {
17+
const completed = siblings.filter(
18+
(s) => s.status === "complete" && s.score !== null,
19+
);
20+
const avgScore =
21+
completed.length > 0
22+
? Math.round(
23+
completed.reduce((s, r) => s + (r.score ?? 0), 0) / completed.length,
24+
)
25+
: null;
26+
const runningCount = siblings.filter(
27+
(s) => s.status === "queued" || s.status === "running",
28+
).length;
29+
30+
return (
31+
<div className="space-y-6">
32+
<div className="card flex flex-wrap items-center justify-between gap-3 p-5">
33+
<div>
34+
<div className="text-xs font-semibold uppercase tracking-wider text-[var(--color-accent)]">
35+
Premium
36+
</div>
37+
<h3 className="mt-1 text-lg font-bold">AI engine reports</h3>
38+
<p className="mt-1 text-sm text-[var(--color-muted)]">
39+
{completed.length} of {siblings.length} engine
40+
{siblings.length === 1 ? "" : "s"} complete
41+
{avgScore !== null && ` · avg score ${avgScore}/100`}
42+
{runningCount > 0 && ` · ${runningCount} still running`}
43+
</p>
44+
</div>
45+
</div>
46+
47+
<div className="grid gap-3 md:grid-cols-2">
48+
{siblings.map((a) => (
49+
<EngineCard key={a.id} audit={a} />
50+
))}
51+
</div>
52+
</div>
53+
);
54+
}
55+
56+
function EngineCard({ audit }: { audit: PremiumSibling }) {
57+
const meta = ENGINES[audit.engine];
58+
const pending = audit.status === "queued" || audit.status === "running";
59+
return (
60+
<div className="card p-4">
61+
<div className="flex items-start justify-between gap-3">
62+
<div className="min-w-0">
63+
<div className="flex items-center gap-2">
64+
<h3 className="truncate text-base font-semibold">
65+
{meta?.label ?? audit.engine}
66+
</h3>
67+
{meta && (
68+
<span className="text-xs text-[var(--color-muted)]">
69+
{meta.cost === 0
70+
? "free"
71+
: `${meta.cost} credit${meta.cost === 1 ? "" : "s"}`}
72+
</span>
73+
)}
74+
</div>
75+
<p className="mt-0.5 truncate text-xs text-[var(--color-muted)]">
76+
{meta?.blurb ?? ""}
77+
</p>
78+
</div>
79+
<div className="flex shrink-0 items-center gap-2">
80+
{pending && (
81+
<span
82+
aria-hidden
83+
className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-[var(--color-border)] border-t-[var(--color-accent)]"
84+
/>
85+
)}
86+
<ScoreBadge score={audit.score} status={audit.status} />
87+
</div>
88+
</div>
89+
90+
{audit.status === "complete" && audit.summary && (
91+
<div className="mt-3 flex gap-3 text-xs text-[var(--color-muted)]">
92+
<span>
93+
<span className="font-semibold text-[var(--color-pass)]">
94+
{audit.summary.pass ?? 0}
95+
</span>{" "}
96+
pass
97+
</span>
98+
<span>
99+
<span className="font-semibold text-[var(--color-warn)]">
100+
{audit.summary.warn ?? 0}
101+
</span>{" "}
102+
warn
103+
</span>
104+
<span>
105+
<span className="font-semibold text-[var(--color-fail)]">
106+
{audit.summary.fail ?? 0}
107+
</span>{" "}
108+
fail
109+
</span>
110+
</div>
111+
)}
112+
113+
{audit.status === "failed" && audit.failed_reason && (
114+
<p className="mt-3 break-words text-xs text-[var(--color-fail)]">
115+
{audit.failed_reason}
116+
</p>
117+
)}
118+
119+
<div className="mt-3 flex items-center gap-3 text-xs">
120+
{audit.status === "complete" && (
121+
<Link
122+
href={`/audits/${audit.id}`}
123+
className="text-[var(--color-accent)] hover:underline"
124+
>
125+
View report →
126+
</Link>
127+
)}
128+
{audit.share_token && audit.status === "complete" && (
129+
<Link
130+
href={`/r/${audit.share_token}`}
131+
className="text-[var(--color-muted)] hover:underline"
132+
>
133+
Share link
134+
</Link>
135+
)}
136+
</div>
137+
</div>
138+
);
139+
}

‎components/report/view-tabs.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ export function ViewTabs({
88
markdownView,
99
structuredView,
1010
performanceView,
11+
performanceLabel = "Performance",
1112
rawMarkdownUrl,
1213
}: {
1314
markdownView: ReactNode;
1415
structuredView: ReactNode;
1516
performanceView?: ReactNode;
17+
performanceLabel?: string;
1618
rawMarkdownUrl?: string;
1719
}) {
1820
const [tab, setTab] = useState<TabKey>("report");
@@ -56,7 +58,11 @@ export function ViewTabs({
5658
<TabButton id="report" label="Report" />
5759
<TabButton id="structured" label="Structured view" />
5860
{performanceView && (
59-
<TabButton id="performance" label="Performance" badge="Premium" />
61+
<TabButton
62+
id="performance"
63+
label={performanceLabel}
64+
badge="Premium"
65+
/>
6066
)}
6167
</div>
6268
{rawMarkdownUrl && (

0 commit comments

Comments
 (0)