Skip to content

Commit 3621a45

Browse files
authored
feat: branch deployment previews via @branch path segments (#9011)
* refactor: convert project layout to Svelte 5 runes and extract shared utilities - Migrate `+layout.svelte` from Svelte 4 to Svelte 5 runes (`$derived`, `$effect`, `$props`) - Extract `baseGetProjectQueryOptions` polling config to `project-query-options.ts` - Extract `resolveRuntimeConnection` to `project-runtime.ts` with unit tests - Remove duplicate mock query chain from `ProjectHeader` (layout already passes mock-aware permissions) - Add section comments and component-level doc block to layout * chore: clean up `features/projects` directory - Delete `invalidations.ts` (dead code, zero consumers) - Delete `ResourceError.svelte` (dead code, shadowed by `web-common` version) - Move `constants.ts` to `user-management/constants.ts` (closer to its only consumer) * fix: update remaining `constants` import paths after file move * fix: update `ResourceError` import to use `web-common` version * refactor: bundle mock user params in `resolveRuntimeConnection` Replace three positional mock params (userId, credentials, permissions) with a single `mockUser` object. The caller constructs it or passes undefined, so the "all or nothing" relationship is enforced by the type system rather than a runtime check. * refactor: remove unnecessary derived wrappers in project layout Remove `pageData`, `pathname`, and `projectError` — pure aliases that add indirection without simplifying anything. Use `page.data`, `page.url.pathname`, and `$projectQuery.error` directly. * feat: add branch deployment selector and Deployments management page Branch deployment UI: - Add BranchSelector component in project header breadcrumbs - Extract branch from URL's @Branch path segment via `extractBranchFromPath` - Inject active branch into intra-project navigations via `handleBranchNavigation` - Pass branch param to GetProject and GetDeploymentCredentials queries - Add BranchDeploymentStopped component for stopped/stopping states - Keep BranchSelector's ListDeployments query in sync on status transitions - Poll during STOPPING status Deployments page: - Add Deployments management page under Status tab - Add `createSmartRefetchInterval` for filtered resource polling - Add branch deployment hint to Deployments page - Extract shared deployment utilities to `deployment-utils.ts` * fix: eliminate branch switch lag by optimistically seeding project cache When the user selects a branch in the dropdown, seed the GetProject query cache with the deployment data already available from ListDeployments. This lets the layout's RuntimeProvider re-key immediately instead of waiting for the network round-trip. * Revert "fix: eliminate branch switch lag by optimistically seeding project cache" This reverts commit ad43959. * refactor: simplify `DeploymentsSection` and migrate to Svelte 5 - Migrate to Svelte 5 runes ($props, $derived, $state) - Extract cache manipulation into deployment-actions.ts - Replace 4 tracking Sets with single pendingId string - Replace deletedIds with optimistic cache removal - Consolidate delete dialog state into single nullable object - Fix CopyableCodeBlock on:click -> onclick (Svelte 5) - Fix parseInt missing radix, redundant isProdDeployment call - Fix status overview showing "main" instead of deployment branch - Fix polling gap: keep polling when only ProjectParser exists during startup - Show loading spinner instead of "no dashboards" during initial build * feat: show spinner for transitory deployment statuses Replace the static yellow dot with a `LoadingCircleOutline` spinner for in-progress states (Pending, Updating, Stopping, Deleting) in both the Deployments table and overview card. * fix: surface project parser errors on deployment overview page When a branch deployment's runtime is running but the project failed to load (e.g., git branch doesn't exist), the overview page was misleadingly showing "Ready" with stale data. Now: - Deployment card shows parser error in a Callout, replacing project-level rows (Last synced, OLAP, AI) that would be defaults - Infrastructure status stays truthful (separate from project health) - Resources/Tables/Errors sections hidden when project failed to load - Download Project button disabled instead of hidden - ErrorsSection no longer has a confusing parent click handler * fix: review feedback — deduplicate status set, fix dark mode border, add deployment-utils tests - Replace `TRANSIENT_STATUSES` Set with existing `isTransitoryStatus()` in `DeploymentsSection` - Use semantic `border-border` instead of `border-gray-200` on data rows - Use index fallback for `{#each}` key when `deployment.id` is undefined - Add `deduplicateDeployments` unit tests - Document web-admin vitest usage in CLAUDE.md * fix: revert unrelated `download-report.ts` change from bad merge * fix: review feedback — data-driven tests, remove `deduplicateDeployments` Restructure branch-utils tests to use shared test data array per reviewer suggestion. Remove `deduplicateDeployments` since the backend guards against duplicates at creation time (a DB constraint follow-up is needed for race conditions). * fix: review feedback — rename `mutateFn`, extract delete dialog, sort branches alphabetically
1 parent faabb5c commit 3621a45

31 files changed

Lines changed: 2064 additions & 213 deletions

.claude/CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ Two deployment modes share the same codebase:
3535
- **Local dev**: `rill devtool start local`
3636
- **Cloud dev**: `rill devtool start cloud`
3737
- **Test Go**: `go test ./...`
38-
- **Test frontend (unit)**: `npm run test -w web-common` (fast, use for tight feedback loops)
38+
- **Test frontend (unit, web-common)**: `npm run test -w web-common` (fast, use for tight feedback loops)
39+
- **Test frontend (unit, web-admin)**: `cd web-admin && npx vitest run src/path/to/spec.ts` (must run from `web-admin/` so vitest picks up the `@rilldata/web-admin` alias)
3940
- **Test frontend (e2e)**: `npm run test -w web-local` or `npm run test -w web-admin` (Playwright, slow)
4041
- **Lint/format frontend**: `npm run quality`
4142
- **Regenerate docs**: `make docs.generate` (run after changes to `proto/`, `cli/` or `runtime/parser`)

web-admin/src/features/authentication/AvatarButton.svelte

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@
2222
type UserLike,
2323
} from "@rilldata/web-common/features/help/initPylonChat";
2424
import { posthogIdentify } from "@rilldata/web-common/lib/analytics/posthog";
25-
import { createAdminServiceGetCurrentUser } from "../../client";
26-
import ProjectAccessControls from "../projects/ProjectAccessControls.svelte";
25+
import {
26+
createAdminServiceGetCurrentUser,
27+
type V1ProjectPermissions,
28+
} from "../../client";
2729
import ViewAsUserPopover from "../view-as-user/ViewAsUserPopover.svelte";
2830
import ThemeToggle from "@rilldata/web-common/features/themes/ThemeToggle.svelte";
2931
32+
export let projectPermissions: V1ProjectPermissions | undefined = undefined;
33+
3034
const user = createAdminServiceGetCurrentUser();
3135
3236
let imgContainer: HTMLElement;
@@ -85,35 +89,30 @@
8589
<div bind:this={imgContainer} class="h-7 w-7"></div>
8690
</DropdownMenu.Trigger>
8791
<DropdownMenu.Content align="end">
88-
{#if params.organization && params.project}
89-
<ProjectAccessControls
90-
organization={params.organization}
91-
project={params.project}
92-
>
93-
<svelte:fragment slot="manage-project">
94-
<DropdownMenu.Sub bind:open={subMenuOpen}>
95-
<DropdownMenu.SubTrigger
96-
onclick={() => {
97-
subMenuOpen = !subMenuOpen;
92+
{#if params.organization && params.project && projectPermissions}
93+
{#if projectPermissions.manageProject}
94+
<DropdownMenu.Sub bind:open={subMenuOpen}>
95+
<DropdownMenu.SubTrigger
96+
onclick={() => {
97+
subMenuOpen = !subMenuOpen;
98+
}}
99+
>
100+
View as
101+
</DropdownMenu.SubTrigger>
102+
<DropdownMenu.SubContent
103+
class="flex flex-col min-w-[150px] max-w-[300px]"
104+
>
105+
<ViewAsUserPopover
106+
organization={params.organization}
107+
project={params.project}
108+
onSelectUser={() => {
109+
subMenuOpen = false;
110+
primaryMenuOpen = false;
98111
}}
99-
>
100-
View as
101-
</DropdownMenu.SubTrigger>
102-
<DropdownMenu.SubContent
103-
class="flex flex-col min-w-[150px] max-w-[300px]"
104-
>
105-
<ViewAsUserPopover
106-
organization={params.organization}
107-
project={params.project}
108-
onSelectUser={() => {
109-
subMenuOpen = false;
110-
primaryMenuOpen = false;
111-
}}
112-
/>
113-
</DropdownMenu.SubContent>
114-
</DropdownMenu.Sub>
115-
</svelte:fragment>
116-
</ProjectAccessControls>
112+
/>
113+
</DropdownMenu.SubContent>
114+
</DropdownMenu.Sub>
115+
{/if}
117116
{#if params.dashboard}
118117
<DropdownMenu.Item
119118
href={`/${params.organization}/${params.project}/-/alerts`}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<script lang="ts">
2+
import {
3+
createAdminServiceStartDeployment,
4+
getAdminServiceGetProjectQueryKey,
5+
V1DeploymentStatus,
6+
type V1GetProjectResponse,
7+
} from "@rilldata/web-admin/client";
8+
import { invalidateDeployments } from "./deployment-utils";
9+
import { Button } from "@rilldata/web-common/components/button";
10+
import CtaContentContainer from "@rilldata/web-common/components/calls-to-action/CTAContentContainer.svelte";
11+
import CtaHeader from "@rilldata/web-common/components/calls-to-action/CTAHeader.svelte";
12+
import CtaLayoutContainer from "@rilldata/web-common/components/calls-to-action/CTALayoutContainer.svelte";
13+
import Spinner from "@rilldata/web-common/features/entity-management/Spinner.svelte";
14+
import { EntityStatus } from "@rilldata/web-common/features/entity-management/types";
15+
import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts";
16+
17+
export let organization: string;
18+
export let project: string;
19+
export let deploymentId: string;
20+
export let status: V1DeploymentStatus;
21+
export let canManage: boolean;
22+
export let branch: string | undefined;
23+
export let onStarted: (() => void) | undefined = undefined;
24+
25+
$: isStopping = status === V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPING;
26+
27+
const startMutation = createAdminServiceStartDeployment();
28+
29+
function handleStart() {
30+
$startMutation.mutate(
31+
{ deploymentId, data: {} },
32+
{
33+
onSuccess: () => {
34+
onStarted?.();
35+
36+
const projectQueryKey = getAdminServiceGetProjectQueryKey(
37+
organization,
38+
project,
39+
branch ? { branch } : undefined,
40+
);
41+
42+
// Without this, the invalidation refetch may return the old STOPPED
43+
// status (race condition), leaving the UI stuck on this page.
44+
queryClient.setQueryData<V1GetProjectResponse>(
45+
projectQueryKey,
46+
(old) => {
47+
if (!old?.deployment) return old;
48+
return {
49+
...old,
50+
deployment: {
51+
...old.deployment,
52+
status: V1DeploymentStatus.DEPLOYMENT_STATUS_PENDING,
53+
},
54+
};
55+
},
56+
);
57+
58+
// Mark stale without immediate refetch; PENDING triggers polling
59+
// (1–2s) which picks up the real server status.
60+
void queryClient.invalidateQueries({
61+
queryKey: projectQueryKey,
62+
refetchType: "none",
63+
});
64+
65+
void invalidateDeployments(organization, project);
66+
},
67+
},
68+
);
69+
}
70+
</script>
71+
72+
<CtaLayoutContainer>
73+
<CtaContentContainer>
74+
{#if isStopping}
75+
<div class="h-16">
76+
<Spinner status={EntityStatus.Running} size="3rem" duration={725} />
77+
</div>
78+
<CtaHeader variant="bold">Deployment is stopping...</CtaHeader>
79+
{:else}
80+
<CtaHeader variant="bold">Deployment stopped</CtaHeader>
81+
<p class="text-sm text-fg-secondary">
82+
This branch deployment is not running.
83+
</p>
84+
{#if canManage}
85+
<Button
86+
type="primary"
87+
loading={$startMutation.isPending}
88+
loadingCopy="Starting..."
89+
onClick={handleStart}
90+
>
91+
Start deployment
92+
</Button>
93+
{/if}
94+
{/if}
95+
</CtaContentContainer>
96+
</CtaLayoutContainer>
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<script lang="ts">
2+
import { page } from "$app/stores";
3+
import CaretDownIcon from "@rilldata/web-common/components/icons/CaretDownIcon.svelte";
4+
import * as DropdownMenu from "@rilldata/web-common/components/dropdown-menu";
5+
import {
6+
extractBranchFromPath,
7+
injectBranchIntoPath,
8+
removeBranchFromPath,
9+
requestSkipBranchInjection,
10+
} from "./branch-utils";
11+
import { isProdDeployment } from "./deployment-utils";
12+
import { getStatusDotClass } from "../projects/status/display-utils";
13+
import {
14+
V1DeploymentStatus,
15+
createAdminServiceListDeployments,
16+
type V1Deployment,
17+
} from "../../client";
18+
19+
export let organization: string;
20+
export let project: string;
21+
export let primaryBranch: string | undefined = undefined;
22+
23+
let open = false;
24+
25+
$: activeBranch = extractBranchFromPath($page.url.pathname);
26+
27+
// Poll at 2s only while the dropdown is open (so the user sees live status
28+
// transitions). When closed, the cached data is sufficient; freshness is
29+
// maintained by invalidateDeployments() calls after create/delete mutations.
30+
$: deploymentsQuery = createAdminServiceListDeployments(
31+
organization,
32+
project,
33+
{},
34+
{
35+
query: {
36+
enabled: !!organization && !!project,
37+
refetchInterval: open ? 2000 : false,
38+
},
39+
},
40+
);
41+
42+
$: deployments = $deploymentsQuery.data?.deployments ?? [];
43+
44+
$: hasBranchDeployments = deployments.some(
45+
(d) => d.branch && d.branch !== primaryBranch,
46+
);
47+
48+
$: isOnBranch = !!activeBranch && activeBranch !== primaryBranch;
49+
50+
// Sort: production first, then alphabetically by branch name
51+
$: sortedDeployments = [...deployments].sort((a, b) => {
52+
const aIsProd = isProdDeployment(a);
53+
const bIsProd = isProdDeployment(b);
54+
if (aIsProd && !bIsProd) return -1;
55+
if (!aIsProd && bIsProd) return 1;
56+
return (a.branch ?? "").localeCompare(b.branch ?? "");
57+
});
58+
59+
// Current branch label for the trigger
60+
$: currentDeployment = isOnBranch
61+
? deployments.find((d) => d.branch === activeBranch)
62+
: deployments.find(isProdDeployment);
63+
$: triggerLabel = isOnBranch
64+
? truncateBranch(activeBranch ?? "")
65+
: truncateBranch(primaryBranch ?? "");
66+
67+
function truncateBranch(branch: string): string {
68+
if (branch.length <= 20) return branch;
69+
return branch.slice(0, 19) + "";
70+
}
71+
72+
function getDeploymentHref(deployment: V1Deployment): string {
73+
const basePath = removeBranchFromPath($page.url.pathname);
74+
if (isProdDeployment(deployment)) return basePath + $page.url.search;
75+
return (
76+
injectBranchIntoPath(basePath, deployment.branch!) + $page.url.search
77+
);
78+
}
79+
80+
function handleClick(deployment: V1Deployment) {
81+
if (isProdDeployment(deployment)) {
82+
requestSkipBranchInjection();
83+
}
84+
open = false;
85+
}
86+
87+
function statusDot(status: V1DeploymentStatus | undefined): string {
88+
return getStatusDotClass(
89+
status ?? V1DeploymentStatus.DEPLOYMENT_STATUS_UNSPECIFIED,
90+
);
91+
}
92+
</script>
93+
94+
{#if hasBranchDeployments || isOnBranch}
95+
<li class="branch-selector">
96+
<DropdownMenu.Root bind:open>
97+
<DropdownMenu.Trigger>
98+
{#snippet child({ props })}
99+
<button {...props} class="chip">
100+
<span class="status-dot {statusDot(currentDeployment?.status)}"
101+
></span>
102+
<span>{triggerLabel}</span>
103+
<span class="caret" class:open>
104+
<CaretDownIcon size="10px" />
105+
</span>
106+
</button>
107+
{/snippet}
108+
</DropdownMenu.Trigger>
109+
<DropdownMenu.Content align="start" class="min-w-[200px] max-w-[300px]">
110+
<DropdownMenu.Group>
111+
<DropdownMenu.Label>All branches</DropdownMenu.Label>
112+
</DropdownMenu.Group>
113+
{#each sortedDeployments as deployment (deployment.id)}
114+
{@const prod = isProdDeployment(deployment)}
115+
{@const isSelected = prod
116+
? !isOnBranch
117+
: activeBranch === deployment.branch}
118+
<DropdownMenu.CheckboxItem
119+
checked={isSelected}
120+
href={getDeploymentHref(deployment)}
121+
onclick={() => handleClick(deployment)}
122+
class="flex items-center gap-x-2"
123+
>
124+
<div class="flex items-center gap-x-2 truncate">
125+
<span
126+
class="inline-block size-1.5 rounded-full flex-none {statusDot(
127+
deployment.status,
128+
)}"
129+
></span>
130+
<span class="truncate">
131+
{deployment.branch || primaryBranch || "main"}
132+
</span>
133+
{#if prod}
134+
<span class="text-[10px] text-fg-muted flex-none">
135+
production
136+
</span>
137+
{/if}
138+
</div>
139+
</DropdownMenu.CheckboxItem>
140+
{/each}
141+
</DropdownMenu.Content>
142+
</DropdownMenu.Root>
143+
</li>
144+
{/if}
145+
146+
<style lang="postcss">
147+
.branch-selector {
148+
@apply flex items-center mr-2;
149+
}
150+
151+
/* Styled to match the dimension chip used elsewhere in the header */
152+
.chip {
153+
@apply flex items-center gap-x-1;
154+
@apply px-2 py-0 rounded-2xl border;
155+
@apply bg-primary-50 border-primary-200 text-primary-800;
156+
@apply transition-colors;
157+
}
158+
159+
.chip:hover {
160+
@apply bg-primary-100;
161+
}
162+
163+
.status-dot {
164+
@apply size-1.5 rounded-full flex-none;
165+
}
166+
167+
.caret {
168+
@apply flex-none transition-transform;
169+
}
170+
171+
.caret.open {
172+
@apply rotate-180;
173+
}
174+
</style>

0 commit comments

Comments
 (0)