Skip to content

Commit c5bc20a

Browse files
chore(release): v0.19.0
GitOrigin-RevId: 693365c43f2c683138ed064b4f1722e986d1e20a
1 parent 13f355c commit c5bc20a

160 files changed

Lines changed: 5800 additions & 1599 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Unreleased
44

5+
## [0.19.0] - 2026-09-01
6+
7+
- Made Search Console imports unlock useful data sooner, show truthful progress and wait reasons, and keep sync controls and freshness status in one place.
8+
9+
- Added import depth and pace choices before connecting Search Console, safe mid-run replanning, and clear Organic sessions status while GA4 catches up.
10+
11+
- Defined Visibility as a Top-20 metric that excludes shallow, failed, and unmeasured checks while showing measured keyword coverage.
12+
513
## [0.18.0] - 2026-08-31
614

715
- Added a guided project setup checklist and clearer email-delivery failures for sign-in and team invitations.

app/(auth)/login/page.test.tsx

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -114,42 +114,56 @@ describe("login page runtime rendering", () => {
114114
expect(dynamic).toBe("force-dynamic");
115115
});
116116

117-
it("uses the dark code-surface hairline for the loaded terminal chrome", async () => {
117+
it("removes the Compose demonstration from the left column", async () => {
118118
const { html } = await renderLoginPage({});
119-
const terminalStart = html.indexOf('class="mt-[26px]');
120-
const terminalEnd = html.indexOf("</pre>", terminalStart);
121-
const terminalHtml = html.slice(terminalStart, terminalEnd);
122-
123-
expect(terminalHtml).toContain("border border-code-border");
124-
expect(terminalHtml).toContain("border-code-border border-b bg-code-bg");
125-
expect(terminalHtml).not.toContain("border border-border");
126-
expect(terminalHtml).not.toContain("border-code-faint");
119+
120+
expect(html).not.toContain("~/bisibility");
121+
expect(html).not.toContain("docker compose");
122+
expect(html).not.toContain("scheduled worker");
123+
expect(html).not.toContain("supporting services");
127124
});
128125

129-
it("summarizes Compose progress around the app and scheduled worker", async () => {
126+
it("describes key ownership instead of claiming the app is self-hosted", async () => {
130127
const { html } = await renderLoginPage({});
131128

132-
expect(html).toContain("Open-source SEO platform");
133-
expect(html).toContain("docker compose -f compose.yaml -f");
134-
expect(html).toContain("compose.worker.yaml -f compose.temporal.yaml up -d");
135-
expect(html).toContain("app <span");
136-
expect(html).toContain("scheduled worker <span");
137-
expect(html).toContain("+ 6 supporting services");
138-
for (const service of [
139-
"postgres",
140-
"redis",
141-
"db-migrations",
142-
"temporal-postgres",
143-
"temporal",
144-
"temporal-ui",
145-
"worker",
146-
]) {
147-
expect(html).not.toContain(`bisibility-${service} <`);
148-
}
149-
expect(html).not.toContain("dashboard ready");
150-
expect(html).not.toContain("bisibility-private");
151-
expect(html).not.toContain("db-migrations-1");
152-
}, 15_000);
129+
expect(html).toContain("Bring your own keys");
130+
expect(html).not.toContain("Self-hosted");
131+
});
132+
133+
it("renders a safe remembered-website cue for an onboarding destination", async () => {
134+
const website = "https://www.Example.com/path?<script>alert(1)</script>";
135+
const next = `/onboarding?${new URLSearchParams({ website })}`;
136+
const { html } = await renderLoginPage({ NEXT_PUBLIC_DOMAIN_ICONS: undefined }, { next });
137+
138+
expect(html).toContain("Setting up tracking for");
139+
expect(html).toContain("example.com");
140+
expect(html).toContain("remembered-website-favicon-probe");
141+
expect(html).toContain("domain=www.example.com&amp;sz=32");
142+
expect(html).not.toContain("<script>");
143+
expect(html).not.toContain("alert(1)");
144+
});
145+
146+
it.each([
147+
{ name: "an absent destination", searchParams: {} },
148+
{ name: "an onboarding destination without a website", searchParams: { next: "/onboarding" } },
149+
{
150+
name: "an onboarding destination with an empty website",
151+
searchParams: { next: "/onboarding?website=" },
152+
},
153+
{
154+
name: "another local destination",
155+
searchParams: { next: "/app?website=example.com" },
156+
},
157+
{
158+
name: "an off-origin destination",
159+
searchParams: { next: "https://evil.example/onboarding?website=example.com" },
160+
},
161+
])("omits the remembered-website cue for $name", async ({ searchParams }) => {
162+
const { html } = await renderLoginPage({}, searchParams);
163+
164+
expect(html).not.toContain("Setting up tracking for");
165+
expect(html).not.toContain("remembered-website-favicon-probe");
166+
});
153167

154168
it("leaves the sign-in surface without a theme control", async () => {
155169
const { html } = await renderLoginPage({});

app/(auth)/login/page.tsx

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { LoginForm } from "@/components/auth/LoginForm";
2+
import { RememberedWebsiteCue } from "@/components/auth/RememberedWebsiteCue";
23
import { BrandLockup } from "@/components/ui";
34
import { isEmailSignInUnavailable } from "@/lib/auth/email-sign-in-availability";
45
import { isFirstRun } from "@/lib/auth/first-run";
5-
import { returnToOrDefault } from "@/lib/auth/return-to";
6+
import { onboardingWebsiteFromReturnTo, returnToOrDefault } from "@/lib/auth/return-to";
67
import {
78
DEV_DEMO_EMAIL,
89
DEV_FIXED_OTP_CODE,
@@ -50,11 +51,13 @@ export default async function LoginPage({ searchParams }: Readonly<LoginPageProp
5051
// An explicit switch keeps the form reachable while signed in; the app-scoped
5152
// recovery page links here when the session is the wrong account.
5253
const switchingAccount = firstParam(params?.switch) === "1";
54+
const returnTo = returnToOrDefault(next);
55+
const rememberedWebsite = onboardingWebsiteFromReturnTo(next);
5356

5457
// The sign-in endpoint is the only surface that knows about the session, so marketing
5558
// navigation can stay static: "Sign in" is always safe to click.
5659
if (!switchingAccount && (await getSession())) {
57-
return redirect(returnToOrDefault(next));
60+
return redirect(returnTo);
5861
}
5962

6063
const capacityMiss: SignInCapacityMiss =
@@ -73,7 +76,7 @@ export default async function LoginPage({ searchParams }: Readonly<LoginPageProp
7376
const brandStats: { icon: typeof GithubLogo; label: string; tone?: string }[] = [
7477
...(githubStars ? [{ icon: GithubLogo, label: `${githubStars} stars` }] : []),
7578
{ icon: ShieldCheck, label: LICENSE, tone: "text-green-text" },
76-
{ icon: LockKey, label: "Self-hosted" },
79+
{ icon: LockKey, label: "Bring your own keys" },
7780
];
7881

7982
return (
@@ -94,31 +97,6 @@ export default async function LoginPage({ searchParams }: Readonly<LoginPageProp
9497
Daily Google positions for every keyword that matters, in a dashboard your whole team
9598
can read.
9699
</p>
97-
98-
<div className="mt-[26px] overflow-hidden rounded-card border border-code-border">
99-
<div className="flex items-center gap-[7px] border-code-border border-b bg-code-bg px-3.5 py-[9px]">
100-
<span className="h-2.5 w-2.5 rounded-full bg-red" />
101-
<span className="h-2.5 w-2.5 rounded-full bg-yellow" />
102-
<span className="h-2.5 w-2.5 rounded-full bg-green" />
103-
<span className="ml-1.5 font-mono text-[11px] text-code-faint">~/bisibility</span>
104-
</div>
105-
<pre className="m-0 overflow-x-auto bg-code-bg px-4 py-[15px] font-mono text-[12.5px] leading-[1.7] text-code-fg">
106-
<span className="text-code-faint"># self-host in one command</span>
107-
{"\n"}
108-
<span className="text-accent-text">$</span> docker compose -f compose.yaml -f
109-
compose.worker.yaml -f compose.temporal.yaml up -d
110-
{"\n"}
111-
<span className="block">
112-
<span className="text-blue-text"></span> app{" "}
113-
<span className="text-green-text">started</span>
114-
</span>
115-
<span className="block">
116-
<span className="text-blue-text"></span> scheduled worker{" "}
117-
<span className="text-green-text">started</span>
118-
</span>
119-
<span className="block text-code-faint">{" + 6 supporting services"}</span>
120-
</pre>
121-
</div>
122100
</div>
123101

124102
<div className="flex items-center gap-4.5 font-mono text-[11.5px] text-fg-muted">
@@ -132,18 +110,21 @@ export default async function LoginPage({ searchParams }: Readonly<LoginPageProp
132110
</section>
133111

134112
<section className="relative flex items-center justify-center px-6 py-11">
135-
<LoginForm
136-
capacity={capacity}
137-
capacityMiss={capacityMiss}
138-
demoEmail={DEV_DEMO_EMAIL}
139-
devOtpCode={DEV_FIXED_OTP_CODE}
140-
dataResidencyMessage={dataResidencyMessage()}
141-
emailSignInUnavailable={emailSignInUnavailable}
142-
enabledProviders={ENABLED_SOCIAL_PROVIDERS}
143-
humanVerificationRequired={isCloud}
144-
legalConsentLinks={legalConsentLinks()}
145-
returnTo={returnToOrDefault(next)}
146-
/>
113+
<div className="w-full max-w-[380px]">
114+
{rememberedWebsite ? <RememberedWebsiteCue website={rememberedWebsite} /> : null}
115+
<LoginForm
116+
capacity={capacity}
117+
capacityMiss={capacityMiss}
118+
demoEmail={DEV_DEMO_EMAIL}
119+
devOtpCode={DEV_FIXED_OTP_CODE}
120+
dataResidencyMessage={dataResidencyMessage()}
121+
emailSignInUnavailable={emailSignInUnavailable}
122+
enabledProviders={ENABLED_SOCIAL_PROVIDERS}
123+
humanVerificationRequired={isCloud}
124+
legalConsentLinks={legalConsentLinks()}
125+
returnTo={returnTo}
126+
/>
127+
</div>
147128
</section>
148129
</main>
149130
);

app/app/(workspace)/[project]/search-console/SearchInsightsSections.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { WorkerTemporalStatus } from "@/lib/ops/worker-temporal-identity";
88
import type { SearchInsightsImportState } from "@/lib/search-insights/queries/context";
99
import type { SearchInsightsFirstView } from "@/lib/search-insights/queries/first-view";
1010
import type { SearchInsightsOauthReturn as OauthReturn } from "@/lib/search-insights/queries/oauth-return";
11+
import type { SearchSyncControlFacts } from "@/lib/search-insights/sync/control-model";
1112
import type { SearchSyncPreflightPlan } from "@/lib/search-insights/sync/plan";
1213

1314
/**
@@ -21,6 +22,7 @@ export async function SearchInsightsTrustStripSection({
2122
projectId,
2223
resumeAction,
2324
retryAction,
25+
statusFacts,
2426
view,
2527
workerStatus,
2628
}: Readonly<{
@@ -29,6 +31,7 @@ export async function SearchInsightsTrustStripSection({
2931
resumeAction: SearchInsightsImportAction;
3032
retryAction: SearchInsightsImportAction;
3133
projectId: string;
34+
statusFacts: SearchSyncControlFacts;
3235
view: Promise<SearchInsightsFirstView>;
3336
workerStatus: WorkerTemporalStatus;
3437
}>) {
@@ -37,7 +40,7 @@ export async function SearchInsightsTrustStripSection({
3740
<SearchInsightsTrustStrip
3841
coverage={data.coverage}
3942
deploymentMode={data.deploymentMode}
40-
localViewReady={Boolean(importState?.firstViewReady)}
43+
localViewReady={importState?.facts?.readyThrough.d7.current === true}
4144
providerAvailabilitySource={importState?.availabilityBoundarySource ?? null}
4245
providerAvailableThrough={importState?.newestFinalizedDate ?? null}
4346
importState={importState}
@@ -46,6 +49,7 @@ export async function SearchInsightsTrustStripSection({
4649
projectId={projectId}
4750
resumeAction={resumeAction}
4851
retryAction={retryAction}
52+
statusFacts={statusFacts}
4953
workerStatus={workerStatus}
5054
/>
5155
);

app/app/(workspace)/[project]/search-console/page.test.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const mocks = vi.hoisted(() => ({
3737
retryImport: vi.fn(),
3838
trustSection: vi.fn(),
3939
workspace: vi.fn(),
40+
workflowStatus: vi.fn(),
4041
}));
4142

4243
vi.mock("@/components/search-insights/SearchInsightsEmptyStates", () => ({
@@ -116,6 +117,9 @@ vi.mock("@/lib/ops/worker-temporal-identity", () => ({
116117
vi.mock("@/lib/temporal/deployment-config", () => ({
117118
temporalDeploymentConfig: mocks.deploymentConfig,
118119
}));
120+
vi.mock("@/lib/temporal/search-insights-status", () => ({
121+
describeSearchInsightsBackfillStatus: mocks.workflowStatus,
122+
}));
119123
vi.mock("@/lib/queries/cost-calculator", () => ({ getProjectCostContext: mocks.costContext }));
120124
vi.mock("@/lib/queries/keywords", () => ({ getKeywordDefaultMarket: mocks.defaultMarket }));
121125
vi.mock("@/lib/queries/project-markets", () => ({ getProjectMarkets: mocks.markets }));
@@ -187,6 +191,7 @@ describe("SearchInsightsPage", () => {
187191
"app: default / rank-checks / alert-deliveries · worker: default / rank-checks / alert-deliveries",
188192
status: "match",
189193
});
194+
mocks.workflowStatus.mockResolvedValue("running");
190195
mocks.markets.mockResolvedValue({ markets: [], maxMarkets: 5 });
191196
mocks.defaultMarket.mockResolvedValue({ device: "desktop", locationKey: "us-en" });
192197
mocks.costContext.mockResolvedValue({ costPerCheckCents: null, depth: 100 });
@@ -382,11 +387,13 @@ describe("SearchInsightsPage", () => {
382387
expect(mocks.noData).toHaveBeenCalledWith(
383388
expect.objectContaining({
384389
facts: expect.objectContaining({
385-
completedDays: 0,
386390
connectionStatus: "connected",
387-
firstViewReady: false,
391+
observability: undefined,
392+
runtime: expect.objectContaining({
393+
workerStatus: expect.objectContaining({ status: "ok" }),
394+
workflowStatus: "running",
395+
}),
388396
state: undefined,
389-
workerStatus: expect.objectContaining({ status: "ok" }),
390397
}),
391398
pauseAction: mocks.pauseImport,
392399
projectId: "prj_1",

app/app/(workspace)/[project]/search-console/page.tsx

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import {
3333
import { loadSearchInsightsRows } from "@/lib/actions/search-insights-rows";
3434
import { getProjectRole } from "@/lib/auth/authorize";
3535
import { canProjectAction } from "@/lib/auth/capabilities";
36-
import { deploymentMode } from "@/lib/deployment/deployment";
3736
import { getWorkerLivenessDetails } from "@/lib/ops/liveness";
3837
import { compareWorkerTemporalIdentity } from "@/lib/ops/worker-temporal-identity";
3938
import { requireReadableProject, resolveProjectAccess } from "@/lib/queries/_auth";
@@ -48,6 +47,7 @@ import { getSearchInsightsFirstView } from "@/lib/search-insights/queries/first-
4847
import { resolveSearchInsightsOauthReturn } from "@/lib/search-insights/queries/oauth-return";
4948
import { loadSearchSyncPreflightPlan } from "@/lib/settings/search-sync-metrics";
5049
import { temporalDeploymentConfig } from "@/lib/temporal/deployment-config";
50+
import { describeSearchInsightsBackfillStatus } from "@/lib/temporal/search-insights-status";
5151
import { Suspense } from "react";
5252
import {
5353
SearchInsightsBodySection,
@@ -135,12 +135,25 @@ export default async function SearchInsightsPage({
135135
...(period ? { period } : {}),
136136
}),
137137
);
138-
const [context, drawers] = await Promise.all([
138+
const [context, drawers, workflowStatus] = await Promise.all([
139139
getSearchInsightsContext(publicId, { period, property: first(query.property), scope }),
140140
connected && scope.property
141141
? searchInsightsDrawerProps(publicId, scope.period.id, scope.property)
142142
: undefined,
143+
scope.property
144+
? describeSearchInsightsBackfillStatus(scope.projectId, scope.property)
145+
: Promise.resolve("unknown" as const),
143146
]);
147+
const statusFacts = {
148+
connectionStatus: context.connection.status,
149+
observability: context.importState?.facts ?? undefined,
150+
pauseStartedAt: context.importState?.pauseStartedAt,
151+
pausedReason: context.importState?.pausedReason,
152+
queue: scope.queue ?? undefined,
153+
runtime: { workerStatus, workflowStatus },
154+
safeError: context.importState?.safeError,
155+
state: context.importState?.state,
156+
};
144157

145158
// Started, not awaited: the first render must never wait on the sixteen-month import, and
146159
// both streamed sections read this one result.
@@ -173,6 +186,7 @@ export default async function SearchInsightsPage({
173186
resumeAction={resumeSearchInsightsImport}
174187
retryAction={retrySearchInsightsImport}
175188
projectId={publicId}
189+
statusFacts={statusFacts}
176190
view={view}
177191
workerStatus={workerStatus}
178192
/>
@@ -200,19 +214,7 @@ export default async function SearchInsightsPage({
200214
) : null}
201215
{view && !context.window ? (
202216
<SearchInsightsNoDataState
203-
facts={{
204-
completedDays: context.importState?.completedDays ?? 0,
205-
connectionStatus: context.connection.status,
206-
deploymentMode: deploymentMode(),
207-
firstViewReady: context.importState?.firstViewReady === true,
208-
lastActivityAt: context.importState?.lastActivityAt,
209-
pauseStartedAt: context.importState?.pauseStartedAt,
210-
pausedReason: context.importState?.pausedReason,
211-
safeError: context.importState?.safeError,
212-
state: context.importState?.state,
213-
waiting: context.importState?.waiting,
214-
workerStatus,
215-
}}
217+
facts={statusFacts}
216218
pauseAction={pauseSearchInsightsImport}
217219
projectId={publicId}
218220
resumeAction={resumeSearchInsightsImport}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { DataSourcesSettingsRouteLoading as default } from "@/components/settings/data-sources/DataSourcesSettingsLoading";

0 commit comments

Comments
 (0)