Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
01dd092
refactor: convert project layout to Svelte 5 runes and extract shared…
ericpgreen2 Mar 30, 2026
5368c81
chore: clean up `features/projects` directory
ericpgreen2 Mar 30, 2026
fb407db
fix: update remaining `constants` import paths after file move
ericpgreen2 Mar 30, 2026
a0f9672
fix: update `ResourceError` import to use `web-common` version
ericpgreen2 Mar 30, 2026
1520660
refactor: bundle mock user params in `resolveRuntimeConnection`
ericpgreen2 Mar 30, 2026
3629e36
refactor: remove unnecessary derived wrappers in project layout
ericpgreen2 Mar 30, 2026
081ddb4
feat: add branch deployment selector and Deployments management page
ericpgreen2 Mar 30, 2026
ad43959
fix: eliminate branch switch lag by optimistically seeding project cache
ericpgreen2 Mar 30, 2026
d9cb63f
Revert "fix: eliminate branch switch lag by optimistically seeding pr…
ericpgreen2 Mar 30, 2026
b53ee01
refactor: simplify `DeploymentsSection` and migrate to Svelte 5
ericpgreen2 Mar 31, 2026
4009957
Merge origin/main into ericgreen/branch-selector-ui
ericpgreen2 Apr 1, 2026
81ef06c
feat: show spinner for transitory deployment statuses
ericpgreen2 Apr 1, 2026
278dac6
fix: surface project parser errors on deployment overview page
ericpgreen2 Apr 1, 2026
ffeca15
fix: review feedback — deduplicate status set, fix dark mode border, …
ericpgreen2 Apr 2, 2026
ed92693
fix: revert unrelated `download-report.ts` change from bad merge
ericpgreen2 Apr 2, 2026
c2d0283
fix: review feedback — data-driven tests, remove `deduplicateDeployme…
ericpgreen2 Apr 9, 2026
0da6b83
fix: review feedback — rename `mutateFn`, extract delete dialog, sort…
ericpgreen2 Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ Two deployment modes share the same codebase:
- **Local dev**: `rill devtool start local`
- **Cloud dev**: `rill devtool start cloud`
- **Test Go**: `go test ./...`
- **Test frontend (unit)**: `npm run test -w web-common` (fast, use for tight feedback loops)
- **Test frontend (unit, web-common)**: `npm run test -w web-common` (fast, use for tight feedback loops)
- **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)
- **Test frontend (e2e)**: `npm run test -w web-local` or `npm run test -w web-admin` (Playwright, slow)
- **Lint/format frontend**: `npm run quality`
- **Regenerate docs**: `make docs.generate` (run after changes to `proto/`, `cli/` or `runtime/parser`)
Expand Down
59 changes: 29 additions & 30 deletions web-admin/src/features/authentication/AvatarButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@
type UserLike,
} from "@rilldata/web-common/features/help/initPylonChat";
import { posthogIdentify } from "@rilldata/web-common/lib/analytics/posthog";
import { createAdminServiceGetCurrentUser } from "../../client";
import ProjectAccessControls from "../projects/ProjectAccessControls.svelte";
import {
createAdminServiceGetCurrentUser,
type V1ProjectPermissions,
} from "../../client";
import ViewAsUserPopover from "../view-as-user/ViewAsUserPopover.svelte";
import ThemeToggle from "@rilldata/web-common/features/themes/ThemeToggle.svelte";

export let projectPermissions: V1ProjectPermissions | undefined = undefined;

const user = createAdminServiceGetCurrentUser();

let imgContainer: HTMLElement;
Expand Down Expand Up @@ -85,35 +89,30 @@
<div bind:this={imgContainer} class="h-7 w-7"></div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if params.organization && params.project}
<ProjectAccessControls
organization={params.organization}
project={params.project}
>
<svelte:fragment slot="manage-project">
<DropdownMenu.Sub bind:open={subMenuOpen}>
<DropdownMenu.SubTrigger
onclick={() => {
subMenuOpen = !subMenuOpen;
{#if params.organization && params.project && projectPermissions}
{#if projectPermissions.manageProject}
<DropdownMenu.Sub bind:open={subMenuOpen}>
<DropdownMenu.SubTrigger
onclick={() => {
subMenuOpen = !subMenuOpen;
}}
>
View as
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="flex flex-col min-w-[150px] max-w-[300px]"
>
<ViewAsUserPopover
organization={params.organization}
project={params.project}
onSelectUser={() => {
subMenuOpen = false;
primaryMenuOpen = false;
}}
>
View as
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="flex flex-col min-w-[150px] max-w-[300px]"
>
<ViewAsUserPopover
organization={params.organization}
project={params.project}
onSelectUser={() => {
subMenuOpen = false;
primaryMenuOpen = false;
}}
/>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</svelte:fragment>
</ProjectAccessControls>
/>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
{#if params.dashboard}
<DropdownMenu.Item
href={`/${params.organization}/${params.project}/-/alerts`}
Expand Down
96 changes: 96 additions & 0 deletions web-admin/src/features/branches/BranchDeploymentStopped.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script lang="ts">
import {
createAdminServiceStartDeployment,
getAdminServiceGetProjectQueryKey,
V1DeploymentStatus,
type V1GetProjectResponse,
} from "@rilldata/web-admin/client";
import { invalidateDeployments } from "./deployment-utils";
import { Button } from "@rilldata/web-common/components/button";
import CtaContentContainer from "@rilldata/web-common/components/calls-to-action/CTAContentContainer.svelte";
import CtaHeader from "@rilldata/web-common/components/calls-to-action/CTAHeader.svelte";
import CtaLayoutContainer from "@rilldata/web-common/components/calls-to-action/CTALayoutContainer.svelte";
import Spinner from "@rilldata/web-common/features/entity-management/Spinner.svelte";
import { EntityStatus } from "@rilldata/web-common/features/entity-management/types";
import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts";

export let organization: string;
export let project: string;
export let deploymentId: string;
export let status: V1DeploymentStatus;
export let canManage: boolean;
export let branch: string | undefined;
export let onStarted: (() => void) | undefined = undefined;

$: isStopping = status === V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPING;

const startMutation = createAdminServiceStartDeployment();

function handleStart() {
$startMutation.mutate(
{ deploymentId, data: {} },
{
onSuccess: () => {
onStarted?.();

const projectQueryKey = getAdminServiceGetProjectQueryKey(
organization,
project,
branch ? { branch } : undefined,
);

// Without this, the invalidation refetch may return the old STOPPED
// status (race condition), leaving the UI stuck on this page.
queryClient.setQueryData<V1GetProjectResponse>(
projectQueryKey,
(old) => {
if (!old?.deployment) return old;
return {
...old,
deployment: {
...old.deployment,
status: V1DeploymentStatus.DEPLOYMENT_STATUS_PENDING,
},
};
},
);

// Mark stale without immediate refetch; PENDING triggers polling
// (1–2s) which picks up the real server status.
void queryClient.invalidateQueries({
queryKey: projectQueryKey,
refetchType: "none",
});

void invalidateDeployments(organization, project);
},
},
);
}
</script>

<CtaLayoutContainer>
<CtaContentContainer>
{#if isStopping}
<div class="h-16">
<Spinner status={EntityStatus.Running} size="3rem" duration={725} />
</div>
<CtaHeader variant="bold">Deployment is stopping...</CtaHeader>
{:else}
<CtaHeader variant="bold">Deployment stopped</CtaHeader>
<p class="text-sm text-fg-secondary">
This branch deployment is not running.
</p>
{#if canManage}
<Button
type="primary"
loading={$startMutation.isPending}
loadingCopy="Starting..."
onClick={handleStart}
>
Start deployment
</Button>
{/if}
{/if}
</CtaContentContainer>
</CtaLayoutContainer>
174 changes: 174 additions & 0 deletions web-admin/src/features/branches/BranchSelector.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<script lang="ts">
import { page } from "$app/stores";
import CaretDownIcon from "@rilldata/web-common/components/icons/CaretDownIcon.svelte";
import * as DropdownMenu from "@rilldata/web-common/components/dropdown-menu";
import {
extractBranchFromPath,
injectBranchIntoPath,
removeBranchFromPath,
requestSkipBranchInjection,
} from "./branch-utils";
import { isProdDeployment } from "./deployment-utils";
import { getStatusDotClass } from "../projects/status/display-utils";
import {
V1DeploymentStatus,
createAdminServiceListDeployments,
type V1Deployment,
} from "../../client";

export let organization: string;
export let project: string;
export let primaryBranch: string | undefined = undefined;

let open = false;

$: activeBranch = extractBranchFromPath($page.url.pathname);

// Poll at 2s only while the dropdown is open (so the user sees live status
// transitions). When closed, the cached data is sufficient; freshness is
// maintained by invalidateDeployments() calls after create/delete mutations.
$: deploymentsQuery = createAdminServiceListDeployments(
organization,
project,
{},
{
query: {
enabled: !!organization && !!project,
refetchInterval: open ? 2000 : false,
},
},
);

$: deployments = $deploymentsQuery.data?.deployments ?? [];

$: hasBranchDeployments = deployments.some(
(d) => d.branch && d.branch !== primaryBranch,
);

$: isOnBranch = !!activeBranch && activeBranch !== primaryBranch;

// Sort: production first, then alphabetically by branch name
$: sortedDeployments = [...deployments].sort((a, b) => {
const aIsProd = isProdDeployment(a);
const bIsProd = isProdDeployment(b);
if (aIsProd && !bIsProd) return -1;
if (!aIsProd && bIsProd) return 1;
return (a.branch ?? "").localeCompare(b.branch ?? "");
});

// Current branch label for the trigger
$: currentDeployment = isOnBranch
? deployments.find((d) => d.branch === activeBranch)
: deployments.find(isProdDeployment);
$: triggerLabel = isOnBranch
? truncateBranch(activeBranch ?? "")
: truncateBranch(primaryBranch ?? "");

function truncateBranch(branch: string): string {
if (branch.length <= 20) return branch;
return branch.slice(0, 19) + "…";
}

function getDeploymentHref(deployment: V1Deployment): string {
const basePath = removeBranchFromPath($page.url.pathname);
if (isProdDeployment(deployment)) return basePath + $page.url.search;
return (
injectBranchIntoPath(basePath, deployment.branch!) + $page.url.search
);
}

function handleClick(deployment: V1Deployment) {
if (isProdDeployment(deployment)) {
requestSkipBranchInjection();
}
open = false;
}

function statusDot(status: V1DeploymentStatus | undefined): string {
return getStatusDotClass(
status ?? V1DeploymentStatus.DEPLOYMENT_STATUS_UNSPECIFIED,
);
}
</script>

{#if hasBranchDeployments || isOnBranch}
<li class="branch-selector">
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<button {...props} class="chip">
<span class="status-dot {statusDot(currentDeployment?.status)}"
></span>
<span>{triggerLabel}</span>
<span class="caret" class:open>
<CaretDownIcon size="10px" />
</span>
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="min-w-[200px] max-w-[300px]">
<DropdownMenu.Group>
<DropdownMenu.Label>All branches</DropdownMenu.Label>
</DropdownMenu.Group>
{#each sortedDeployments as deployment (deployment.id)}
{@const prod = isProdDeployment(deployment)}
{@const isSelected = prod
? !isOnBranch
: activeBranch === deployment.branch}
<DropdownMenu.CheckboxItem
checked={isSelected}
href={getDeploymentHref(deployment)}
onclick={() => handleClick(deployment)}
class="flex items-center gap-x-2"
>
<div class="flex items-center gap-x-2 truncate">
<span
class="inline-block size-1.5 rounded-full flex-none {statusDot(
deployment.status,
)}"
></span>
<span class="truncate">
{deployment.branch || primaryBranch || "main"}
</span>
{#if prod}
<span class="text-[10px] text-fg-muted flex-none">
production
</span>
{/if}
</div>
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</li>
{/if}

<style lang="postcss">
.branch-selector {
@apply flex items-center mr-2;
}

/* Styled to match the dimension chip used elsewhere in the header */
.chip {
@apply flex items-center gap-x-1;
@apply px-2 py-0 rounded-2xl border;
@apply bg-primary-50 border-primary-200 text-primary-800;
@apply transition-colors;
}

.chip:hover {
@apply bg-primary-100;
}

.status-dot {
@apply size-1.5 rounded-full flex-none;
}

.caret {
@apply flex-none transition-transform;
}

.caret.open {
@apply rotate-180;
}
</style>
Loading
Loading