From b330c0803ce75e29d1d8c344469cbd3fa9082b48 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Wed, 28 May 2025 14:18:32 -0400 Subject: [PATCH 1/4] fix: reimplement reporting of preset-hard-limited metric (#18055) Addresses concerns raised in https://github.com/coder/coder/pull/18045 --- coderd/prebuilds/global_snapshot.go | 35 ++++++---- .../coderd/prebuilds/metricscollector.go | 11 +--- enterprise/coderd/prebuilds/reconcile.go | 64 +++++++++++++------ enterprise/coderd/prebuilds/reconcile_test.go | 7 +- 4 files changed, 72 insertions(+), 45 deletions(-) diff --git a/coderd/prebuilds/global_snapshot.go b/coderd/prebuilds/global_snapshot.go index f4c094289b54e..976461780fd07 100644 --- a/coderd/prebuilds/global_snapshot.go +++ b/coderd/prebuilds/global_snapshot.go @@ -12,11 +12,11 @@ import ( // GlobalSnapshot represents a full point-in-time snapshot of state relating to prebuilds across all templates. type GlobalSnapshot struct { - Presets []database.GetTemplatePresetsWithPrebuildsRow - RunningPrebuilds []database.GetRunningPrebuiltWorkspacesRow - PrebuildsInProgress []database.CountInProgressPrebuildsRow - Backoffs []database.GetPresetsBackoffRow - HardLimitedPresets []database.GetPresetsAtFailureLimitRow + Presets []database.GetTemplatePresetsWithPrebuildsRow + RunningPrebuilds []database.GetRunningPrebuiltWorkspacesRow + PrebuildsInProgress []database.CountInProgressPrebuildsRow + Backoffs []database.GetPresetsBackoffRow + HardLimitedPresetsMap map[uuid.UUID]database.GetPresetsAtFailureLimitRow } func NewGlobalSnapshot( @@ -26,12 +26,17 @@ func NewGlobalSnapshot( backoffs []database.GetPresetsBackoffRow, hardLimitedPresets []database.GetPresetsAtFailureLimitRow, ) GlobalSnapshot { + hardLimitedPresetsMap := make(map[uuid.UUID]database.GetPresetsAtFailureLimitRow, len(hardLimitedPresets)) + for _, preset := range hardLimitedPresets { + hardLimitedPresetsMap[preset.PresetID] = preset + } + return GlobalSnapshot{ - Presets: presets, - RunningPrebuilds: runningPrebuilds, - PrebuildsInProgress: prebuildsInProgress, - Backoffs: backoffs, - HardLimitedPresets: hardLimitedPresets, + Presets: presets, + RunningPrebuilds: runningPrebuilds, + PrebuildsInProgress: prebuildsInProgress, + Backoffs: backoffs, + HardLimitedPresetsMap: hardLimitedPresetsMap, } } @@ -66,9 +71,7 @@ func (s GlobalSnapshot) FilterByPreset(presetID uuid.UUID) (*PresetSnapshot, err backoffPtr = &backoff } - _, isHardLimited := slice.Find(s.HardLimitedPresets, func(row database.GetPresetsAtFailureLimitRow) bool { - return row.PresetID == preset.ID - }) + _, isHardLimited := s.HardLimitedPresetsMap[preset.ID] return &PresetSnapshot{ Preset: preset, @@ -80,6 +83,12 @@ func (s GlobalSnapshot) FilterByPreset(presetID uuid.UUID) (*PresetSnapshot, err }, nil } +func (s GlobalSnapshot) IsHardLimited(presetID uuid.UUID) bool { + _, isHardLimited := s.HardLimitedPresetsMap[presetID] + + return isHardLimited +} + // filterExpiredWorkspaces splits running workspaces into expired and non-expired // based on the preset's TTL. // If TTL is missing or zero, all workspaces are considered non-expired. diff --git a/enterprise/coderd/prebuilds/metricscollector.go b/enterprise/coderd/prebuilds/metricscollector.go index 90257c26dd580..4499849ffde0a 100644 --- a/enterprise/coderd/prebuilds/metricscollector.go +++ b/enterprise/coderd/prebuilds/metricscollector.go @@ -280,16 +280,9 @@ func (k hardLimitedPresetKey) String() string { return fmt.Sprintf("%s:%s:%s", k.orgName, k.templateName, k.presetName) } -// nolint:revive // isHardLimited determines if the preset should be reported as hard-limited in Prometheus. -func (mc *MetricsCollector) trackHardLimitedStatus(orgName, templateName, presetName string, isHardLimited bool) { +func (mc *MetricsCollector) registerHardLimitedPresets(isPresetHardLimited map[hardLimitedPresetKey]bool) { mc.isPresetHardLimitedMu.Lock() defer mc.isPresetHardLimitedMu.Unlock() - key := hardLimitedPresetKey{orgName: orgName, templateName: templateName, presetName: presetName} - - if isHardLimited { - mc.isPresetHardLimited[key] = true - } else { - delete(mc.isPresetHardLimited, key) - } + mc.isPresetHardLimited = isPresetHardLimited } diff --git a/enterprise/coderd/prebuilds/reconcile.go b/enterprise/coderd/prebuilds/reconcile.go index 90c97afa26d69..ebfcfaf2b3182 100644 --- a/enterprise/coderd/prebuilds/reconcile.go +++ b/enterprise/coderd/prebuilds/reconcile.go @@ -256,6 +256,9 @@ func (c *StoreReconciler) ReconcileAll(ctx context.Context) error { if err != nil { return xerrors.Errorf("determine current snapshot: %w", err) } + + c.reportHardLimitedPresets(snapshot) + if len(snapshot.Presets) == 0 { logger.Debug(ctx, "no templates found with prebuilds configured") return nil @@ -296,6 +299,49 @@ func (c *StoreReconciler) ReconcileAll(ctx context.Context) error { return err } +func (c *StoreReconciler) reportHardLimitedPresets(snapshot *prebuilds.GlobalSnapshot) { + // presetsMap is a map from key (orgName:templateName:presetName) to list of corresponding presets. + // Multiple versions of a preset can exist with the same orgName, templateName, and presetName, + // because templates can have multiple versions — or deleted templates can share the same name. + presetsMap := make(map[hardLimitedPresetKey][]database.GetTemplatePresetsWithPrebuildsRow) + for _, preset := range snapshot.Presets { + key := hardLimitedPresetKey{ + orgName: preset.OrganizationName, + templateName: preset.TemplateName, + presetName: preset.Name, + } + + presetsMap[key] = append(presetsMap[key], preset) + } + + // Report a preset as hard-limited only if all the following conditions are met: + // - The preset is marked as hard-limited + // - The preset is using the active version of its template, and the template has not been deleted + // + // The second condition is important because a hard-limited preset that has become outdated is no longer relevant. + // Its associated prebuilt workspaces were likely deleted, and it's not meaningful to continue reporting it + // as hard-limited to the admin. + // + // This approach accounts for all relevant scenarios: + // Scenario #1: The admin created a new template version with the same preset names. + // Scenario #2: The admin created a new template version and renamed the presets. + // Scenario #3: The admin deleted a template version that contained hard-limited presets. + // + // In all of these cases, only the latest and non-deleted presets will be reported. + // All other presets will be ignored and eventually removed from Prometheus. + isPresetHardLimited := make(map[hardLimitedPresetKey]bool) + for key, presets := range presetsMap { + for _, preset := range presets { + if preset.UsingActiveVersion && !preset.Deleted && snapshot.IsHardLimited(preset.ID) { + isPresetHardLimited[key] = true + break + } + } + } + + c.metrics.registerHardLimitedPresets(isPresetHardLimited) +} + // SnapshotState captures the current state of all prebuilds across templates. func (c *StoreReconciler) SnapshotState(ctx context.Context, store database.Store) (*prebuilds.GlobalSnapshot, error) { if err := ctx.Err(); err != nil { @@ -361,24 +407,6 @@ func (c *StoreReconciler) ReconcilePreset(ctx context.Context, ps prebuilds.Pres slog.F("preset_name", ps.Preset.Name), ) - // Report a metric only if the preset uses the latest version of the template and the template is not deleted. - // This avoids conflicts between metrics from old and new template versions. - // - // NOTE: Multiple versions of a preset can exist with the same orgName, templateName, and presetName, - // because templates can have multiple versions — or deleted templates can share the same name. - // - // The safest approach is to report the metric only for the latest version of the preset. - // When a new template version is released, the metric for the new preset should overwrite - // the old value in Prometheus. - // - // However, there’s one edge case: if an admin creates a template, it becomes hard-limited, - // then deletes the template and never creates another with the same name, - // the old preset will continue to be reported as hard-limited — - // even though it’s deleted. This will persist until `coderd` is restarted. - if ps.Preset.UsingActiveVersion && !ps.Preset.Deleted { - c.metrics.trackHardLimitedStatus(ps.Preset.OrganizationName, ps.Preset.TemplateName, ps.Preset.Name, ps.IsHardLimited) - } - // If the preset reached the hard failure limit for the first time during this iteration: // - Mark it as hard-limited in the database // - Send notifications to template admins diff --git a/enterprise/coderd/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go index 7de22db64c8be..a0e1f9726d7d5 100644 --- a/enterprise/coderd/prebuilds/reconcile_test.go +++ b/enterprise/coderd/prebuilds/reconcile_test.go @@ -1034,8 +1034,7 @@ func TestHardLimitedPresetShouldNotBlockDeletion(t *testing.T) { require.Equal(t, database.WorkspaceTransitionDelete, workspaceBuilds[0].Transition) require.Equal(t, database.WorkspaceTransitionStart, workspaceBuilds[1].Transition) - // The metric is still set to 1, even though the preset has become outdated. - // This happens because the old value hasn't been overwritten by a newer preset yet. + // Metric is deleted after preset became outdated. mf, err = registry.Gather() require.NoError(t, err) metric = findMetric(mf, prebuilds.MetricPresetHardLimitedGauge, map[string]string{ @@ -1043,9 +1042,7 @@ func TestHardLimitedPresetShouldNotBlockDeletion(t *testing.T) { "preset_name": preset.Name, "org_name": org.Name, }) - require.NotNil(t, metric) - require.NotNil(t, metric.GetGauge()) - require.EqualValues(t, 1, metric.GetGauge().GetValue()) + require.Nil(t, metric) }) } } From bc3b8d5a51145d93c6dbf00bdaedb533d845c708 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 28 May 2025 15:19:05 -0300 Subject: [PATCH 2/4] feat: add task page (#18076) **Demo:** ![image](https://github.com/user-attachments/assets/ca59ba92-a73a-4613-ae41-910f3f0455d2) --- site/src/components/Table/Table.tsx | 28 +- site/src/modules/apps/AppStatusIcon.tsx | 47 ++ site/src/modules/tasks/tasks.ts | 8 + .../WorkspaceAppStatus/WorkspaceAppStatus.tsx | 6 +- site/src/pages/TaskPage/TaskPage.stories.tsx | 150 +++++++ site/src/pages/TaskPage/TaskPage.tsx | 407 ++++++++++++++++++ site/src/pages/TasksPage/TasksPage.tsx | 35 +- site/src/pages/TerminalPage/TerminalPage.tsx | 6 +- site/src/pages/WorkspacePage/AppStatuses.tsx | 51 +-- site/src/router.tsx | 2 + 10 files changed, 676 insertions(+), 64 deletions(-) create mode 100644 site/src/modules/apps/AppStatusIcon.tsx create mode 100644 site/src/modules/tasks/tasks.ts create mode 100644 site/src/pages/TaskPage/TaskPage.stories.tsx create mode 100644 site/src/pages/TaskPage/TaskPage.tsx diff --git a/site/src/components/Table/Table.tsx b/site/src/components/Table/Table.tsx index c20fe99428e09..b642655f5539b 100644 --- a/site/src/components/Table/Table.tsx +++ b/site/src/components/Table/Table.tsx @@ -3,6 +3,7 @@ * @see {@link https://ui.shadcn.com/docs/components/table} */ +import { type VariantProps, cva } from "class-variance-authority"; import * as React from "react"; import { cn } from "utils/cn"; @@ -60,15 +61,38 @@ const TableFooter = React.forwardRef< /> )); +const tableRowVariants = cva( + [ + "border-0 border-b border-solid border-border transition-colors", + "data-[state=selected]:bg-muted", + ], + { + variants: { + hover: { + false: null, + true: cn([ + "cursor-pointer hover:outline focus:outline outline-1 -outline-offset-1 outline-border-hover", + "first:rounded-t-md last:rounded-b-md", + ]), + }, + }, + defaultVariants: { + hover: false, + }, + }, +); + export const TableRow = React.forwardRef< HTMLTableRowElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( + React.HTMLAttributes & + VariantProps +>(({ className, hover, ...props }, ref) => ( = ({ + status, + latest, + className: customClassName, +}) => { + const className = cn(["size-4 shrink-0", customClassName]); + + switch (status.state) { + case "complete": + return ( + + ); + case "failure": + return ( + + ); + case "working": + return latest ? ( + + ) : ( + + ); + default: + return ( + + ); + } +}; diff --git a/site/src/modules/tasks/tasks.ts b/site/src/modules/tasks/tasks.ts new file mode 100644 index 0000000000000..c48f5ec1c3f22 --- /dev/null +++ b/site/src/modules/tasks/tasks.ts @@ -0,0 +1,8 @@ +import type { Workspace } from "api/typesGenerated"; + +export const AI_PROMPT_PARAMETER_NAME = "AI Prompt"; + +export type Task = { + workspace: Workspace; + prompt: string; +}; diff --git a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx index 76e74f17c351e..f2eab7f2086ac 100644 --- a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx +++ b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx @@ -34,7 +34,7 @@ export const WorkspaceAppStatus = ({ } return ( -
+
@@ -48,7 +48,9 @@ export const WorkspaceAppStatus = ({ {status.message} - {status.state} + + {status.state} +
); }; diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx new file mode 100644 index 0000000000000..1fd9c4b93cfa6 --- /dev/null +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -0,0 +1,150 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { spyOn } from "@storybook/test"; +import { + MockFailedWorkspace, + MockStartingWorkspace, + MockStoppedWorkspace, + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceApp, + MockWorkspaceAppStatus, + MockWorkspaceResource, + mockApiError, +} from "testHelpers/entities"; +import { withProxyProvider } from "testHelpers/storybook"; +import TaskPage, { data } from "./TaskPage"; + +const meta: Meta = { + title: "pages/TaskPage", + component: TaskPage, + parameters: { + layout: "fullscreen", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockImplementation( + () => new Promise((res) => 1000 * 60 * 60), + ); + }, +}; + +export const LoadingError: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockRejectedValue( + mockApiError({ + message: "Failed to load task", + detail: "You don't have permission to access this resource.", + }), + ); + }, +}; + +export const WaitingOnBuild: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: MockStartingWorkspace, + }); + }, +}; + +export const WaitingOnStatus: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockWorkspace, + latest_app_status: null, + }, + }); + }, +}; + +export const FailedBuild: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: MockFailedWorkspace, + }); + }, +}; + +export const TerminatedBuild: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: MockStoppedWorkspace, + }); + }, +}; + +export const TerminatedBuildWithStatus: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockStoppedWorkspace, + latest_app_status: MockWorkspaceAppStatus, + }, + }); + }, +}; + +export const Active: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + resources: [ + { + ...MockWorkspaceResource, + agents: [ + { + ...MockWorkspaceAgent, + apps: [ + { + ...MockWorkspaceApp, + id: "claude-code", + display_name: "Claude Code", + icon: "/icon/claude.svg", + url: `${window.location.protocol}/iframe.html?viewMode=story&id=pages-terminal--ready&args=&globals=`, + external: true, + statuses: [ + MockWorkspaceAppStatus, + { + ...MockWorkspaceAppStatus, + id: "2", + message: "Planning changes", + state: "working", + }, + ], + }, + { + ...MockWorkspaceApp, + id: "vscode", + display_name: "VSCode", + icon: "/icon/code.svg", + }, + ], + }, + ], + }, + ], + }, + latest_app_status: { + ...MockWorkspaceAppStatus, + app_id: "claude-code", + }, + }, + }); + }, +}; diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx new file mode 100644 index 0000000000000..692c99db2d63f --- /dev/null +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -0,0 +1,407 @@ +import { API } from "api/api"; +import { getErrorDetail, getErrorMessage } from "api/errors"; +import type { WorkspaceApp, WorkspaceStatus } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { Loader } from "components/Loader/Loader"; +import { Margins } from "components/Margins/Margins"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { Spinner } from "components/Spinner/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { useProxy } from "contexts/ProxyContext"; +import { ArrowLeftIcon, LayoutGridIcon, RotateCcwIcon } from "lucide-react"; +import { AppStatusIcon } from "modules/apps/AppStatusIcon"; +import { getAppHref } from "modules/apps/apps"; +import { useAppLink } from "modules/apps/useAppLink"; +import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; +import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; +import type React from "react"; +import { type FC, type ReactNode, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { useQuery } from "react-query"; +import { useParams } from "react-router-dom"; +import { Link as RouterLink } from "react-router-dom"; +import { cn } from "utils/cn"; +import { pageTitle } from "utils/page"; +import { timeFrom } from "utils/time"; + +const TaskPage = () => { + const { workspace: workspaceName, username } = useParams() as { + workspace: string; + username: string; + }; + const { + data: task, + error, + refetch, + } = useQuery({ + queryKey: ["tasks", username, workspaceName], + queryFn: () => data.fetchTask(username, workspaceName), + refetchInterval: 5_000, + }); + + if (error) { + return ( + <> + + {pageTitle("Error loading task")} + + +
+
+

+ {getErrorMessage(error, "Failed to load task")} +

+ + {getErrorDetail(error)} + +
+ + +
+
+
+ + ); + } + + if (!task) { + return ( + <> + + {pageTitle("Loading task")} + + + + ); + } + + let content: ReactNode = null; + const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"]; + const terminatedStatuses: WorkspaceStatus[] = [ + "canceled", + "canceling", + "deleted", + "deleting", + "stopped", + "stopping", + ]; + + if (waitingStatuses.includes(task.workspace.latest_build.status)) { + content = ( +
+
+ +

+ Building your task +

+ + Your task is being built and will be ready soon + +
+
+ ); + } else if (task.workspace.latest_build.status === "failed") { + content = ( +
+
+

+ Task build failed +

+ + Please check the logs for more details. + + +
+
+ ); + } else if (terminatedStatuses.includes(task.workspace.latest_build.status)) { + content = ( + +
+ {task.workspace.latest_app_status && ( +
+ +
+ )} +
+
+

+ Task build terminated +

+ + So apps and previous statuses are not available + +
+
+
+
+ ); + } else if (!task.workspace.latest_app_status) { + content = ( +
+
+ +

+ Running your task +

+ + The status should be available soon + +
+
+ ); + } else { + const statuses = task.workspace.latest_build.resources + .flatMap((r) => r.agents) + .flatMap((a) => a?.apps) + .flatMap((a) => a?.statuses) + .filter((s) => !!s) + .sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); + + content = ( +
+ + + +
+ ); + } + + return ( + <> + + {pageTitle(task.prompt)} + + +
+
+
+ + + + + + Back to tasks + + + +
+

{task.prompt}

+ + Created by {task.workspace.owner_name}{" "} + {timeFrom(new Date(task.workspace.created_at))} + +
+
+
+ + {content} +
+ + ); +}; + +export default TaskPage; + +type TaskAppsProps = { + task: Task; +}; + +const TaskApps: FC = ({ task }) => { + const agents = task.workspace.latest_build.resources + .flatMap((r) => r.agents) + .filter((a) => !!a); + + const apps = agents.flatMap((a) => a?.apps).filter((a) => !!a); + + const [activeAppId, setActiveAppId] = useState(() => { + const appId = task.workspace.latest_app_status?.app_id; + if (!appId) { + throw new Error("No active app found in task"); + } + return appId; + }); + + const activeApp = apps.find((app) => app.id === activeAppId); + if (!activeApp) { + throw new Error(`Active app with ID ${activeAppId} not found in task`); + } + + const agent = agents.find((a) => + a.apps.some((app) => app.id === activeAppId), + ); + if (!agent) { + throw new Error(`Agent for app ${activeAppId} not found in task workspace`); + } + + const { proxy } = useProxy(); + const [iframeSrc, setIframeSrc] = useState(() => { + const src = getAppHref(activeApp, { + agent, + workspace: task.workspace, + path: proxy.preferredPathAppURL, + host: proxy.preferredWildcardHostname, + }); + return src; + }); + + return ( +
+
+ {apps.map((app) => ( + { + if (app.external) { + return; + } + + e.preventDefault(); + setActiveAppId(app.id); + setIframeSrc(e.currentTarget.href); + }} + /> + ))} +
+ +
+