mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add backend logic for determining tasks tab visibility (#18401)
This PR implements the backend logic for determining if the Tasks tab should be visible in the web UI as described in [the RFC](https://www.notion.so/coderhq/Coder-Tasks-207d579be5928053ab68c8d9a4b59eaa?source=copy_link#210d579be5928013ab5acbe69a2f548b). The frontend component will be added in a follow-up PR once the entire Tasks backend is implemented so as not to break the dogfood environment until then.
This commit is contained in:
+3
@@ -85,6 +85,9 @@ Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
|
||||
is detected. By default it instructs users to update using 'curl -L
|
||||
https://coder.com/install.sh | sh'.
|
||||
|
||||
--hide-ai-tasks bool, $CODER_HIDE_AI_TASKS (default: false)
|
||||
Hide AI tasks from the dashboard.
|
||||
|
||||
--ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS
|
||||
These SSH config options will override the default SSH config options.
|
||||
Provide options in "key=value" or "key value" format separated by
|
||||
|
||||
+3
@@ -520,6 +520,9 @@ client:
|
||||
# 'webgl', or 'dom'.
|
||||
# (default: canvas, type: string)
|
||||
webTerminalRenderer: canvas
|
||||
# Hide AI tasks from the dashboard.
|
||||
# (default: false, type: bool)
|
||||
hideAITasks: false
|
||||
# Support links to display in the top right drop down menu.
|
||||
# (default: <unset>, type: struct[[]codersdk.LinkConfig])
|
||||
supportLinks: []
|
||||
|
||||
Generated
+3
@@ -12483,6 +12483,9 @@ const docTemplate = `{
|
||||
"healthcheck": {
|
||||
"$ref": "#/definitions/codersdk.HealthcheckConfig"
|
||||
},
|
||||
"hide_ai_tasks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"http_address": {
|
||||
"description": "HTTPAddress is a string because it may be set to zero to disable.",
|
||||
"type": "string"
|
||||
|
||||
Generated
+3
@@ -11183,6 +11183,9 @@
|
||||
"healthcheck": {
|
||||
"$ref": "#/definitions/codersdk.HealthcheckConfig"
|
||||
},
|
||||
"hide_ai_tasks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"http_address": {
|
||||
"description": "HTTPAddress is a string because it may be set to zero to disable.",
|
||||
"type": "string"
|
||||
|
||||
@@ -628,6 +628,7 @@ func New(options *Options) *API {
|
||||
Entitlements: options.Entitlements,
|
||||
Telemetry: options.Telemetry,
|
||||
Logger: options.Logger.Named("site"),
|
||||
HideAITasks: options.DeploymentValues.HideAITasks.Value(),
|
||||
})
|
||||
api.SiteHandler.Experiments.Store(&experiments)
|
||||
|
||||
|
||||
@@ -3451,6 +3451,11 @@ func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now ti
|
||||
return q.db.GetWorkspacesEligibleForTransition(ctx, now)
|
||||
}
|
||||
|
||||
func (q *querier) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) {
|
||||
// Anyone can call HasTemplateVersionsWithAITask.
|
||||
return q.db.HasTemplateVersionsWithAITask(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) {
|
||||
return insert(q.log, q.auth,
|
||||
rbac.ResourceApiKey.WithOwner(arg.UserID.String()),
|
||||
|
||||
@@ -4566,6 +4566,9 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
s.Run("GetProvisionerJobByIDForUpdate", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(uuid.New()).Asserts(rbac.ResourceProvisionerJobs, policy.ActionRead).Errors(sql.ErrNoRows)
|
||||
}))
|
||||
s.Run("HasTemplateVersionsWithAITask", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args().Asserts()
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestNotifications() {
|
||||
|
||||
@@ -8495,6 +8495,19 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no
|
||||
return workspaces, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) HasTemplateVersionsWithAITask(_ context.Context) (bool, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
for _, templateVersion := range q.templateVersions {
|
||||
if templateVersion.HasAITask {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.APIKey{}, err
|
||||
|
||||
@@ -2041,6 +2041,13 @@ func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Contex
|
||||
return workspaces, err
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.HasTemplateVersionsWithAITask(ctx)
|
||||
m.queryLatencies.WithLabelValues("HasTemplateVersionsWithAITask").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) {
|
||||
start := time.Now()
|
||||
key, err := m.s.InsertAPIKey(ctx, arg)
|
||||
|
||||
@@ -4292,6 +4292,21 @@ func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForTransition(ctx, now any
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForTransition", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForTransition), ctx, now)
|
||||
}
|
||||
|
||||
// HasTemplateVersionsWithAITask mocks base method.
|
||||
func (m *MockStore) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HasTemplateVersionsWithAITask", ctx)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// HasTemplateVersionsWithAITask indicates an expected call of HasTemplateVersionsWithAITask.
|
||||
func (mr *MockStoreMockRecorder) HasTemplateVersionsWithAITask(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasTemplateVersionsWithAITask", reflect.TypeOf((*MockStore)(nil).HasTemplateVersionsWithAITask), ctx)
|
||||
}
|
||||
|
||||
// InTx mocks base method.
|
||||
func (m *MockStore) InTx(arg0 func(database.Store) error, arg1 *database.TxOptions) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -462,6 +462,8 @@ type sqlcQuerier interface {
|
||||
GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error)
|
||||
GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error)
|
||||
GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error)
|
||||
// Determines if the template versions table has any rows with has_ai_task = TRUE.
|
||||
HasTemplateVersionsWithAITask(ctx context.Context) (bool, error)
|
||||
InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error)
|
||||
// We use the organization_id as the id
|
||||
// for simplicity since all users is
|
||||
|
||||
@@ -11806,6 +11806,18 @@ func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, create
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const hasTemplateVersionsWithAITask = `-- name: HasTemplateVersionsWithAITask :one
|
||||
SELECT EXISTS (SELECT 1 FROM template_versions WHERE has_ai_task = TRUE)
|
||||
`
|
||||
|
||||
// Determines if the template versions table has any rows with has_ai_task = TRUE.
|
||||
func (q *sqlQuerier) HasTemplateVersionsWithAITask(ctx context.Context) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, hasTemplateVersionsWithAITask)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const insertTemplateVersion = `-- name: InsertTemplateVersion :exec
|
||||
INSERT INTO
|
||||
template_versions (
|
||||
|
||||
@@ -226,3 +226,7 @@ FROM
|
||||
WHERE
|
||||
template_versions.id IN (archived_versions.id)
|
||||
RETURNING template_versions.id;
|
||||
|
||||
-- name: HasTemplateVersionsWithAITask :one
|
||||
-- Determines if the template versions table has any rows with has_ai_task = TRUE.
|
||||
SELECT EXISTS (SELECT 1 FROM template_versions WHERE has_ai_task = TRUE);
|
||||
|
||||
@@ -399,6 +399,7 @@ type DeploymentValues struct {
|
||||
AdditionalCSPPolicy serpent.StringArray `json:"additional_csp_policy,omitempty" typescript:",notnull"`
|
||||
WorkspaceHostnameSuffix serpent.String `json:"workspace_hostname_suffix,omitempty" typescript:",notnull"`
|
||||
Prebuilds PrebuildsConfig `json:"workspace_prebuilds,omitempty" typescript:",notnull"`
|
||||
HideAITasks serpent.Bool `json:"hide_ai_tasks,omitempty" typescript:",notnull"`
|
||||
|
||||
Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
|
||||
WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"`
|
||||
@@ -3116,6 +3117,16 @@ Write out the current server config as YAML to stdout.`,
|
||||
YAML: "failure_hard_limit",
|
||||
Hidden: true,
|
||||
},
|
||||
{
|
||||
Name: "Hide AI Tasks",
|
||||
Description: "Hide AI tasks from the dashboard.",
|
||||
Flag: "hide-ai-tasks",
|
||||
Env: "CODER_HIDE_AI_TASKS",
|
||||
Default: "false",
|
||||
Value: &c.HideAITasks,
|
||||
Group: &deploymentGroupClient,
|
||||
YAML: "hideAITasks",
|
||||
},
|
||||
}
|
||||
|
||||
return opts
|
||||
|
||||
Generated
+1
@@ -272,6 +272,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
|
||||
"refresh": 0,
|
||||
"threshold_database": 0
|
||||
},
|
||||
"hide_ai_tasks": true,
|
||||
"http_address": "string",
|
||||
"http_cookies": {
|
||||
"same_site": "string",
|
||||
|
||||
Generated
+3
@@ -2443,6 +2443,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"refresh": 0,
|
||||
"threshold_database": 0
|
||||
},
|
||||
"hide_ai_tasks": true,
|
||||
"http_address": "string",
|
||||
"http_cookies": {
|
||||
"same_site": "string",
|
||||
@@ -2943,6 +2944,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"refresh": 0,
|
||||
"threshold_database": 0
|
||||
},
|
||||
"hide_ai_tasks": true,
|
||||
"http_address": "string",
|
||||
"http_cookies": {
|
||||
"same_site": "string",
|
||||
@@ -3243,6 +3245,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
| `external_auth` | [serpent.Struct-array_codersdk_ExternalAuthConfig](#serpentstruct-array_codersdk_externalauthconfig) | false | | |
|
||||
| `external_token_encryption_keys` | array of string | false | | |
|
||||
| `healthcheck` | [codersdk.HealthcheckConfig](#codersdkhealthcheckconfig) | false | | |
|
||||
| `hide_ai_tasks` | boolean | false | | |
|
||||
| `http_address` | string | false | | Http address is a string because it may be set to zero to disable. |
|
||||
| `http_cookies` | [codersdk.HTTPCookieConfig](#codersdkhttpcookieconfig) | false | | |
|
||||
| `in_memory_database` | boolean | false | | |
|
||||
|
||||
Generated
+11
@@ -1614,3 +1614,14 @@ Enable Coder Inbox.
|
||||
| Default | <code>5</code> |
|
||||
|
||||
The upper limit of attempts to send a notification.
|
||||
|
||||
### --hide-ai-tasks
|
||||
|
||||
| | |
|
||||
|-------------|-----------------------------------|
|
||||
| Type | <code>bool</code> |
|
||||
| Environment | <code>$CODER_HIDE_AI_TASKS</code> |
|
||||
| YAML | <code>client.hideAITasks</code> |
|
||||
| Default | <code>false</code> |
|
||||
|
||||
Hide AI tasks from the dashboard.
|
||||
|
||||
@@ -86,6 +86,9 @@ Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
|
||||
is detected. By default it instructs users to update using 'curl -L
|
||||
https://coder.com/install.sh | sh'.
|
||||
|
||||
--hide-ai-tasks bool, $CODER_HIDE_AI_TASKS (default: false)
|
||||
Hide AI tasks from the dashboard.
|
||||
|
||||
--ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS
|
||||
These SSH config options will override the default SSH config options.
|
||||
Provide options in "key=value" or "key value" format separated by
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<meta property="regions" content="{{ .Regions }}" />
|
||||
<meta property="docs-url" content="{{ .DocsURL }}" />
|
||||
<meta property="logo-url" content="{{ .LogoURL }}" />
|
||||
<meta property="tasks-tab-visible" content="{{ .TasksTabVisible }}" />
|
||||
<!-- We need to set data-react-helmet to be able to override it in the workspace page -->
|
||||
<link
|
||||
rel="alternate icon"
|
||||
|
||||
@@ -85,6 +85,7 @@ type Options struct {
|
||||
Entitlements *entitlements.Set
|
||||
Telemetry telemetry.Reporter
|
||||
Logger slog.Logger
|
||||
HideAITasks bool
|
||||
}
|
||||
|
||||
func New(opts *Options) *Handler {
|
||||
@@ -316,6 +317,8 @@ type htmlState struct {
|
||||
Experiments string
|
||||
Regions string
|
||||
DocsURL string
|
||||
|
||||
TasksTabVisible string
|
||||
}
|
||||
|
||||
type csrfState struct {
|
||||
@@ -445,6 +448,7 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
|
||||
var user database.User
|
||||
var themePreference string
|
||||
var terminalFont string
|
||||
var tasksTabVisible bool
|
||||
orgIDs := []uuid.UUID{}
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
@@ -480,6 +484,20 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
|
||||
orgIDs = memberIDs[0].OrganizationIDs
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
// If HideAITasks is true, force hide the tasks tab
|
||||
if h.opts.HideAITasks {
|
||||
tasksTabVisible = false
|
||||
return nil
|
||||
}
|
||||
|
||||
hasAITask, err := h.opts.Database.HasTemplateVersionsWithAITask(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tasksTabVisible = hasAITask
|
||||
return nil
|
||||
})
|
||||
err := eg.Wait()
|
||||
if err == nil {
|
||||
var wg sync.WaitGroup
|
||||
@@ -550,6 +568,14 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tasksTabVisible, err := json.Marshal(tasksTabVisible)
|
||||
if err == nil {
|
||||
state.TasksTabVisible = html.EscapeString(string(tasksTabVisible))
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
Generated
+1
@@ -736,6 +736,7 @@ export interface DeploymentValues {
|
||||
readonly additional_csp_policy?: string;
|
||||
readonly workspace_hostname_suffix?: string;
|
||||
readonly workspace_prebuilds?: PrebuildsConfig;
|
||||
readonly hide_ai_tasks?: boolean;
|
||||
readonly config?: string;
|
||||
readonly write_config?: boolean;
|
||||
readonly address?: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
MockBuildInfo,
|
||||
MockEntitlements,
|
||||
MockExperiments,
|
||||
MockTasksTabVisible,
|
||||
MockUserAppearanceSettings,
|
||||
MockUserOwner,
|
||||
} from "testHelpers/entities";
|
||||
@@ -41,6 +42,7 @@ const mockDataForTags = {
|
||||
user: MockUserOwner,
|
||||
userAppearance: MockUserAppearanceSettings,
|
||||
regions: MockRegions,
|
||||
tasksTabVisible: MockTasksTabVisible,
|
||||
} as const satisfies Record<MetadataKey, MetadataValue>;
|
||||
|
||||
const emptyMetadata: RuntimeHtmlMetadata = {
|
||||
@@ -72,6 +74,10 @@ const emptyMetadata: RuntimeHtmlMetadata = {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
tasksTabVisible: {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const populatedMetadata: RuntimeHtmlMetadata = {
|
||||
@@ -103,6 +109,10 @@ const populatedMetadata: RuntimeHtmlMetadata = {
|
||||
available: true,
|
||||
value: MockUserAppearanceSettings,
|
||||
},
|
||||
tasksTabVisible: {
|
||||
available: true,
|
||||
value: MockTasksTabVisible,
|
||||
},
|
||||
};
|
||||
|
||||
function seedInitialMetadata(metadataKey: string): () => void {
|
||||
|
||||
@@ -30,6 +30,7 @@ type AvailableMetadata = Readonly<{
|
||||
entitlements: Entitlements;
|
||||
regions: readonly Region[];
|
||||
"build-info": BuildInfoResponse;
|
||||
tasksTabVisible: boolean;
|
||||
}>;
|
||||
|
||||
export type MetadataKey = keyof AvailableMetadata;
|
||||
@@ -91,6 +92,7 @@ export class MetadataManager implements MetadataManagerApi {
|
||||
experiments: this.registerValue<Experiments>("experiments"),
|
||||
"build-info": this.registerValue<BuildInfoResponse>("build-info"),
|
||||
regions: this.registerRegionValue(),
|
||||
tasksTabVisible: this.registerValue<boolean>("tasksTabVisible"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -534,6 +534,8 @@ export const MockUserAppearanceSettings: TypesGen.UserAppearanceSettings = {
|
||||
terminal_font: "",
|
||||
};
|
||||
|
||||
export const MockTasksTabVisible: boolean = false;
|
||||
|
||||
export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = {
|
||||
organization_id: MockOrganization.id,
|
||||
user_id: MockUserOwner.id,
|
||||
|
||||
Reference in New Issue
Block a user