From a55f2eec7de1041d42deaf550a175ffcfa37a8b8 Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Tue, 15 Sep 2026 12:59:22 +0530 Subject: [PATCH 1/3] feat: manage deployment slots from project status --- admin/project_slots_test.go | 71 +++++ admin/projects.go | 4 +- admin/server/projects.go | 4 +- admin/server/projects_test.go | 28 ++ cli/pkg/local/slots.go | 8 +- package-lock.json | 2 + web-admin/package.json | 4 +- .../features/branches/BranchesSection.svelte | 16 +- .../branches/ProjectSlotsSection.svelte | 11 + .../projects/settings/HibernateProject.svelte | 33 ++- .../settings/ProjectSlotsSettings.svelte | 163 ++++++++++ .../project-resources.component.spec.ts | 279 ++++++++++++++++++ .../status/overview/DeploymentSection.svelte | 11 + .../status/overview/slots-utils.test.ts | 4 +- .../projects/status/overview/slots-utils.ts | 2 +- .../[project]/-/status/+layout.svelte | 16 +- .../[project]/-/status/branches/+page.svelte | 4 +- web-admin/vite.config.ts | 26 +- web-common/src/lib/i18n/messages/en.json | 11 +- web-common/src/lib/i18n/messages/es.json | 11 +- 20 files changed, 669 insertions(+), 39 deletions(-) create mode 100644 admin/project_slots_test.go create mode 100644 web-admin/src/features/branches/ProjectSlotsSection.svelte create mode 100644 web-admin/src/features/projects/settings/ProjectSlotsSettings.svelte create mode 100644 web-admin/src/features/projects/settings/project-resources.component.spec.ts diff --git a/admin/project_slots_test.go b/admin/project_slots_test.go new file mode 100644 index 000000000000..294399400613 --- /dev/null +++ b/admin/project_slots_test.go @@ -0,0 +1,71 @@ +package admin + +import ( + "context" + "testing" + + "github.com/rilldata/rill/admin/database" + "github.com/rilldata/rill/admin/jobs" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestUpdateProjectDevSlots(t *testing.T) { + for _, slots := range []int{2, 4} { + t.Run(map[int]string{2: "unchanged", 4: "resized"}[slots], func(t *testing.T) { + project := &database.Project{ID: "project", PrimaryBranch: "main", ProdSlots: 2, DevSlots: 2} + db := &slotUpdateDB{project: project} + jobClient := &slotUpdateJobs{} + svc := &Service{DB: db, Jobs: jobClient, Logger: zap.NewNop()} + + updated, err := svc.UpdateProject(t.Context(), project, &database.UpdateProjectOptions{ + PrimaryBranch: "main", ProdSlots: 2, DevSlots: slots, + }) + require.NoError(t, err) + require.Equal(t, slots, updated.DevSlots) + require.Equal(t, 2, updated.ProdSlots) + if slots == 2 { + require.Empty(t, jobClient.deploymentIDs) + } else { + require.Equal(t, []string{"dev-deployment"}, jobClient.deploymentIDs) + require.Equal(t, database.DeploymentStatusRunning, db.desiredStatus) + } + }) + } +} + +// Only the database and queue operations used by UpdateProject are implemented. +type slotUpdateDB struct { + database.DB + project *database.Project + desiredStatus database.DeploymentStatus +} + +func (db *slotUpdateDB) FindDeploymentsForProject(_ context.Context, _, _, branch string) ([]*database.Deployment, error) { + if branch != "" { + return nil, nil + } + return []*database.Deployment{{ID: "dev-deployment", Environment: "dev", Branch: "feature", DesiredStatus: database.DeploymentStatusRunning}}, nil +} + +func (db *slotUpdateDB) UpdateProject(_ context.Context, _ string, opts *database.UpdateProjectOptions) (*database.Project, error) { + updated := *db.project + updated.ProdSlots = opts.ProdSlots + updated.DevSlots = opts.DevSlots + return &updated, nil +} + +func (db *slotUpdateDB) UpdateDeploymentDesiredStatus(_ context.Context, id string, desired database.DeploymentStatus) (*database.Deployment, error) { + db.desiredStatus = desired + return &database.Deployment{ID: id, DesiredStatus: desired}, nil +} + +type slotUpdateJobs struct { + jobs.Client + deploymentIDs []string +} + +func (j *slotUpdateJobs) ReconcileDeployment(_ context.Context, id string) (*jobs.InsertResult, error) { + j.deploymentIDs = append(j.deploymentIDs, id) + return &jobs.InsertResult{}, nil +} diff --git a/admin/projects.go b/admin/projects.go index ea4aa9479657..bcdb6afb4977 100644 --- a/admin/projects.go +++ b/admin/projects.go @@ -197,6 +197,7 @@ func (s *Service) UpdateProject(ctx context.Context, oldProj *database.Project, impactsDeployments := (oldProj.ProdVersion != opts.ProdVersion) || (oldProj.ProdSlots != opts.ProdSlots) || + (oldProj.DevSlots != opts.DevSlots) || (oldProj.Name != opts.Name) || (oldProj.Subpath != opts.Subpath) || (oldProj.PrimaryBranch != opts.PrimaryBranch) || @@ -246,8 +247,7 @@ func (s *Service) UpdateProject(ctx context.Context, oldProj *database.Project, } } - // TODO: changing prod related fields like slots, branch etc should only impact prod deployments, but for now we update all deployments - // NOTE: there is no way to change dev-slots right now + // TODO: changing environment-specific fields like slots should only impact deployments in that environment, but for now we update all deployments. err = s.UpdateDeploymentsForProject(ctx, proj) if err != nil { return nil, err diff --git a/admin/server/projects.go b/admin/server/projects.go index 281680ec76ad..50a31e3585a6 100644 --- a/admin/server/projects.go +++ b/admin/server/projects.go @@ -32,9 +32,9 @@ const prodDeplTTL = 14 * 24 * time.Hour // defaultProdSlots and defaultDevSlots are the slot counts applied when a CreateProject // request omits them (e.g. the UI, or older CLIs that don't pass these fields). -const defaultProdSlots = 4 +const defaultProdSlots = 2 -const defaultDevSlots = 4 +const defaultDevSlots = 2 // runtimeAccessTokenTTL is the validity duration of JWTs issued for runtime access when calling GetProject. // This TTL is not used for tokens created for internal communication between the admin and runtime services. diff --git a/admin/server/projects_test.go b/admin/server/projects_test.go index 4b123ab2770a..09ec49d8932f 100644 --- a/admin/server/projects_test.go +++ b/admin/server/projects_test.go @@ -9,6 +9,34 @@ import ( "github.com/stretchr/testify/require" ) +func TestCreateProjectSlots(t *testing.T) { + fix := testadmin.New(t) + _, client := fix.NewUser(t) + org, err := client.CreateOrganization(t.Context(), &adminv1.CreateOrganizationRequest{Name: randomName()}) + require.NoError(t, err) + + for _, tt := range []struct { + name string + prod, dev int64 + wantProd, wantDev int64 + }{ + {name: "defaults", wantProd: 2, wantDev: 2}, + {name: "explicit", prod: 1, dev: 3, wantProd: 1, wantDev: 3}, + {name: "default-prod", dev: 1, wantProd: 2, wantDev: 1}, + {name: "default-dev", prod: 3, wantProd: 3, wantDev: 2}, + } { + t.Run(tt.name, func(t *testing.T) { + resp, err := client.CreateProject(t.Context(), &adminv1.CreateProjectRequest{ + Org: org.Organization.Name, Project: tt.name, + ProdSlots: tt.prod, DevSlots: tt.dev, SkipDeploy: true, + }) + require.NoError(t, err) + require.Equal(t, tt.wantProd, resp.Project.ProdSlots) + require.Equal(t, tt.wantDev, resp.Project.DevSlots) + }) + } +} + func TestProjectVariables(t *testing.T) { fix := testadmin.New(t) diff --git a/cli/pkg/local/slots.go b/cli/pkg/local/slots.go index 889960ff4fb7..93b1a14e54d6 100644 --- a/cli/pkg/local/slots.go +++ b/cli/pkg/local/slots.go @@ -5,7 +5,7 @@ import "github.com/rilldata/rill/cli/pkg/cmdutil" // DefaultProdSlots returns the prod slot count to request from the admin API. // // Returns 0 for released builds, which signals the admin server to apply its own default. -// Dev builds request a single slot to keep local deployments small. +// Dev builds request two slots to match the default for new deployments. // // A slot represents the following resources: // - 1 CPU core @@ -13,7 +13,7 @@ import "github.com/rilldata/rill/cli/pkg/cmdutil" // - 40 GB of storage func DefaultProdSlots(ch *cmdutil.Helper) int { if ch.IsDev() { - return 1 + return 2 } return 0 } @@ -21,7 +21,7 @@ func DefaultProdSlots(ch *cmdutil.Helper) int { // DefaultDevSlots returns the dev slot count to request from the admin API. // // Returns 0 for released builds, which signals the admin server to apply its own default. -// Dev builds request a single slot to keep local deployments small. +// Dev builds request two slots to match the default for new deployments. // // A slot represents the following resources: // - 1 CPU core @@ -29,7 +29,7 @@ func DefaultProdSlots(ch *cmdutil.Helper) int { // - 40 GB of storage func DefaultDevSlots(ch *cmdutil.Helper) int { if ch.IsDev() { - return 1 + return 2 } return 0 } diff --git a/package-lock.json b/package-lock.json index 6177083b77e2..bfecd8f9661c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36163,10 +36163,12 @@ "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.55.0", "@tanstack/svelte-query": "~5.76.0", + "@testing-library/svelte": "^5.2.7", "autoprefixer": "^10.4.20", "axios": "1.13.6", "cronstrue": "^2.41.0", "dotenv": "^16.5.0", + "jsdom": "^26.0.0", "orval": "^7.8.0", "postcss": "^8.4.47", "svelte": "^5.0.0", diff --git a/web-admin/package.json b/web-admin/package.json index 136ac21d5414..fa15018e8c94 100644 --- a/web-admin/package.json +++ b/web-admin/package.json @@ -24,17 +24,19 @@ "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.55.0", "@tanstack/svelte-query": "~5.76.0", - "tanstack-table-8-svelte-5": "^0.1.2", + "@testing-library/svelte": "^5.2.7", "autoprefixer": "^10.4.20", "axios": "1.13.6", "cronstrue": "^2.41.0", "dotenv": "^16.5.0", + "jsdom": "^26.0.0", "orval": "^7.8.0", "postcss": "^8.4.47", "svelte": "^5.0.0", "svelte-check": "^4.0.4", "svelte-preprocess": "^6.0.3", "tailwindcss": "^3.4.13", + "tanstack-table-8-svelte-5": "^0.1.2", "vitest": "^4.1.1", "web-common": "*" }, diff --git a/web-admin/src/features/branches/BranchesSection.svelte b/web-admin/src/features/branches/BranchesSection.svelte index aaa2057b59ea..2ccedf51e101 100644 --- a/web-admin/src/features/branches/BranchesSection.svelte +++ b/web-admin/src/features/branches/BranchesSection.svelte @@ -468,13 +468,15 @@ {/each} -
- - - {m.branch_add_from_cli()} - - -
+ {#if $cloudEditing} +
+ + + {m.branch_add_from_cli()} + + +
+ {/if} {/if} diff --git a/web-admin/src/features/branches/ProjectSlotsSection.svelte b/web-admin/src/features/branches/ProjectSlotsSection.svelte new file mode 100644 index 000000000000..8b65c101ebc8 --- /dev/null +++ b/web-admin/src/features/branches/ProjectSlotsSection.svelte @@ -0,0 +1,11 @@ + + + diff --git a/web-admin/src/features/projects/settings/HibernateProject.svelte b/web-admin/src/features/projects/settings/HibernateProject.svelte index 93b18221d522..7295e6e9b2e4 100644 --- a/web-admin/src/features/projects/settings/HibernateProject.svelte +++ b/web-admin/src/features/projects/settings/HibernateProject.svelte @@ -3,6 +3,8 @@ createAdminServiceGetProject, createAdminServiceHibernateProject, getAdminServiceGetProjectQueryKey, + getAdminServiceListDeploymentsQueryKey, + getAdminServiceListProjectsForOrganizationQueryKey, type RpcStatus, } from "@rilldata/web-admin/client"; import SettingsContainer from "@rilldata/web-admin/features/organizations/settings/SettingsContainer.svelte"; @@ -33,9 +35,14 @@ ); let isHibernated = $derived(!$projectResp.data?.deployment); + let canManage = $derived( + !!$projectResp.data?.projectPermissions?.manageProject, + ); + let hibernateResult = $derived($hibernateProjectMutation); async function hibernateProject() { + if (!canManage || isHibernated || hibernateResult.isPending) return; try { await $hibernateProjectMutation.mutateAsync({ org: organization, @@ -44,9 +51,21 @@ dialogOpen = false; - await queryClient.refetchQueries({ - queryKey: getAdminServiceGetProjectQueryKey(organization, project), - }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: getAdminServiceGetProjectQueryKey(organization, project), + }), + queryClient.invalidateQueries({ + queryKey: getAdminServiceListDeploymentsQueryKey( + organization, + project, + ), + }), + queryClient.invalidateQueries({ + queryKey: + getAdminServiceListProjectsForOrganizationQueryKey(organization), + }), + ]); eventBus.emit("notification", { message: m.settings_project_hibernated_notification(), @@ -73,7 +92,7 @@ @@ -89,7 +108,11 @@ - + {/snippet} + diff --git a/web-admin/src/features/projects/settings/project-resources.component.spec.ts b/web-admin/src/features/projects/settings/project-resources.component.spec.ts new file mode 100644 index 000000000000..99713a4b11f3 --- /dev/null +++ b/web-admin/src/features/projects/settings/project-resources.component.spec.ts @@ -0,0 +1,279 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { writable } from "svelte/store"; +import HibernateProject from "./HibernateProject.svelte"; +import ProjectSlotsSection from "@rilldata/web-admin/features/branches/ProjectSlotsSection.svelte"; +import { featureFlags } from "@rilldata/web-common/features/feature-flags"; + +const mocks = vi.hoisted(() => ({ + update: vi.fn(), + hibernate: vi.fn(), + invalidate: vi.fn(), + notify: vi.fn(), +})); + +const project = writable({ + data: { + project: { prodSlots: "2", devSlots: "3" }, + deployment: { id: "deployment" }, + projectPermissions: { manageProject: true }, + }, +}); + +vi.mock("@rilldata/web-admin/client", () => ({ + createAdminServiceGetProject: () => project, + createAdminServiceUpdateProject: () => + writable({ mutateAsync: mocks.update, isPending: false }), + createAdminServiceHibernateProject: () => + writable({ mutateAsync: mocks.hibernate, isPending: false }), + getAdminServiceGetProjectQueryKey: () => ["project"], + getAdminServiceListDeploymentsQueryKey: () => ["deployments"], + getAdminServiceListProjectsForOrganizationQueryKey: () => ["projects"], +})); +vi.mock("@rilldata/web-common/lib/svelte-query/globalQueryClient", () => ({ + queryClient: { invalidateQueries: mocks.invalidate }, +})); +vi.mock("@rilldata/web-common/lib/event-bus/event-bus", () => ({ + eventBus: { emit: mocks.notify }, +})); +vi.mock("@rilldata/web-common/features/feature-flags", async () => { + const { writable } = await import("svelte/store"); + return { featureFlags: { cloudEditing: writable(false) } }; +}); + +beforeEach(() => { + vi.clearAllMocks(); + featureFlags.cloudEditing.set(false); + mocks.update.mockResolvedValue({}); + mocks.hibernate.mockResolvedValue({}); + project.set({ + data: { + project: { prodSlots: "2", devSlots: "3" }, + deployment: { id: "deployment" }, + projectPermissions: { manageProject: true }, + }, + }); +}); +afterEach(cleanup); + +const props = { organization: "test-org", project: "test-project" }; + +it("shows development slot controls only when cloud editing is enabled", async () => { + render(ProjectSlotsSection, props); + expect( + screen.getByRole("spinbutton", { name: "Production slots" }), + ).toBeTruthy(); + expect( + screen.queryByRole("spinbutton", { name: "Development slots" }), + ).toBeNull(); + + featureFlags.cloudEditing.set(true); + await waitFor(() => + expect( + screen.getByRole("spinbutton", { name: "Development slots" }), + ).toBeTruthy(), + ); + + featureFlags.cloudEditing.set(false); + await waitFor(() => + expect( + screen.queryByRole("spinbutton", { name: "Development slots" }), + ).toBeNull(), + ); + expect( + screen.getByRole("spinbutton", { name: "Production slots" }), + ).toBeTruthy(); +}); + +describe.each([ + { + field: "prodSlots", + title: "Production slots", + }, + { + field: "devSlots", + title: "Development slots", + }, +])("$title", ({ field, title }) => { + it("validates slots, preserves edits during refetch, and sends only the selected environment", async () => { + featureFlags.cloudEditing.set(true); + render(ProjectSlotsSection, props); + const input = screen.getByRole("spinbutton", { name: title }); + const save = screen.getByRole("button", { + name: "Save", + }); + expect(save.hasAttribute("disabled")).toBe(true); + for (const value of ["", "0", "-1", "1.5"]) { + await fireEvent.input(input, { target: { value } }); + expect(save.hasAttribute("disabled")).toBe(true); + } + await fireEvent.input(input, { target: { value: "4" } }); + project.update((p) => ({ + ...p, + data: { ...p.data, project: { ...p.data.project, [field]: "2" } }, + })); + await waitFor(() => expect((input as HTMLInputElement).value).toBe("4")); + expect(save.hasAttribute("disabled")).toBe(false); + await fireEvent.submit(input.closest("form")!); + await waitFor(() => + expect(mocks.update).toHaveBeenCalledWith({ + org: "test-org", + project: "test-project", + data: { [field]: "4" }, + }), + ); + await waitFor(() => expect(mocks.invalidate).toHaveBeenCalledTimes(3)); + }); + + it("displays quota errors and keeps the input available for correction", async () => { + mocks.update.mockRejectedValueOnce({ + response: { data: { message: "Slot quota exceeded" } }, + }); + featureFlags.cloudEditing.set(true); + render(ProjectSlotsSection, props); + const input = screen.getByRole("spinbutton", { name: title }); + await fireEvent.input(input, { target: { value: "8" } }); + await fireEvent.submit(input.closest("form")!); + await waitFor(() => + expect(screen.getByRole("alert").textContent).toBe("Slot quota exceeded"), + ); + expect((input as HTMLInputElement).value).toBe("8"); + expect(mocks.invalidate).not.toHaveBeenCalled(); + }); + + it("disables editing without manage permission", () => { + project.update((p) => ({ + ...p, + data: { ...p.data, projectPermissions: { manageProject: false } }, + })); + featureFlags.cloudEditing.set(true); + render(ProjectSlotsSection, props); + expect( + screen.getByRole("spinbutton", { name: title }).hasAttribute("disabled"), + ).toBe(true); + expect( + screen.getByRole("button", { name: "Save" }).hasAttribute("disabled"), + ).toBe(true); + }); +}); + +it("saves both environments with one button and one request", async () => { + featureFlags.cloudEditing.set(true); + render(ProjectSlotsSection, props); + expect(screen.getAllByRole("form")).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Save" })).toHaveLength(1); + const prodInput = screen.getByRole("spinbutton", { + name: "Production slots", + }) as HTMLInputElement; + const devInput = screen.getByRole("spinbutton", { + name: "Development slots", + }) as HTMLInputElement; + expect(prodInput.value).toBe("2"); + expect(devInput.value).toBe("3"); + expect(prodInput.id).not.toBe(devInput.id); + await fireEvent.input(prodInput, { target: { value: "8" } }); + await fireEvent.input(devInput, { target: { value: "4" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(mocks.update).toHaveBeenCalledExactlyOnceWith({ + org: "test-org", + project: "test-project", + data: { prodSlots: "8", devSlots: "4" }, + }), + ); +}); + +it("does not submit hidden development slots or let them block a production update", async () => { + featureFlags.cloudEditing.set(true); + render(ProjectSlotsSection, props); + await fireEvent.input( + screen.getByRole("spinbutton", { name: "Development slots" }), + { target: { value: "0" } }, + ); + await fireEvent.input( + screen.getByRole("spinbutton", { name: "Production slots" }), + { target: { value: "4" } }, + ); + expect( + screen.getByRole("button", { name: "Save" }).hasAttribute("disabled"), + ).toBe(true); + featureFlags.cloudEditing.set(false); + await waitFor(() => + expect( + screen.queryByRole("spinbutton", { name: "Development slots" }), + ).toBeNull(), + ); + await fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(mocks.update).toHaveBeenCalledExactlyOnceWith({ + org: "test-org", + project: "test-project", + data: { prodSlots: "4" }, + }), + ); +}); + +describe("hibernate project", () => { + it("keeps the confirmation open when hibernation fails", async () => { + mocks.hibernate.mockRejectedValueOnce({ + response: { data: { message: "Unable to stop deployment" } }, + }); + render(HibernateProject, props); + await fireEvent.click( + screen.getByRole("button", { name: "Hibernate project" }), + ); + await fireEvent.click(screen.getByRole("button", { name: "Hibernate" })); + await waitFor(() => + expect(mocks.notify).toHaveBeenCalledWith("notification", { + message: "Unable to stop deployment", + type: "error", + }), + ); + expect(screen.getByRole("alertdialog")).toBeTruthy(); + expect(mocks.invalidate).not.toHaveBeenCalled(); + }); + + it("disables hibernation without manage permission", () => { + project.update((p) => ({ + ...p, + data: { ...p.data, projectPermissions: { manageProject: false } }, + })); + render(HibernateProject, props); + expect( + screen + .getByRole("button", { + name: "Hibernate project", + }) + .hasAttribute("disabled"), + ).toBe(true); + }); + + it("requires confirmation, supports cancellation, and refreshes project state", async () => { + render(HibernateProject, props); + await fireEvent.click( + screen.getByRole("button", { name: "Hibernate project" }), + ); + expect(screen.getByRole("alertdialog")).toBeTruthy(); + expect(mocks.hibernate).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(mocks.hibernate).not.toHaveBeenCalled(); + await fireEvent.click( + screen.getByRole("button", { name: "Hibernate project" }), + ); + await fireEvent.click(screen.getByRole("button", { name: "Hibernate" })); + await waitFor(() => + expect(mocks.hibernate).toHaveBeenCalledWith({ + org: "test-org", + project: "test-project", + }), + ); + await waitFor(() => expect(mocks.invalidate).toHaveBeenCalledTimes(3)); + }); +}); diff --git a/web-admin/src/features/projects/status/overview/DeploymentSection.svelte b/web-admin/src/features/projects/status/overview/DeploymentSection.svelte index f5ebd3574a19..1a5c6edc4d1b 100644 --- a/web-admin/src/features/projects/status/overview/DeploymentSection.svelte +++ b/web-admin/src/features/projects/status/overview/DeploymentSection.svelte @@ -38,6 +38,7 @@ import { getGitUrlFromRemote } from "@rilldata/web-common/features/project/deploy/github-utils"; import ProjectClone from "./ProjectClone.svelte"; import OverviewCard from "@rilldata/web-common/features/projects/status/overview/OverviewCard.svelte"; + import { Button } from "@rilldata/web-common/components/button"; import ClusterSize from "./ClusterSize.svelte"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; @@ -167,6 +168,16 @@ {m.status_label_cluster_size()} + {#if $proj.data?.projectPermissions?.manageProject && !activeBranch} + + {/if} {/if} diff --git a/web-admin/src/features/projects/status/overview/slots-utils.test.ts b/web-admin/src/features/projects/status/overview/slots-utils.test.ts index ab47d78b8073..128ef44bd967 100644 --- a/web-admin/src/features/projects/status/overview/slots-utils.test.ts +++ b/web-admin/src/features/projects/status/overview/slots-utils.test.ts @@ -20,8 +20,8 @@ describe("slots-utils", () => { expect(DEFAULT_MANAGED_SLOTS).toBe(2); }); - it("self-managed default is 4 slots", () => { - expect(DEFAULT_SELF_MANAGED_SLOTS).toBe(4); + it("self-managed default is 2 slots", () => { + expect(DEFAULT_SELF_MANAGED_SLOTS).toBe(2); }); it("all slot values are at least managed minimum", () => { diff --git a/web-admin/src/features/projects/status/overview/slots-utils.ts b/web-admin/src/features/projects/status/overview/slots-utils.ts index d8cc8b90c3db..01e81f8aef5c 100644 --- a/web-admin/src/features/projects/status/overview/slots-utils.ts +++ b/web-admin/src/features/projects/status/overview/slots-utils.ts @@ -3,7 +3,7 @@ export const HOURS_PER_MONTH = 730; // Default slots by deployment type export const DEFAULT_MANAGED_SLOTS = 2; // Rill-managed (DuckDB) -export const DEFAULT_SELF_MANAGED_SLOTS = 4; // Self-managed (MotherDuck, ClickHouse, Druid, Pinot, StarRocks) +export const DEFAULT_SELF_MANAGED_SLOTS = 2; // Self-managed (MotherDuck, ClickHouse, Druid, Pinot, StarRocks) export interface SlotTier { slots: number; diff --git a/web-admin/src/routes/[organization]/[project]/-/status/+layout.svelte b/web-admin/src/routes/[organization]/[project]/-/status/+layout.svelte index f8806c079081..616054160a93 100644 --- a/web-admin/src/routes/[organization]/[project]/-/status/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/-/status/+layout.svelte @@ -4,11 +4,9 @@ import { page } from "$app/stores"; import ContentContainer from "@rilldata/web-common/components/layout/ContentContainer.svelte"; import LeftNav from "@rilldata/web-admin/components/nav/LeftNav.svelte"; - import { featureFlags } from "@rilldata/web-common/features/feature-flags.ts"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; $: basePage = `/${$page.params.organization}/${$page.params.project}/-/status`; - const { cloudEditing } = featureFlags; $: navItems = [ { @@ -16,15 +14,11 @@ route: "", hasPermission: true, }, - ...($cloudEditing - ? [ - { - label: m.status_nav_branches(), - route: "/branches", - hasPermission: true, - }, - ] - : []), + { + label: m.status_nav_branches(), + route: "/branches", + hasPermission: true, + }, { label: m.status_nav_resources(), route: "/resources", diff --git a/web-admin/src/routes/[organization]/[project]/-/status/branches/+page.svelte b/web-admin/src/routes/[organization]/[project]/-/status/branches/+page.svelte index 7d10d5dbc579..ed1cc0e3450a 100644 --- a/web-admin/src/routes/[organization]/[project]/-/status/branches/+page.svelte +++ b/web-admin/src/routes/[organization]/[project]/-/status/branches/+page.svelte @@ -1,11 +1,13 @@ -
+
+
diff --git a/web-admin/vite.config.ts b/web-admin/vite.config.ts index c010847ee0b9..33074f902975 100644 --- a/web-admin/vite.config.ts +++ b/web-admin/vite.config.ts @@ -1,13 +1,37 @@ import { paraglideVitePlugin } from "@inlang/paraglide-js"; import { sveltekit } from "@sveltejs/kit/vite"; +import { svelteTesting } from "@testing-library/svelte/vite"; import dns from "dns"; -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; import { readPublicEmailDomains } from "./src/features/projects/user-management/readPublicEmailDomains"; // print dev server as `localhost` not `127.0.0.1` dns.setDefaultResultOrder("verbatim"); export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { + name: "unit", + exclude: [...configDefaults.exclude, "**/*.component.spec.ts"], + }, + }, + { + extends: true, + plugins: [svelteTesting()], + ssr: { + noExternal: ["lucide-svelte", "bits-ui", "runed", "svelte-toolbelt"], + }, + test: { + name: "components", + environment: "jsdom", + include: ["src/**/*.component.spec.ts"], + }, + }, + ], + }, resolve: { alias: { "@rilldata/web-admin": "/src", diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json index 1a3ad4038678..15fb9cd07752 100644 --- a/web-common/src/lib/i18n/messages/en.json +++ b/web-common/src/lib/i18n/messages/en.json @@ -1809,7 +1809,7 @@ "settings_favicon_title": "Favicon", "settings_github_title": "GitHub", "settings_hibernate_button": "Hibernate", - "settings_hibernate_confirm_description": "The project will be paused and will not consume resources. It can be woken up at any time.", + "settings_hibernate_confirm_description": "All deployments for this project, including development branches, will stop. Dashboards will be unavailable until the project is woken up.", "settings_hibernate_confirm_title": "Hibernate this project?", "settings_hibernate_failed_notification": "Failed to hibernate project", "settings_hibernate_project_button": "Hibernate project", @@ -1847,6 +1847,15 @@ "settings_rename_button": "Rename", "settings_rename_project_title": "Rename Project", "settings_save_button": "Save", + "settings_deployment_slots_description": "Each slot provides 1 vCPU and 4 GiB of memory per deployment.", + "settings_deployment_slots_failed": "Failed to update deployment slots", + "settings_deployment_slots_saved": "Deployment slots updated", + "settings_deployment_slots_title": "Deployment slots", + "settings_dev_slots_title": "Development slots", + "settings_slots_edit": "Change slots", + "settings_slots_invalid": "Enter a positive whole number of slots.", + "settings_slots_restart_description": "Changing slots updates deployments and may briefly interrupt access. Resource usage and costs may change.", + "settings_slots_title": "Production slots", "settings_thumbnail_description": "Click to upload your thumbnail. The thumbnail will be used when sharing links to Rill in applications like Slack.", "settings_thumbnail_title": "Thumbnail", "settings_updated_org_notification": "Updated organization", diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json index c39f5533913c..2099793b9987 100644 --- a/web-common/src/lib/i18n/messages/es.json +++ b/web-common/src/lib/i18n/messages/es.json @@ -1818,7 +1818,7 @@ "settings_favicon_title": "Favicon", "settings_github_title": "GitHub", "settings_hibernate_button": "Hibernar", - "settings_hibernate_confirm_description": "El proyecto se pausará y no consumirá recursos. Se puede reactivar en cualquier momento.", + "settings_hibernate_confirm_description": "Se detendrán todos los despliegues de este proyecto, incluidas las ramas de desarrollo. Los dashboards no estarán disponibles hasta que se reactive el proyecto.", "settings_hibernate_confirm_title": "¿Hibernar este proyecto?", "settings_hibernate_failed_notification": "Error al hibernar el proyecto", "settings_hibernate_project_button": "Hibernar proyecto", @@ -1856,6 +1856,15 @@ "settings_rename_button": "Renombrar", "settings_rename_project_title": "Renombrar proyecto", "settings_save_button": "Guardar", + "settings_deployment_slots_description": "Cada slot proporciona 1 vCPU y 4 GiB de memoria por despliegue.", + "settings_deployment_slots_failed": "Error al actualizar los slots de despliegue", + "settings_deployment_slots_saved": "Slots de despliegue actualizados", + "settings_deployment_slots_title": "Slots de despliegue", + "settings_dev_slots_title": "Slots de desarrollo", + "settings_slots_edit": "Cambiar slots", + "settings_slots_invalid": "Introduce un número entero positivo de slots.", + "settings_slots_restart_description": "Cambiar los slots actualiza los despliegues y puede interrumpir brevemente el acceso. El uso de recursos y los costos pueden cambiar.", + "settings_slots_title": "Slots de producción", "settings_thumbnail_description": "Haga clic para subir su miniatura. La miniatura se usará al compartir enlaces a Rill en aplicaciones como Slack.", "settings_thumbnail_title": "Miniatura", "settings_updated_org_notification": "Organización actualizada", From c34a52d36219f167717aaeaa636490f21f40986f Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Tue, 15 Sep 2026 22:04:26 +0530 Subject: [PATCH 2/3] fix: highlight disruption when changing deployment slots --- .../settings/ProjectSlotsSettings.svelte | 115 ++++++++++-------- web-common/src/lib/i18n/messages/en.json | 2 +- web-common/src/lib/i18n/messages/es.json | 2 +- 3 files changed, 63 insertions(+), 56 deletions(-) diff --git a/web-admin/src/features/projects/settings/ProjectSlotsSettings.svelte b/web-admin/src/features/projects/settings/ProjectSlotsSettings.svelte index 6d534d147457..d66217dc5be6 100644 --- a/web-admin/src/features/projects/settings/ProjectSlotsSettings.svelte +++ b/web-admin/src/features/projects/settings/ProjectSlotsSettings.svelte @@ -7,6 +7,7 @@ getAdminServiceListProjectsForOrganizationQueryKey, type RpcStatus, } from "@rilldata/web-admin/client"; + import DangerZone from "@rilldata/web-admin/components/settings/DangerZone.svelte"; import SettingsContainer from "@rilldata/web-admin/features/organizations/settings/SettingsContainer.svelte"; import ClusterSize from "@rilldata/web-admin/features/projects/status/overview/ClusterSize.svelte"; import { Button } from "@rilldata/web-common/components/button"; @@ -106,58 +107,64 @@ } - -
{ - event.preventDefault(); - void saveSlots(); - }} - class="flex flex-col gap-4" - > -

{m.settings_deployment_slots_description()}

-
- {#each fields as { field, label } (field)} -
- { - editedSlots[field] = ( - event.currentTarget as HTMLInputElement - ).value; - error = undefined; - }} - disabled={!canManage || isPending} - errors={editedSlots[field] !== undefined && - !validSlots(slots[field]) - ? m.settings_slots_invalid() - : undefined} - alwaysShowError - textClass="text-sm" - additionalClass="max-w-[260px]" - /> - {#if validSlots(slots[field])}{/if} -
- {/each} -
-

{m.settings_slots_restart_description()}

- {#if error}{/if} -
- {#snippet action()} - - {/snippet} -
+

{m.settings_deployment_slots_description()}

+
+ {#each fields as { field, label } (field)} +
+ { + editedSlots[field] = ( + event.currentTarget as HTMLInputElement + ).value; + error = undefined; + }} + disabled={!canManage || isPending} + errors={editedSlots[field] !== undefined && + !validSlots(slots[field]) + ? m.settings_slots_invalid() + : undefined} + alwaysShowError + textClass="text-sm" + additionalClass="max-w-[260px]" + /> + {#if validSlots(slots[field])}{/if} +
+ {/each} +
+

+ {m.settings_slots_restart_description()} +

+ {#if error}{/if} + + {#snippet action()} + + {/snippet} + + diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json index 15fb9cd07752..6c1f7eb3583e 100644 --- a/web-common/src/lib/i18n/messages/en.json +++ b/web-common/src/lib/i18n/messages/en.json @@ -1854,7 +1854,7 @@ "settings_dev_slots_title": "Development slots", "settings_slots_edit": "Change slots", "settings_slots_invalid": "Enter a positive whole number of slots.", - "settings_slots_restart_description": "Changing slots updates deployments and may briefly interrupt access. Resource usage and costs may change.", + "settings_slots_restart_description": "Changing slots reconciles the project again and may restart deployments, briefly interrupting access. Resource usage and costs may change.", "settings_slots_title": "Production slots", "settings_thumbnail_description": "Click to upload your thumbnail. The thumbnail will be used when sharing links to Rill in applications like Slack.", "settings_thumbnail_title": "Thumbnail", diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json index 2099793b9987..a70e57aea8ed 100644 --- a/web-common/src/lib/i18n/messages/es.json +++ b/web-common/src/lib/i18n/messages/es.json @@ -1863,7 +1863,7 @@ "settings_dev_slots_title": "Slots de desarrollo", "settings_slots_edit": "Cambiar slots", "settings_slots_invalid": "Introduce un número entero positivo de slots.", - "settings_slots_restart_description": "Cambiar los slots actualiza los despliegues y puede interrumpir brevemente el acceso. El uso de recursos y los costos pueden cambiar.", + "settings_slots_restart_description": "Cambiar los slots vuelve a reconciliar el proyecto y puede reiniciar los despliegues, interrumpiendo brevemente el acceso. El uso de recursos y los costos pueden cambiar.", "settings_slots_title": "Slots de producción", "settings_thumbnail_description": "Haga clic para subir su miniatura. La miniatura se usará al compartir enlaces a Rill en aplicaciones como Slack.", "settings_thumbnail_title": "Miniatura", From fca7c978b70c9672a1d795762a5888ef253074a5 Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Thu, 17 Sep 2026 20:12:07 +0530 Subject: [PATCH 3/3] fix: scope slot reconciliation and run admin unit tests in CI --- .github/workflows/web-test-unit-tests.yml | 20 +++- admin/deployments.go | 5 +- admin/project_slots_test.go | 92 +++++++++++++------ admin/projects.go | 25 +++-- .../src/features/bookmarks/utils.spec.ts | 31 ++++--- .../status/tables/model-actions.spec.ts | 5 +- .../projects/status/tables/utils.spec.ts | 19 +++- web-admin/vite.config.ts | 1 + 8 files changed, 144 insertions(+), 54 deletions(-) diff --git a/.github/workflows/web-test-unit-tests.yml b/.github/workflows/web-test-unit-tests.yml index 68c539bee3c7..ceb4c8307c36 100644 --- a/.github/workflows/web-test-unit-tests.yml +++ b/.github/workflows/web-test-unit-tests.yml @@ -2,7 +2,10 @@ name: Unit tests for web code on: pull_request: paths: - - ".github/workflows/web-test.yml" + - ".github/workflows/web-test-unit-tests.yml" + - ".nvmrc" + - "package.json" + - "package-lock.json" - "web-admin/**" - "web-common/**" - "web-local/**" @@ -19,10 +22,17 @@ jobs: with: filters: | admin: - - ".github/workflows/web-test.yml" + - ".github/workflows/web-test-unit-tests.yml" + - ".nvmrc" + - "package.json" + - "package-lock.json" - "web-admin/**" + - "web-common/**" common: - - ".github/workflows/web-test.yml" + - ".github/workflows/web-test-unit-tests.yml" + - ".nvmrc" + - "package.json" + - "package-lock.json" - "web-common/**" - name: Set up NodeJS @@ -40,3 +50,7 @@ jobs: - name: Run web-common unit tests if: steps.filter.outputs.common == 'true' run: npm run test -w web-common + + - name: Run web-admin unit tests + if: steps.filter.outputs.admin == 'true' + run: npm run test:unit -w web-admin diff --git a/admin/deployments.go b/admin/deployments.go index 668e0a191b0a..b7ba128797c4 100644 --- a/admin/deployments.go +++ b/admin/deployments.go @@ -136,9 +136,10 @@ func (s *Service) TeardownDeployment(ctx context.Context, depl *database.Deploym } // UpdateDeploymentsForProject updates the deployments of a project. +// An empty environment updates deployments in all environments. // Care must be taken to avoid one broken deployment from blocking updates to other healthy deployments. -func (s *Service) UpdateDeploymentsForProject(ctx context.Context, p *database.Project) error { - ds, err := s.DB.FindDeploymentsForProject(ctx, p.ID, "", "") +func (s *Service) UpdateDeploymentsForProject(ctx context.Context, p *database.Project, environment string) error { + ds, err := s.DB.FindDeploymentsForProject(ctx, p.ID, environment, "") if err != nil { return err } diff --git a/admin/project_slots_test.go b/admin/project_slots_test.go index 294399400613..99fd4ae9f594 100644 --- a/admin/project_slots_test.go +++ b/admin/project_slots_test.go @@ -2,6 +2,7 @@ package admin import ( "context" + "sync" "testing" "github.com/rilldata/rill/admin/database" @@ -10,62 +11,101 @@ import ( "go.uber.org/zap" ) -func TestUpdateProjectDevSlots(t *testing.T) { - for _, slots := range []int{2, 4} { - t.Run(map[int]string{2: "unchanged", 4: "resized"}[slots], func(t *testing.T) { - project := &database.Project{ID: "project", PrimaryBranch: "main", ProdSlots: 2, DevSlots: 2} - db := &slotUpdateDB{project: project} - jobClient := &slotUpdateJobs{} - svc := &Service{DB: db, Jobs: jobClient, Logger: zap.NewNop()} +func TestUpdateProjectSlots(t *testing.T) { + tests := []struct { + name string + prodSlots int + devSlots int + subpath string + expectedIDs []string + }{ + {name: "unchanged", prodSlots: 2, devSlots: 2}, + {name: "production only", prodSlots: 4, devSlots: 2, expectedIDs: []string{"prod-deployment", "prod-branch-deployment"}}, + {name: "development only", prodSlots: 2, devSlots: 4, expectedIDs: []string{"dev-deployment", "dev-branch-deployment"}}, + {name: "both environments", prodSlots: 4, devSlots: 4, expectedIDs: []string{"prod-deployment", "prod-branch-deployment", "dev-deployment", "dev-branch-deployment"}}, + {name: "shared configuration", prodSlots: 2, devSlots: 2, subpath: "project", expectedIDs: []string{"prod-deployment", "prod-branch-deployment", "dev-deployment", "dev-branch-deployment"}}, + {name: "shared configuration with production slots", prodSlots: 4, devSlots: 2, subpath: "project", expectedIDs: []string{"prod-deployment", "prod-branch-deployment", "dev-deployment", "dev-branch-deployment"}}, + {name: "shared configuration with development slots", prodSlots: 2, devSlots: 4, subpath: "project", expectedIDs: []string{"prod-deployment", "prod-branch-deployment", "dev-deployment", "dev-branch-deployment"}}, + } + for _, tt := range tests { + for _, status := range []database.DeploymentStatus{database.DeploymentStatusRunning, database.DeploymentStatusStopped} { + t.Run(tt.name+"/"+status.String(), func(t *testing.T) { + project := &database.Project{ID: "project", PrimaryBranch: "main", ProdSlots: 2, DevSlots: 2} + db := &slotUpdateDB{ + project: project, + deployments: []*database.Deployment{ + {ID: "prod-deployment", Environment: "prod", Branch: "main", DesiredStatus: status}, + {ID: "prod-branch-deployment", Environment: "prod", Branch: "release", DesiredStatus: status}, + {ID: "dev-deployment", Environment: "dev", Branch: "feature", DesiredStatus: status}, + {ID: "dev-branch-deployment", Environment: "dev", Branch: "another-feature", DesiredStatus: status}, + }, + desiredStatuses: make(map[string]database.DeploymentStatus), + } + jobClient := &slotUpdateJobs{} + svc := &Service{DB: db, Jobs: jobClient, Logger: zap.NewNop()} - updated, err := svc.UpdateProject(t.Context(), project, &database.UpdateProjectOptions{ - PrimaryBranch: "main", ProdSlots: 2, DevSlots: slots, + updated, err := svc.UpdateProject(t.Context(), project, &database.UpdateProjectOptions{ + PrimaryBranch: "main", ProdSlots: tt.prodSlots, DevSlots: tt.devSlots, Subpath: tt.subpath, + }) + require.NoError(t, err) + require.Equal(t, tt.devSlots, updated.DevSlots) + require.Equal(t, tt.prodSlots, updated.ProdSlots) + require.Equal(t, tt.subpath, updated.Subpath) + require.ElementsMatch(t, tt.expectedIDs, jobClient.deploymentIDs) + expectedStatuses := make(map[string]database.DeploymentStatus) + for _, id := range tt.expectedIDs { + expectedStatuses[id] = status + } + // Unaffected deployments must not be touched, and hibernated deployments must stay stopped. + require.Equal(t, expectedStatuses, db.desiredStatuses) }) - require.NoError(t, err) - require.Equal(t, slots, updated.DevSlots) - require.Equal(t, 2, updated.ProdSlots) - if slots == 2 { - require.Empty(t, jobClient.deploymentIDs) - } else { - require.Equal(t, []string{"dev-deployment"}, jobClient.deploymentIDs) - require.Equal(t, database.DeploymentStatusRunning, db.desiredStatus) - } - }) + } } } // Only the database and queue operations used by UpdateProject are implemented. type slotUpdateDB struct { database.DB - project *database.Project - desiredStatus database.DeploymentStatus + mu sync.Mutex + project *database.Project + deployments []*database.Deployment + desiredStatuses map[string]database.DeploymentStatus } -func (db *slotUpdateDB) FindDeploymentsForProject(_ context.Context, _, _, branch string) ([]*database.Deployment, error) { - if branch != "" { - return nil, nil +func (db *slotUpdateDB) FindDeploymentsForProject(_ context.Context, _, environment, branch string) ([]*database.Deployment, error) { + var deployments []*database.Deployment + for _, d := range db.deployments { + if (environment == "" || d.Environment == environment) && (branch == "" || d.Branch == branch) { + deployments = append(deployments, d) + } } - return []*database.Deployment{{ID: "dev-deployment", Environment: "dev", Branch: "feature", DesiredStatus: database.DeploymentStatusRunning}}, nil + return deployments, nil } func (db *slotUpdateDB) UpdateProject(_ context.Context, _ string, opts *database.UpdateProjectOptions) (*database.Project, error) { updated := *db.project updated.ProdSlots = opts.ProdSlots updated.DevSlots = opts.DevSlots + updated.Subpath = opts.Subpath return &updated, nil } func (db *slotUpdateDB) UpdateDeploymentDesiredStatus(_ context.Context, id string, desired database.DeploymentStatus) (*database.Deployment, error) { - db.desiredStatus = desired + db.mu.Lock() + defer db.mu.Unlock() + db.desiredStatuses[id] = desired return &database.Deployment{ID: id, DesiredStatus: desired}, nil } type slotUpdateJobs struct { jobs.Client + mu sync.Mutex deploymentIDs []string } func (j *slotUpdateJobs) ReconcileDeployment(_ context.Context, id string) (*jobs.InsertResult, error) { + j.mu.Lock() + defer j.mu.Unlock() j.deploymentIDs = append(j.deploymentIDs, id) return &jobs.InsertResult{}, nil } diff --git a/admin/projects.go b/admin/projects.go index bcdb6afb4977..a8dc83aa29cb 100644 --- a/admin/projects.go +++ b/admin/projects.go @@ -195,9 +195,7 @@ func (s *Service) UpdateProject(ctx context.Context, oldProj *database.Project, return nil, err } - impactsDeployments := (oldProj.ProdVersion != opts.ProdVersion) || - (oldProj.ProdSlots != opts.ProdSlots) || - (oldProj.DevSlots != opts.DevSlots) || + impactsAllDeployments := (oldProj.ProdVersion != opts.ProdVersion) || (oldProj.Name != opts.Name) || (oldProj.Subpath != opts.Subpath) || (oldProj.PrimaryBranch != opts.PrimaryBranch) || @@ -207,7 +205,9 @@ func (s *Service) UpdateProject(ctx context.Context, oldProj *database.Project, !reflect.DeepEqual(oldProj.ArchiveAssetID, opts.ArchiveAssetID) || !reflect.DeepEqual(oldProj.OverrideDiskGB, opts.OverrideDiskGB) - if !impactsDeployments { + prodSlotsChanged := oldProj.ProdSlots != opts.ProdSlots + devSlotsChanged := oldProj.DevSlots != opts.DevSlots + if !impactsAllDeployments && !prodSlotsChanged && !devSlotsChanged { return proj, nil } @@ -247,8 +247,17 @@ func (s *Service) UpdateProject(ctx context.Context, oldProj *database.Project, } } - // TODO: changing environment-specific fields like slots should only impact deployments in that environment, but for now we update all deployments. - err = s.UpdateDeploymentsForProject(ctx, proj) + // Slot-only changes should only reconcile deployments in the affected environment. + // An empty environment updates all deployments for shared changes or changes to both slot counts. + environment := "" + if !impactsAllDeployments { + if prodSlotsChanged && !devSlotsChanged { + environment = "prod" + } else if devSlotsChanged && !prodSlotsChanged { + environment = "dev" + } + } + err = s.UpdateDeploymentsForProject(ctx, proj, environment) if err != nil { return nil, err } @@ -294,7 +303,7 @@ func (s *Service) UpdateProjectVariables(ctx context.Context, project *database. // Update deployments s.Logger.Info("update project variables: updating deployments", observability.ZapCtx(ctx)) - err = s.UpdateDeploymentsForProject(ctx, project) + err = s.UpdateDeploymentsForProject(ctx, project, "") if err != nil { return err } @@ -315,7 +324,7 @@ func (s *Service) UpdateOrgDeploymentAnnotations(ctx context.Context, org *datab } for _, proj := range projs { - err := s.UpdateDeploymentsForProject(ctx, proj) + err := s.UpdateDeploymentsForProject(ctx, proj, "") if err != nil { return err } diff --git a/web-admin/src/features/bookmarks/utils.spec.ts b/web-admin/src/features/bookmarks/utils.spec.ts index 245ba44d695b..a4691aa333a1 100644 --- a/web-admin/src/features/bookmarks/utils.spec.ts +++ b/web-admin/src/features/bookmarks/utils.spec.ts @@ -24,6 +24,7 @@ import { AD_BIDS_TIME_RANGE_SUMMARY, } from "@rilldata/web-common/features/dashboards/stores/test-data/data.ts"; import { getTimeControlState } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store.ts"; +import { cleanUrlParams } from "@rilldata/web-common/features/dashboards/url-state/clean-url-params.ts"; import { convertPartialExploreStateToUrlParams } from "@rilldata/web-common/features/dashboards/url-state/convert-partial-explore-state-to-url-params.ts"; import type { DashboardTimeControls } from "@rilldata/web-common/lib/time/types.ts"; import { DashboardState_ActivePage } from "@rilldata/web-common/proto/gen/rill/ui/v1/dashboard_pb.ts"; @@ -67,7 +68,7 @@ describe("getBookmarkData and parseBookmarks", () => { ]), selectedTimeRange: { name: "P7D", - interval: V1TimeGrain.TIME_GRAIN_HOUR, // Equal to default + interval: V1TimeGrain.TIME_GRAIN_HOUR, // Explicit grain for the P7D range } as DashboardTimeControls, showTimeComparison: true, selectedComparisonTimeRange: { @@ -84,23 +85,23 @@ describe("getBookmarkData and parseBookmarks", () => { subTitle: "Empty url", curUrlSearch: "", expectedFullUrlSearch: - "view=explore&tr=P7D&tz=UTC&grain=hour&compare_tr=rill-PP&f=publisher+IN+('Facebook','Yahoo')&compare_dim=&measures=bid_price&dims=*&expand_dim=domain&sort_by=bid_price&sort_dir=DESC&sort_type=value&leaderboard_measures=bid_price", + "view=explore&tr=P7D&tz=UTC&grain=hour&compare_tr=rill-PP&f=publisher+IN+('Facebook','Yahoo')&compare_dim=&measures=bid_price&dims=*&expand_dim=domain&sort_by=bid_price&sort_dir=DESC&sort_type=value&leaderboard_measures=bid_price&chart_type=adaptive&dyn_y=false&lb_ctx=false", isActive: false, }, { subTitle: "Filter only equal", curUrlSearch: - "view=tdd&tr=P7D&compare_tr=rill-PW&f=publisher+IN+('Facebook','Yahoo')&measure=impressions&chart_type=stacked_bar", + "view=tdd&tr=P7D&grain=hour&compare_tr=rill-PW&f=publisher+IN+('Facebook','Yahoo')&measure=impressions&chart_type=stacked_bar", expectedFullUrlSearch: - "view=explore&tr=P7D&tz=UTC&grain=hour&compare_tr=rill-PP&f=publisher+IN+('Facebook','Yahoo')&compare_dim=&measures=bid_price&dims=*&expand_dim=domain&sort_by=bid_price&sort_dir=DESC&sort_type=value&leaderboard_measures=bid_price", + "view=explore&tr=P7D&tz=UTC&grain=hour&compare_tr=rill-PP&f=publisher+IN+('Facebook','Yahoo')&compare_dim=&measures=bid_price&dims=*&expand_dim=domain&sort_by=bid_price&sort_dir=DESC&sort_type=value&leaderboard_measures=bid_price&chart_type=adaptive&dyn_y=false&lb_ctx=false", isActive: false, }, { subTitle: "Same url", curUrlSearch: - "tr=P7D&compare_tr=rill-PP&f=publisher+IN+('Facebook','Yahoo')&measures=bid_price&expand_dim=domain&sort_by=bid_price&leaderboard_measures=bid_price", + "tr=P7D&compare_tr=rill-PP&grain=hour&f=publisher+IN+('Facebook','Yahoo')&measures=bid_price&expand_dim=domain&sort_by=bid_price&leaderboard_measures=bid_price", expectedFullUrlSearch: - "view=explore&tr=P7D&tz=UTC&grain=hour&compare_tr=rill-PP&f=publisher+IN+('Facebook','Yahoo')&compare_dim=&measures=bid_price&dims=*&expand_dim=domain&sort_by=bid_price&sort_dir=DESC&sort_type=value&leaderboard_measures=bid_price", + "view=explore&tr=P7D&tz=UTC&grain=hour&compare_tr=rill-PP&f=publisher+IN+('Facebook','Yahoo')&compare_dim=&measures=bid_price&dims=*&expand_dim=domain&sort_by=bid_price&sort_dir=DESC&sort_type=value&leaderboard_measures=bid_price&chart_type=adaptive&dyn_y=false&lb_ctx=false", isActive: true, }, ], @@ -139,7 +140,7 @@ describe("getBookmarkData and parseBookmarks", () => { { subTitle: "Filter only equal", curUrlSearch: - "view=tdd&tr=P7D&compare_tr=rill-PW&f=publisher+IN+('Facebook','Yahoo')&measure=impressions&chart_type=stacked_bar", + "view=tdd&tr=P7D&grain=hour&compare_tr=rill-PW&f=publisher+IN+('Facebook','Yahoo')&measure=impressions&chart_type=stacked_bar", expectedFullUrlSearch: "view=tdd&tr=P7D&grain=hour&compare_tr=rill-PW&f=publisher+IN+('Facebook','Yahoo')&measure=impressions&chart_type=stacked_bar", isActive: true, @@ -147,9 +148,9 @@ describe("getBookmarkData and parseBookmarks", () => { { subTitle: "Same url", curUrlSearch: - "tr=P7D&compare_tr=rill-PP&f=publisher+IN+('Facebook','Yahoo')&measures=bid_price&expand_dim=domain&sort_by=bid_price&leaderboard_measures=bid_price", + "tr=P7D&compare_tr=rill-PP&grain=hour&f=publisher+IN+('Facebook','Yahoo')&measures=bid_price&expand_dim=domain&sort_by=bid_price&leaderboard_measures=bid_price", expectedFullUrlSearch: - "tr=P7D&grain=hour&compare_tr=rill-PP&f=publisher+IN+('Facebook','Yahoo')&measures=bid_price&expand_dim=domain&sort_by=bid_price&leaderboard_measures=bid_price", + "tr=P7D&compare_tr=rill-PP&grain=hour&f=publisher+IN+('Facebook','Yahoo')&measures=bid_price&expand_dim=domain&sort_by=bid_price&leaderboard_measures=bid_price", isActive: true, }, ], @@ -226,9 +227,17 @@ describe("getBookmarkData and parseBookmarks", () => { expect(parsedBookmark.isActive).toEqual(isActive); assertUnorderedUrlSearch(parsedProtoBookmark.url, parsedBookmark.url); + // Legacy proto bookmarks omit newer default-valued settings (dyn_y and lb_ctx). + // Compare the complete navigation URLs after removing equivalent defaults. assertUnorderedUrlSearch( - parsedProtoBookmark.fullUrl, - parsedBookmark.fullUrl, + cleanUrlParams( + new URLSearchParams(parsedProtoBookmark.fullUrl.slice(1)), + AD_BIDS_RILL_DEFAULT_EXPLORE_URL_PARAMS, + ).toString(), + cleanUrlParams( + new URLSearchParams(parsedBookmark.fullUrl.slice(1)), + AD_BIDS_RILL_DEFAULT_EXPLORE_URL_PARAMS, + ).toString(), ); expect(parsedProtoBookmark.isActive).toEqual(isActive); }); diff --git a/web-admin/src/features/projects/status/tables/model-actions.spec.ts b/web-admin/src/features/projects/status/tables/model-actions.spec.ts index 7db9268c26df..4b2ea321fc66 100644 --- a/web-admin/src/features/projects/status/tables/model-actions.spec.ts +++ b/web-admin/src/features/projects/status/tables/model-actions.spec.ts @@ -70,7 +70,7 @@ describe("getAvailableModelActions", () => { expect(actions).not.toContain("refreshErrored"); }); - it("includes refreshErrored for partitioned model with errored partitions", () => { + it("omits refreshErrored for non-incremental model with errored partitions", () => { const resource = makeModel({ partitionsResolver: "sql", partitionsModelId: "abc-123", @@ -79,7 +79,7 @@ describe("getAvailableModelActions", () => { const actions = getAvailableModelActions(resource); expect(actions).toContain("viewPartitions"); - expect(actions).toContain("refreshErrored"); + expect(actions).not.toContain("refreshErrored"); expect(actions).toContain("fullRefresh"); }); @@ -103,6 +103,7 @@ describe("getAvailableModelActions", () => { it("does not show refreshErrored when partitionsHaveErrors is true but no partitionsModelId", () => { const resource = makeModel({ + incremental: true, partitionsResolver: "sql", partitionsHaveErrors: true, // no partitionsModelId diff --git a/web-admin/src/features/projects/status/tables/utils.spec.ts b/web-admin/src/features/projects/status/tables/utils.spec.ts index b4be99bde0f3..dadda2da1f00 100644 --- a/web-admin/src/features/projects/status/tables/utils.spec.ts +++ b/web-admin/src/features/projects/status/tables/utils.spec.ts @@ -221,7 +221,10 @@ describe("tables utils", () => { it("returns false when no partitionsModelId", () => { const resource: V1Resource = { - model: { state: { partitionsHaveErrors: true } }, + model: { + spec: { incremental: true }, + state: { partitionsHaveErrors: true }, + }, }; expect(hasModelErroredPartitions(resource)).toBe(false); }); @@ -229,15 +232,27 @@ describe("tables utils", () => { it("returns false when partitionsHaveErrors is false", () => { const resource: V1Resource = { model: { + spec: { incremental: true }, state: { partitionsModelId: "123", partitionsHaveErrors: false }, }, }; expect(hasModelErroredPartitions(resource)).toBe(false); }); - it("returns true when both conditions are met", () => { + it("returns false for a non-incremental model with errored partitions", () => { + const resource: V1Resource = { + model: { + spec: { incremental: false }, + state: { partitionsModelId: "123", partitionsHaveErrors: true }, + }, + }; + expect(hasModelErroredPartitions(resource)).toBe(false); + }); + + it("returns true for an incremental model with errored partitions", () => { const resource: V1Resource = { model: { + spec: { incremental: true }, state: { partitionsModelId: "123", partitionsHaveErrors: true }, }, }; diff --git a/web-admin/vite.config.ts b/web-admin/vite.config.ts index 33074f902975..359e08437b87 100644 --- a/web-admin/vite.config.ts +++ b/web-admin/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ extends: true, test: { name: "unit", + include: ["src/**/*.{spec,test}.ts"], exclude: [...configDefaults.exclude, "**/*.component.spec.ts"], }, },