Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b8f87d325 | |||
| ee84b2aba9 | |||
| a164d508cf | |||
| b9f140e53e | |||
| 7f7b13f0ab | |||
| e2bbd12137 | |||
| e769d1bd7d | |||
| cccb680ec2 | |||
| e8fb418820 | |||
| 2c5e003c91 | |||
| f44a8994da | |||
| 84b94a8376 | |||
| 2a990ce758 | |||
| c86f1288f1 | |||
| 9440adf435 | |||
| 755e8be5ad | |||
| c9e335c453 | |||
| 2d1f35f8a6 | |||
| b0036af57b | |||
| 2953245862 | |||
| 5d07014f9f | |||
| 002e88fefc | |||
| bbf3fbc830 | |||
| 9fa103929a | |||
| acd2ff63a7 | |||
| e3e17e15f7 | |||
| af678606fc | |||
| 3190406de3 | |||
| 3ce82bb885 |
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.25.7"
|
||||
default: "1.25.8"
|
||||
use-cache:
|
||||
description: "Whether to use the cache."
|
||||
default: "true"
|
||||
|
||||
@@ -240,6 +240,7 @@ jobs:
|
||||
- name: Create Coder Task for Documentation Check
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
id: create_task
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/create-task-action
|
||||
with:
|
||||
coder-url: ${{ secrets.DOC_CHECK_CODER_URL }}
|
||||
@@ -254,8 +255,21 @@ jobs:
|
||||
github-issue-url: ${{ steps.determine-context.outputs.pr_url }}
|
||||
comment-on-issue: false
|
||||
|
||||
- name: Handle Task Creation Failure
|
||||
if: steps.check-secrets.outputs.skip != 'true' && steps.create_task.outcome != 'success'
|
||||
run: |
|
||||
{
|
||||
echo "## Documentation Check Task"
|
||||
echo ""
|
||||
echo "⚠️ The external Coder task service was unavailable, so this"
|
||||
echo "advisory documentation check did not run."
|
||||
echo ""
|
||||
echo "Maintainers can rerun the workflow or trigger it manually"
|
||||
echo "after the service recovers."
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Write Task Info
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
if: steps.check-secrets.outputs.skip != 'true' && steps.create_task.outcome == 'success'
|
||||
env:
|
||||
TASK_CREATED: ${{ steps.create_task.outputs.task-created }}
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
@@ -273,7 +287,7 @@ jobs:
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Wait for Task Completion
|
||||
if: steps.check-secrets.outputs.skip != 'true'
|
||||
if: steps.check-secrets.outputs.skip != 'true' && steps.create_task.outcome == 'success'
|
||||
id: wait_task
|
||||
env:
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
@@ -363,7 +377,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Fetch Task Logs
|
||||
if: always() && steps.check-secrets.outputs.skip != 'true'
|
||||
if: always() && steps.check-secrets.outputs.skip != 'true' && steps.create_task.outcome == 'success'
|
||||
env:
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
run: |
|
||||
@@ -376,7 +390,7 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Cleanup Task
|
||||
if: always() && steps.check-secrets.outputs.skip != 'true'
|
||||
if: always() && steps.check-secrets.outputs.skip != 'true' && steps.create_task.outcome == 'success'
|
||||
env:
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
run: |
|
||||
@@ -390,6 +404,7 @@ jobs:
|
||||
- name: Write Final Summary
|
||||
if: always() && steps.check-secrets.outputs.skip != 'true'
|
||||
env:
|
||||
CREATE_TASK_OUTCOME: ${{ steps.create_task.outcome }}
|
||||
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
|
||||
TASK_MESSAGE: ${{ steps.wait_task.outputs.task_message }}
|
||||
RESULT_URI: ${{ steps.wait_task.outputs.result_uri }}
|
||||
@@ -400,10 +415,15 @@ jobs:
|
||||
echo "---"
|
||||
echo "### Result"
|
||||
echo ""
|
||||
echo "**Status:** ${TASK_MESSAGE:-Task completed}"
|
||||
if [[ -n "${RESULT_URI}" ]]; then
|
||||
echo "**Comment:** ${RESULT_URI}"
|
||||
if [[ "${CREATE_TASK_OUTCOME}" == "success" ]]; then
|
||||
echo "**Status:** ${TASK_MESSAGE:-Task completed}"
|
||||
if [[ -n "${RESULT_URI}" ]]; then
|
||||
echo "**Comment:** ${RESULT_URI}"
|
||||
fi
|
||||
echo ""
|
||||
echo "Task \`${TASK_NAME}\` has been cleaned up."
|
||||
else
|
||||
echo "**Status:** Skipped because the external Coder task"
|
||||
echo "service was unavailable."
|
||||
fi
|
||||
echo ""
|
||||
echo "Task \`${TASK_NAME}\` has been cleaned up."
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
+7
-2
@@ -857,13 +857,18 @@ aibridgeproxy:
|
||||
# Comma-separated list of AI provider domains for which HTTPS traffic will be
|
||||
# decrypted and routed through AI Bridge. Requests to other domains will be
|
||||
# tunneled directly without decryption. Supported domains: api.anthropic.com,
|
||||
# api.openai.com, api.individual.githubcopilot.com.
|
||||
# (default: api.anthropic.com,api.openai.com,api.individual.githubcopilot.com,
|
||||
# api.openai.com, api.individual.githubcopilot.com,
|
||||
# api.business.githubcopilot.com, api.enterprise.githubcopilot.com, chatgpt.com.
|
||||
# (default:
|
||||
# api.anthropic.com,api.openai.com,api.individual.githubcopilot.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,chatgpt.com,
|
||||
# type: string-array)
|
||||
domain_allowlist:
|
||||
- api.anthropic.com
|
||||
- api.openai.com
|
||||
- api.individual.githubcopilot.com
|
||||
- api.business.githubcopilot.com
|
||||
- api.enterprise.githubcopilot.com
|
||||
- chatgpt.com
|
||||
# URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests
|
||||
# through. Format: http://[user:pass@]host:port or https://[user:pass@]host:port.
|
||||
# (default: <unset>, type: string)
|
||||
|
||||
@@ -20,6 +20,21 @@ const HeaderCoderToken = "X-Coder-AI-Governance-Token" //nolint:gosec // This is
|
||||
// request forwarded to aibridged for cross-service log correlation.
|
||||
const HeaderCoderRequestID = "X-Coder-AI-Governance-Request-Id"
|
||||
|
||||
// Copilot provider.
|
||||
const (
|
||||
ProviderCopilotBusiness = "copilot-business"
|
||||
HostCopilotBusiness = "api.business.githubcopilot.com"
|
||||
ProviderCopilotEnterprise = "copilot-enterprise"
|
||||
HostCopilotEnterprise = "api.enterprise.githubcopilot.com"
|
||||
)
|
||||
|
||||
// ChatGPT provider.
|
||||
const (
|
||||
ProviderChatGPT = "chatgpt"
|
||||
HostChatGPT = "chatgpt.com"
|
||||
BaseURLChatGPT = "https://" + HostChatGPT + "/backend-api/codex"
|
||||
)
|
||||
|
||||
// IsBYOK reports whether the request is using BYOK mode, determined
|
||||
// by the presence of the X-Coder-AI-Governance-Token header.
|
||||
func IsBYOK(header http.Header) bool {
|
||||
|
||||
+1
-1
@@ -220,7 +220,7 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) {
|
||||
Type: string(v.Object.ResourceType),
|
||||
AnyOrgOwner: v.Object.AnyOrgOwner,
|
||||
}
|
||||
if obj.Owner == "me" {
|
||||
if obj.Owner == codersdk.Me {
|
||||
obj.Owner = auth.ID
|
||||
}
|
||||
|
||||
|
||||
@@ -2811,7 +2811,15 @@ func (q *querier) GetDERPMeshKey(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
func (q *querier) GetDefaultChatModelConfig(ctx context.Context) (database.ChatModelConfig, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
// Any user who can read chat resources can read the default
|
||||
// model config, since model resolution is required to create
|
||||
// a chat. This avoids gating on ResourceDeploymentConfig
|
||||
// which regular members lack.
|
||||
act, ok := ActorFromContext(ctx)
|
||||
if !ok {
|
||||
return database.ChatModelConfig{}, ErrNoActor
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(act.ID)); err != nil {
|
||||
return database.ChatModelConfig{}, err
|
||||
}
|
||||
return q.db.GetDefaultChatModelConfig(ctx)
|
||||
|
||||
@@ -631,7 +631,7 @@ func (s *MethodTestSuite) TestChats() {
|
||||
s.Run("GetDefaultChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
config := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
|
||||
dbm.EXPECT().GetDefaultChatModelConfig(gomock.Any()).Return(config, nil).AnyTimes()
|
||||
check.Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config)
|
||||
check.Asserts(rbac.ResourceChat.WithOwner(testActorID.String()), policy.ActionRead).Returns(config)
|
||||
}))
|
||||
s.Run("GetChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Remove 'agents-access' from all users who have it.
|
||||
UPDATE users
|
||||
SET rbac_roles = array_remove(rbac_roles, 'agents-access')
|
||||
WHERE 'agents-access' = ANY(rbac_roles);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Grant 'agents-access' to every user who has ever created a chat.
|
||||
UPDATE users
|
||||
SET rbac_roles = array_append(rbac_roles, 'agents-access')
|
||||
WHERE id IN (SELECT DISTINCT owner_id FROM chats)
|
||||
AND NOT ('agents-access' = ANY(rbac_roles));
|
||||
@@ -877,3 +877,149 @@ func TestMigration000387MigrateTaskWorkspaces(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, antCount, "antagonist workspaces (deleted and regular) should not be migrated")
|
||||
}
|
||||
|
||||
func TestMigration000457ChatAccessRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const migrationVersion = 457
|
||||
|
||||
sqlDB := testSQLDB(t)
|
||||
|
||||
// Migrate up to the migration before the one that grants
|
||||
// agents-access roles.
|
||||
next, err := migrations.Stepper(sqlDB)
|
||||
require.NoError(t, err)
|
||||
for {
|
||||
version, more, err := next()
|
||||
require.NoError(t, err)
|
||||
if !more {
|
||||
t.Fatalf("migration %d not found", migrationVersion)
|
||||
}
|
||||
if version == migrationVersion-1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitSuperLong)
|
||||
|
||||
// Define test users.
|
||||
userWithChat := uuid.New() // Has a chat, no agents-access role.
|
||||
userAlreadyHasRole := uuid.New() // Has a chat and already has agents-access.
|
||||
userNoChat := uuid.New() // No chat at all.
|
||||
userWithChatAndRoles := uuid.New() // Has a chat and other existing roles.
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
// We need a chat_provider and chat_model_config for the chats FK.
|
||||
providerID := uuid.New()
|
||||
modelConfigID := uuid.New()
|
||||
|
||||
tx, err := sqlDB.BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
defer tx.Rollback()
|
||||
|
||||
fixtures := []struct {
|
||||
query string
|
||||
args []any
|
||||
}{
|
||||
// Insert test users with varying rbac_roles.
|
||||
{
|
||||
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[]any{userWithChat, "user-with-chat", "chat@test.com", []byte{}, now, now, "active", pq.StringArray{}, "password"},
|
||||
},
|
||||
{
|
||||
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[]any{userAlreadyHasRole, "user-already-has-role", "already@test.com", []byte{}, now, now, "active", pq.StringArray{"agents-access"}, "password"},
|
||||
},
|
||||
{
|
||||
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[]any{userNoChat, "user-no-chat", "nochat@test.com", []byte{}, now, now, "active", pq.StringArray{}, "password"},
|
||||
},
|
||||
{
|
||||
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[]any{userWithChatAndRoles, "user-with-roles", "roles@test.com", []byte{}, now, now, "active", pq.StringArray{"template-admin"}, "password"},
|
||||
},
|
||||
// Insert a chat provider and model config for the chats FK.
|
||||
{
|
||||
`INSERT INTO chat_providers (id, provider, display_name, api_key, enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[]any{providerID, "openai", "OpenAI", "", true, now, now},
|
||||
},
|
||||
{
|
||||
`INSERT INTO chat_model_configs (id, provider, model, display_name, enabled, context_limit, compression_threshold, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[]any{modelConfigID, "openai", "gpt-4", "GPT 4", true, 100000, 70, now, now},
|
||||
},
|
||||
// Insert chats for users A, B, and D (not C).
|
||||
{
|
||||
`INSERT INTO chats (id, owner_id, last_model_config_id, title, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[]any{uuid.New(), userWithChat, modelConfigID, "Chat A", now, now},
|
||||
},
|
||||
{
|
||||
`INSERT INTO chats (id, owner_id, last_model_config_id, title, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[]any{uuid.New(), userAlreadyHasRole, modelConfigID, "Chat B", now, now},
|
||||
},
|
||||
{
|
||||
`INSERT INTO chats (id, owner_id, last_model_config_id, title, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[]any{uuid.New(), userWithChatAndRoles, modelConfigID, "Chat D", now, now},
|
||||
},
|
||||
}
|
||||
|
||||
for i, f := range fixtures {
|
||||
_, err := tx.ExecContext(ctx, f.query, f.args...)
|
||||
require.NoError(t, err, "fixture %d", i)
|
||||
}
|
||||
require.NoError(t, tx.Commit())
|
||||
|
||||
// Run the migration.
|
||||
version, _, err := next()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, migrationVersion, version)
|
||||
|
||||
// Helper to get rbac_roles for a user.
|
||||
getRoles := func(t *testing.T, userID uuid.UUID) []string {
|
||||
t.Helper()
|
||||
var roles pq.StringArray
|
||||
err := sqlDB.QueryRowContext(ctx,
|
||||
"SELECT rbac_roles FROM users WHERE id = $1", userID,
|
||||
).Scan(&roles)
|
||||
require.NoError(t, err)
|
||||
return roles
|
||||
}
|
||||
|
||||
// Verify: user with chat gets agents-access.
|
||||
roles := getRoles(t, userWithChat)
|
||||
require.Contains(t, roles, "agents-access",
|
||||
"user with chat should get agents-access")
|
||||
|
||||
// Verify: user who already had agents-access has no duplicate.
|
||||
roles = getRoles(t, userAlreadyHasRole)
|
||||
count := 0
|
||||
for _, r := range roles {
|
||||
if r == "agents-access" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, count,
|
||||
"user who already had agents-access should not get a duplicate")
|
||||
|
||||
// Verify: user without chat does NOT get agents-access.
|
||||
roles = getRoles(t, userNoChat)
|
||||
require.NotContains(t, roles, "agents-access",
|
||||
"user without chat should not get agents-access")
|
||||
|
||||
// Verify: user with chat and existing roles gets agents-access
|
||||
// appended while preserving existing roles.
|
||||
roles = getRoles(t, userWithChatAndRoles)
|
||||
require.Contains(t, roles, "agents-access",
|
||||
"user with chat and other roles should get agents-access")
|
||||
require.Contains(t, roles, "template-admin",
|
||||
"existing roles should be preserved")
|
||||
}
|
||||
|
||||
@@ -996,8 +996,6 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg Lis
|
||||
query := fmt.Sprintf("-- name: ListAuthorizedAIBridgeSessions :many\n%s", filtered)
|
||||
rows, err := q.db.QueryContext(ctx, query,
|
||||
arg.AfterSessionID,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
arg.StartedAfter,
|
||||
arg.StartedBefore,
|
||||
arg.InitiatorID,
|
||||
@@ -1005,6 +1003,8 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg Lis
|
||||
arg.Model,
|
||||
arg.Client,
|
||||
arg.SessionID,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -788,6 +788,10 @@ type sqlcQuerier interface {
|
||||
// Returns paginated sessions with aggregated metadata, token counts, and
|
||||
// the most recent user prompt. A "session" is a logical grouping of
|
||||
// interceptions that share the same session_id (set by the client).
|
||||
//
|
||||
// Pagination-first strategy: identify the page of sessions cheaply via a
|
||||
// single GROUP BY scan, then do expensive lateral joins (tokens, prompts,
|
||||
// first-interception metadata) only for the ~page-size result set.
|
||||
ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams) ([]ListAIBridgeSessionsRow, error)
|
||||
ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeTokenUsage, error)
|
||||
ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeToolUsage, error)
|
||||
|
||||
@@ -1251,8 +1251,12 @@ func TestGetAuthorizedChats(t *testing.T) {
|
||||
owner := dbgen.User(t, db, database.User{
|
||||
RBACRoles: []string{rbac.RoleOwner().String()},
|
||||
})
|
||||
member := dbgen.User(t, db, database.User{})
|
||||
secondMember := dbgen.User(t, db, database.User{})
|
||||
member := dbgen.User(t, db, database.User{
|
||||
RBACRoles: pq.StringArray{rbac.RoleAgentsAccess().String()},
|
||||
})
|
||||
secondMember := dbgen.User(t, db, database.User{
|
||||
RBACRoles: pq.StringArray{rbac.RoleAgentsAccess().String()},
|
||||
})
|
||||
|
||||
// Create FK dependencies: a chat provider and model config.
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
@@ -1407,7 +1411,9 @@ func TestGetAuthorizedChats(t *testing.T) {
|
||||
|
||||
// Use a dedicated user for pagination to avoid interference
|
||||
// with the other parallel subtests.
|
||||
paginationUser := dbgen.User(t, db, database.User{})
|
||||
paginationUser := dbgen.User(t, db, database.User{
|
||||
RBACRoles: pq.StringArray{rbac.RoleAgentsAccess().String()},
|
||||
})
|
||||
for i := range 7 {
|
||||
_, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
OwnerID: paginationUser.ID,
|
||||
|
||||
@@ -1347,95 +1347,87 @@ func (q *sqlQuerier) ListAIBridgeSessionThreads(ctx context.Context, arg ListAIB
|
||||
}
|
||||
|
||||
const listAIBridgeSessions = `-- name: ListAIBridgeSessions :many
|
||||
WITH filtered_interceptions AS (
|
||||
WITH cursor_pos AS (
|
||||
-- Resolve the cursor's started_at once, outside the HAVING clause,
|
||||
-- so the planner cannot accidentally re-evaluate it per group.
|
||||
SELECT MIN(aibridge_interceptions.started_at) AS started_at
|
||||
FROM aibridge_interceptions
|
||||
WHERE aibridge_interceptions.session_id = $1 AND aibridge_interceptions.ended_at IS NOT NULL
|
||||
),
|
||||
session_page AS (
|
||||
-- Paginate at the session level first; only cheap aggregates here.
|
||||
SELECT
|
||||
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id, aibridge_interceptions.client, aibridge_interceptions.thread_parent_id, aibridge_interceptions.thread_root_id, aibridge_interceptions.client_session_id, aibridge_interceptions.session_id
|
||||
ai.session_id,
|
||||
ai.initiator_id,
|
||||
MIN(ai.started_at) AS started_at,
|
||||
MAX(ai.ended_at) AS ended_at,
|
||||
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
aibridge_interceptions ai
|
||||
WHERE
|
||||
-- Remove inflight interceptions (ones which lack an ended_at value).
|
||||
aibridge_interceptions.ended_at IS NOT NULL
|
||||
ai.ended_at IS NOT NULL
|
||||
-- Filter by time frame
|
||||
AND CASE
|
||||
WHEN $4::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at >= $4::timestamptz
|
||||
WHEN $2::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN ai.started_at >= $2::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $5::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at <= $5::timestamptz
|
||||
WHEN $3::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN ai.started_at <= $3::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
-- Filter initiator_id
|
||||
AND CASE
|
||||
WHEN $6::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN aibridge_interceptions.initiator_id = $6::uuid
|
||||
WHEN $4::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ai.initiator_id = $4::uuid
|
||||
ELSE true
|
||||
END
|
||||
-- Filter provider
|
||||
AND CASE
|
||||
WHEN $7::text != '' THEN aibridge_interceptions.provider = $7::text
|
||||
WHEN $5::text != '' THEN ai.provider = $5::text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter model
|
||||
AND CASE
|
||||
WHEN $8::text != '' THEN aibridge_interceptions.model = $8::text
|
||||
WHEN $6::text != '' THEN ai.model = $6::text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter client
|
||||
AND CASE
|
||||
WHEN $9::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = $9::text
|
||||
WHEN $7::text != '' THEN COALESCE(ai.client, 'Unknown') = $7::text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter session_id
|
||||
AND CASE
|
||||
WHEN $10::text != '' THEN aibridge_interceptions.session_id = $10::text
|
||||
WHEN $8::text != '' THEN ai.session_id = $8::text
|
||||
ELSE true
|
||||
END
|
||||
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeSessions
|
||||
-- @authorize_filter
|
||||
),
|
||||
session_tokens AS (
|
||||
-- Aggregate token usage across all interceptions in each session.
|
||||
-- Group by (session_id, initiator_id) to avoid merging sessions from
|
||||
-- different users who happen to share the same client_session_id.
|
||||
SELECT
|
||||
fi.session_id,
|
||||
fi.initiator_id,
|
||||
COALESCE(SUM(tu.input_tokens), 0)::bigint AS input_tokens,
|
||||
COALESCE(SUM(tu.output_tokens), 0)::bigint AS output_tokens
|
||||
-- TODO: add extra token types once https://github.com/coder/aibridge/issues/150 lands.
|
||||
FROM
|
||||
filtered_interceptions fi
|
||||
LEFT JOIN
|
||||
aibridge_token_usages tu ON fi.id = tu.interception_id
|
||||
GROUP BY
|
||||
fi.session_id, fi.initiator_id
|
||||
),
|
||||
session_root AS (
|
||||
-- Build one summary row per session. Group by (session_id, initiator_id)
|
||||
-- to avoid merging sessions from different users who happen to share the
|
||||
-- same client_session_id. The ARRAY_AGG with ORDER BY picks values from
|
||||
-- the chronologically first interception for fields that should represent
|
||||
-- the session as a whole (client, metadata). Threads are counted as
|
||||
-- distinct root interception IDs: an interception with a NULL
|
||||
-- thread_root_id is itself a thread root.
|
||||
SELECT
|
||||
fi.session_id,
|
||||
fi.initiator_id,
|
||||
(ARRAY_AGG(fi.client ORDER BY fi.started_at, fi.id))[1] AS client,
|
||||
(ARRAY_AGG(fi.metadata ORDER BY fi.started_at, fi.id))[1] AS metadata,
|
||||
ARRAY_AGG(DISTINCT fi.provider ORDER BY fi.provider) AS providers,
|
||||
ARRAY_AGG(DISTINCT fi.model ORDER BY fi.model) AS models,
|
||||
MIN(fi.started_at) AS started_at,
|
||||
MAX(fi.ended_at) AS ended_at,
|
||||
COUNT(DISTINCT COALESCE(fi.thread_root_id, fi.id)) AS threads,
|
||||
-- Collect IDs for lateral prompt lookup.
|
||||
ARRAY_AGG(fi.id) AS interception_ids
|
||||
FROM
|
||||
filtered_interceptions fi
|
||||
GROUP BY
|
||||
fi.session_id, fi.initiator_id
|
||||
ai.session_id, ai.initiator_id
|
||||
HAVING
|
||||
-- Cursor pagination: uses a composite (started_at, session_id)
|
||||
-- cursor to support keyset pagination. The less-than comparison
|
||||
-- matches the DESC sort order so rows after the cursor come
|
||||
-- later in results. The cursor value comes from cursor_pos to
|
||||
-- guarantee single evaluation.
|
||||
CASE
|
||||
WHEN $1::text != '' THEN (
|
||||
(MIN(ai.started_at), ai.session_id) < (
|
||||
(SELECT started_at FROM cursor_pos),
|
||||
$1::text
|
||||
)
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
ORDER BY
|
||||
MIN(ai.started_at) DESC,
|
||||
ai.session_id DESC
|
||||
LIMIT COALESCE(NULLIF($10::integer, 0), 100)
|
||||
OFFSET $9
|
||||
)
|
||||
SELECT
|
||||
sr.session_id,
|
||||
sp.session_id,
|
||||
visible_users.id AS user_id,
|
||||
visible_users.username AS user_username,
|
||||
visible_users.name AS user_name,
|
||||
@@ -1444,51 +1436,52 @@ SELECT
|
||||
sr.models::text[] AS models,
|
||||
COALESCE(sr.client, '')::varchar(64) AS client,
|
||||
sr.metadata::jsonb AS metadata,
|
||||
sr.started_at::timestamptz AS started_at,
|
||||
sr.ended_at::timestamptz AS ended_at,
|
||||
sr.threads,
|
||||
sp.started_at::timestamptz AS started_at,
|
||||
sp.ended_at::timestamptz AS ended_at,
|
||||
sp.threads,
|
||||
COALESCE(st.input_tokens, 0)::bigint AS input_tokens,
|
||||
COALESCE(st.output_tokens, 0)::bigint AS output_tokens,
|
||||
COALESCE(slp.prompt, '') AS last_prompt
|
||||
FROM
|
||||
session_root sr
|
||||
session_page sp
|
||||
JOIN
|
||||
visible_users ON visible_users.id = sr.initiator_id
|
||||
LEFT JOIN
|
||||
session_tokens st ON st.session_id = sr.session_id AND st.initiator_id = sr.initiator_id
|
||||
visible_users ON visible_users.id = sp.initiator_id
|
||||
LEFT JOIN LATERAL (
|
||||
-- Lateral join to efficiently fetch only the most recent user prompt
|
||||
-- across all interceptions in the session, avoiding a full aggregation.
|
||||
SELECT
|
||||
(ARRAY_AGG(ai.client ORDER BY ai.started_at, ai.id))[1] AS client,
|
||||
(ARRAY_AGG(ai.metadata ORDER BY ai.started_at, ai.id))[1] AS metadata,
|
||||
ARRAY_AGG(DISTINCT ai.provider ORDER BY ai.provider) AS providers,
|
||||
ARRAY_AGG(DISTINCT ai.model ORDER BY ai.model) AS models,
|
||||
ARRAY_AGG(ai.id) AS interception_ids
|
||||
FROM aibridge_interceptions ai
|
||||
WHERE ai.session_id = sp.session_id
|
||||
AND ai.initiator_id = sp.initiator_id
|
||||
AND ai.ended_at IS NOT NULL
|
||||
) sr ON true
|
||||
LEFT JOIN LATERAL (
|
||||
-- Aggregate tokens only for this session's interceptions.
|
||||
SELECT
|
||||
COALESCE(SUM(tu.input_tokens), 0)::bigint AS input_tokens,
|
||||
COALESCE(SUM(tu.output_tokens), 0)::bigint AS output_tokens
|
||||
FROM aibridge_token_usages tu
|
||||
WHERE tu.interception_id = ANY(sr.interception_ids)
|
||||
) st ON true
|
||||
LEFT JOIN LATERAL (
|
||||
-- Fetch only the most recent user prompt across all interceptions
|
||||
-- in the session.
|
||||
SELECT up.prompt
|
||||
FROM aibridge_user_prompts up
|
||||
WHERE up.interception_id = ANY(sr.interception_ids)
|
||||
ORDER BY up.created_at DESC, up.id DESC
|
||||
LIMIT 1
|
||||
) slp ON true
|
||||
WHERE
|
||||
-- Cursor pagination: uses a composite (started_at, session_id) cursor
|
||||
-- to support keyset pagination. The less-than comparison matches the
|
||||
-- DESC sort order so that rows after the cursor come later in results.
|
||||
CASE
|
||||
WHEN $1::text != '' THEN (
|
||||
(sr.started_at, sr.session_id) < (
|
||||
(SELECT started_at FROM session_root WHERE session_id = $1),
|
||||
$1::text
|
||||
)
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
ORDER BY
|
||||
sr.started_at DESC,
|
||||
sr.session_id DESC
|
||||
LIMIT COALESCE(NULLIF($3::integer, 0), 100)
|
||||
OFFSET $2
|
||||
sp.started_at DESC,
|
||||
sp.session_id DESC
|
||||
`
|
||||
|
||||
type ListAIBridgeSessionsParams struct {
|
||||
AfterSessionID string `db:"after_session_id" json:"after_session_id"`
|
||||
Offset int32 `db:"offset_" json:"offset_"`
|
||||
Limit int32 `db:"limit_" json:"limit_"`
|
||||
StartedAfter time.Time `db:"started_after" json:"started_after"`
|
||||
StartedBefore time.Time `db:"started_before" json:"started_before"`
|
||||
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
|
||||
@@ -1496,6 +1489,8 @@ type ListAIBridgeSessionsParams struct {
|
||||
Model string `db:"model" json:"model"`
|
||||
Client string `db:"client" json:"client"`
|
||||
SessionID string `db:"session_id" json:"session_id"`
|
||||
Offset int32 `db:"offset_" json:"offset_"`
|
||||
Limit int32 `db:"limit_" json:"limit_"`
|
||||
}
|
||||
|
||||
type ListAIBridgeSessionsRow struct {
|
||||
@@ -1519,11 +1514,13 @@ type ListAIBridgeSessionsRow struct {
|
||||
// Returns paginated sessions with aggregated metadata, token counts, and
|
||||
// the most recent user prompt. A "session" is a logical grouping of
|
||||
// interceptions that share the same session_id (set by the client).
|
||||
//
|
||||
// Pagination-first strategy: identify the page of sessions cheaply via a
|
||||
// single GROUP BY scan, then do expensive lateral joins (tokens, prompts,
|
||||
// first-interception metadata) only for the ~page-size result set.
|
||||
func (q *sqlQuerier) ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams) ([]ListAIBridgeSessionsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listAIBridgeSessions,
|
||||
arg.AfterSessionID,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
arg.StartedAfter,
|
||||
arg.StartedBefore,
|
||||
arg.InitiatorID,
|
||||
@@ -1531,6 +1528,8 @@ func (q *sqlQuerier) ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeS
|
||||
arg.Model,
|
||||
arg.Client,
|
||||
arg.SessionID,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -454,95 +454,91 @@ WHERE
|
||||
-- Returns paginated sessions with aggregated metadata, token counts, and
|
||||
-- the most recent user prompt. A "session" is a logical grouping of
|
||||
-- interceptions that share the same session_id (set by the client).
|
||||
WITH filtered_interceptions AS (
|
||||
--
|
||||
-- Pagination-first strategy: identify the page of sessions cheaply via a
|
||||
-- single GROUP BY scan, then do expensive lateral joins (tokens, prompts,
|
||||
-- first-interception metadata) only for the ~page-size result set.
|
||||
WITH cursor_pos AS (
|
||||
-- Resolve the cursor's started_at once, outside the HAVING clause,
|
||||
-- so the planner cannot accidentally re-evaluate it per group.
|
||||
SELECT MIN(aibridge_interceptions.started_at) AS started_at
|
||||
FROM aibridge_interceptions
|
||||
WHERE aibridge_interceptions.session_id = @after_session_id AND aibridge_interceptions.ended_at IS NOT NULL
|
||||
),
|
||||
session_page AS (
|
||||
-- Paginate at the session level first; only cheap aggregates here.
|
||||
SELECT
|
||||
aibridge_interceptions.*
|
||||
ai.session_id,
|
||||
ai.initiator_id,
|
||||
MIN(ai.started_at) AS started_at,
|
||||
MAX(ai.ended_at) AS ended_at,
|
||||
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads
|
||||
FROM
|
||||
aibridge_interceptions
|
||||
aibridge_interceptions ai
|
||||
WHERE
|
||||
-- Remove inflight interceptions (ones which lack an ended_at value).
|
||||
aibridge_interceptions.ended_at IS NOT NULL
|
||||
ai.ended_at IS NOT NULL
|
||||
-- Filter by time frame
|
||||
AND CASE
|
||||
WHEN @started_after::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at >= @started_after::timestamptz
|
||||
WHEN @started_after::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN ai.started_at >= @started_after::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @started_before::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at <= @started_before::timestamptz
|
||||
WHEN @started_before::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN ai.started_at <= @started_before::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
-- Filter initiator_id
|
||||
AND CASE
|
||||
WHEN @initiator_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN aibridge_interceptions.initiator_id = @initiator_id::uuid
|
||||
WHEN @initiator_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ai.initiator_id = @initiator_id::uuid
|
||||
ELSE true
|
||||
END
|
||||
-- Filter provider
|
||||
AND CASE
|
||||
WHEN @provider::text != '' THEN aibridge_interceptions.provider = @provider::text
|
||||
WHEN @provider::text != '' THEN ai.provider = @provider::text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter model
|
||||
AND CASE
|
||||
WHEN @model::text != '' THEN aibridge_interceptions.model = @model::text
|
||||
WHEN @model::text != '' THEN ai.model = @model::text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter client
|
||||
AND CASE
|
||||
WHEN @client::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = @client::text
|
||||
WHEN @client::text != '' THEN COALESCE(ai.client, 'Unknown') = @client::text
|
||||
ELSE true
|
||||
END
|
||||
-- Filter session_id
|
||||
AND CASE
|
||||
WHEN @session_id::text != '' THEN aibridge_interceptions.session_id = @session_id::text
|
||||
WHEN @session_id::text != '' THEN ai.session_id = @session_id::text
|
||||
ELSE true
|
||||
END
|
||||
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeSessions
|
||||
-- @authorize_filter
|
||||
),
|
||||
session_tokens AS (
|
||||
-- Aggregate token usage across all interceptions in each session.
|
||||
-- Group by (session_id, initiator_id) to avoid merging sessions from
|
||||
-- different users who happen to share the same client_session_id.
|
||||
SELECT
|
||||
fi.session_id,
|
||||
fi.initiator_id,
|
||||
COALESCE(SUM(tu.input_tokens), 0)::bigint AS input_tokens,
|
||||
COALESCE(SUM(tu.output_tokens), 0)::bigint AS output_tokens
|
||||
-- TODO: add extra token types once https://github.com/coder/aibridge/issues/150 lands.
|
||||
FROM
|
||||
filtered_interceptions fi
|
||||
LEFT JOIN
|
||||
aibridge_token_usages tu ON fi.id = tu.interception_id
|
||||
GROUP BY
|
||||
fi.session_id, fi.initiator_id
|
||||
),
|
||||
session_root AS (
|
||||
-- Build one summary row per session. Group by (session_id, initiator_id)
|
||||
-- to avoid merging sessions from different users who happen to share the
|
||||
-- same client_session_id. The ARRAY_AGG with ORDER BY picks values from
|
||||
-- the chronologically first interception for fields that should represent
|
||||
-- the session as a whole (client, metadata). Threads are counted as
|
||||
-- distinct root interception IDs: an interception with a NULL
|
||||
-- thread_root_id is itself a thread root.
|
||||
SELECT
|
||||
fi.session_id,
|
||||
fi.initiator_id,
|
||||
(ARRAY_AGG(fi.client ORDER BY fi.started_at, fi.id))[1] AS client,
|
||||
(ARRAY_AGG(fi.metadata ORDER BY fi.started_at, fi.id))[1] AS metadata,
|
||||
ARRAY_AGG(DISTINCT fi.provider ORDER BY fi.provider) AS providers,
|
||||
ARRAY_AGG(DISTINCT fi.model ORDER BY fi.model) AS models,
|
||||
MIN(fi.started_at) AS started_at,
|
||||
MAX(fi.ended_at) AS ended_at,
|
||||
COUNT(DISTINCT COALESCE(fi.thread_root_id, fi.id)) AS threads,
|
||||
-- Collect IDs for lateral prompt lookup.
|
||||
ARRAY_AGG(fi.id) AS interception_ids
|
||||
FROM
|
||||
filtered_interceptions fi
|
||||
GROUP BY
|
||||
fi.session_id, fi.initiator_id
|
||||
ai.session_id, ai.initiator_id
|
||||
HAVING
|
||||
-- Cursor pagination: uses a composite (started_at, session_id)
|
||||
-- cursor to support keyset pagination. The less-than comparison
|
||||
-- matches the DESC sort order so rows after the cursor come
|
||||
-- later in results. The cursor value comes from cursor_pos to
|
||||
-- guarantee single evaluation.
|
||||
CASE
|
||||
WHEN @after_session_id::text != '' THEN (
|
||||
(MIN(ai.started_at), ai.session_id) < (
|
||||
(SELECT started_at FROM cursor_pos),
|
||||
@after_session_id::text
|
||||
)
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
ORDER BY
|
||||
MIN(ai.started_at) DESC,
|
||||
ai.session_id DESC
|
||||
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100)
|
||||
OFFSET @offset_
|
||||
)
|
||||
SELECT
|
||||
sr.session_id,
|
||||
sp.session_id,
|
||||
visible_users.id AS user_id,
|
||||
visible_users.username AS user_username,
|
||||
visible_users.name AS user_name,
|
||||
@@ -551,45 +547,48 @@ SELECT
|
||||
sr.models::text[] AS models,
|
||||
COALESCE(sr.client, '')::varchar(64) AS client,
|
||||
sr.metadata::jsonb AS metadata,
|
||||
sr.started_at::timestamptz AS started_at,
|
||||
sr.ended_at::timestamptz AS ended_at,
|
||||
sr.threads,
|
||||
sp.started_at::timestamptz AS started_at,
|
||||
sp.ended_at::timestamptz AS ended_at,
|
||||
sp.threads,
|
||||
COALESCE(st.input_tokens, 0)::bigint AS input_tokens,
|
||||
COALESCE(st.output_tokens, 0)::bigint AS output_tokens,
|
||||
COALESCE(slp.prompt, '') AS last_prompt
|
||||
FROM
|
||||
session_root sr
|
||||
session_page sp
|
||||
JOIN
|
||||
visible_users ON visible_users.id = sr.initiator_id
|
||||
LEFT JOIN
|
||||
session_tokens st ON st.session_id = sr.session_id AND st.initiator_id = sr.initiator_id
|
||||
visible_users ON visible_users.id = sp.initiator_id
|
||||
LEFT JOIN LATERAL (
|
||||
-- Lateral join to efficiently fetch only the most recent user prompt
|
||||
-- across all interceptions in the session, avoiding a full aggregation.
|
||||
SELECT
|
||||
(ARRAY_AGG(ai.client ORDER BY ai.started_at, ai.id))[1] AS client,
|
||||
(ARRAY_AGG(ai.metadata ORDER BY ai.started_at, ai.id))[1] AS metadata,
|
||||
ARRAY_AGG(DISTINCT ai.provider ORDER BY ai.provider) AS providers,
|
||||
ARRAY_AGG(DISTINCT ai.model ORDER BY ai.model) AS models,
|
||||
ARRAY_AGG(ai.id) AS interception_ids
|
||||
FROM aibridge_interceptions ai
|
||||
WHERE ai.session_id = sp.session_id
|
||||
AND ai.initiator_id = sp.initiator_id
|
||||
AND ai.ended_at IS NOT NULL
|
||||
) sr ON true
|
||||
LEFT JOIN LATERAL (
|
||||
-- Aggregate tokens only for this session's interceptions.
|
||||
SELECT
|
||||
COALESCE(SUM(tu.input_tokens), 0)::bigint AS input_tokens,
|
||||
COALESCE(SUM(tu.output_tokens), 0)::bigint AS output_tokens
|
||||
FROM aibridge_token_usages tu
|
||||
WHERE tu.interception_id = ANY(sr.interception_ids)
|
||||
) st ON true
|
||||
LEFT JOIN LATERAL (
|
||||
-- Fetch only the most recent user prompt across all interceptions
|
||||
-- in the session.
|
||||
SELECT up.prompt
|
||||
FROM aibridge_user_prompts up
|
||||
WHERE up.interception_id = ANY(sr.interception_ids)
|
||||
ORDER BY up.created_at DESC, up.id DESC
|
||||
LIMIT 1
|
||||
) slp ON true
|
||||
WHERE
|
||||
-- Cursor pagination: uses a composite (started_at, session_id) cursor
|
||||
-- to support keyset pagination. The less-than comparison matches the
|
||||
-- DESC sort order so that rows after the cursor come later in results.
|
||||
CASE
|
||||
WHEN @after_session_id::text != '' THEN (
|
||||
(sr.started_at, sr.session_id) < (
|
||||
(SELECT started_at FROM session_root WHERE session_id = @after_session_id),
|
||||
@after_session_id::text
|
||||
)
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
ORDER BY
|
||||
sr.started_at DESC,
|
||||
sr.session_id DESC
|
||||
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100)
|
||||
OFFSET @offset_
|
||||
sp.started_at DESC,
|
||||
sp.session_id DESC
|
||||
;
|
||||
|
||||
-- name: ListAIBridgeSessionThreads :many
|
||||
|
||||
+24
-3
@@ -393,6 +393,11 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
|
||||
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String())) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.CreateChatRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
@@ -498,6 +503,10 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if dbauthz.IsNotAuthorizedError(err) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to create chat.",
|
||||
Detail: err.Error(),
|
||||
@@ -616,6 +625,10 @@ func (api *API) chatCostSummary(rw http.ResponseWriter, r *http.Request) {
|
||||
EndDate: endDate,
|
||||
})
|
||||
if err != nil {
|
||||
if dbauthz.IsNotAuthorizedError(err) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
@@ -626,6 +639,10 @@ func (api *API) chatCostSummary(rw http.ResponseWriter, r *http.Request) {
|
||||
EndDate: endDate,
|
||||
})
|
||||
if err != nil {
|
||||
if dbauthz.IsNotAuthorizedError(err) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
@@ -636,6 +653,10 @@ func (api *API) chatCostSummary(rw http.ResponseWriter, r *http.Request) {
|
||||
EndDate: endDate,
|
||||
})
|
||||
if err != nil {
|
||||
if dbauthz.IsNotAuthorizedError(err) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
@@ -1620,9 +1641,9 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var err error
|
||||
// Use chatDaemon when available so it can notify active
|
||||
// subscribers. Fall back to direct DB for the simple
|
||||
// archive flag — no streaming state is involved.
|
||||
// Use chatDaemon when available so it can interrupt active
|
||||
// processing before broadcasting archive state. Fall back to
|
||||
// direct DB when no daemon is running.
|
||||
if archived {
|
||||
if api.chatDaemon != nil {
|
||||
err = api.chatDaemon.ArchiveChat(ctx, chat)
|
||||
|
||||
+67
-12
@@ -194,10 +194,15 @@ func TestPostChats(t *testing.T) {
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
// Use a member with agents-access instead of the owner to
|
||||
// verify least-privilege access.
|
||||
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
|
||||
chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
@@ -208,7 +213,7 @@ func TestPostChats(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEqual(t, uuid.Nil, chat.ID)
|
||||
require.Equal(t, user.UserID, chat.OwnerID)
|
||||
require.Equal(t, member.ID, chat.OwnerID)
|
||||
require.Equal(t, modelConfig.ID, chat.LastModelConfigID)
|
||||
require.Equal(t, "hello from chats route tests", chat.Title)
|
||||
require.Equal(t, codersdk.ChatStatusPending, chat.Status)
|
||||
@@ -218,9 +223,9 @@ func TestPostChats(t *testing.T) {
|
||||
require.NotNil(t, chat.RootChatID)
|
||||
require.Equal(t, chat.ID, *chat.RootChatID)
|
||||
|
||||
chatResult, err := client.GetChat(ctx, chat.ID)
|
||||
chatResult, err := memberClient.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil)
|
||||
messagesResult, err := memberClient.GetChatMessages(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, chat.ID, chatResult.ID)
|
||||
|
||||
@@ -240,6 +245,29 @@ func TestPostChats(t *testing.T) {
|
||||
require.True(t, foundUserMessage)
|
||||
})
|
||||
|
||||
t.Run("MemberWithoutAgentsAccess", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModelConfig(t, client)
|
||||
|
||||
// Member without agents-access should be denied.
|
||||
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
|
||||
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "this should fail",
|
||||
},
|
||||
},
|
||||
})
|
||||
requireSDKError(t, err, http.StatusForbidden)
|
||||
})
|
||||
|
||||
t.Run("HidesSystemPromptMessages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -271,7 +299,7 @@ func TestPostChats(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
adminClient, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
|
||||
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
|
||||
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
@@ -307,6 +335,7 @@ func TestPostChats(t *testing.T) {
|
||||
adminClient.Client,
|
||||
firstUser.OrganizationID,
|
||||
rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID),
|
||||
rbac.RoleAgentsAccess(),
|
||||
)
|
||||
orgAdminClient := codersdk.NewExperimentalClient(orgAdminClientRaw)
|
||||
|
||||
@@ -518,7 +547,7 @@ func TestListChats(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
memberDBChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
OwnerID: member.ID,
|
||||
@@ -586,6 +615,32 @@ func TestListChats(t *testing.T) {
|
||||
require.Equal(t, memberChats[0].ID, memberChats[0].DiffStatus.ChatID)
|
||||
})
|
||||
|
||||
t.Run("MemberWithoutAgentsAccess", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
// Create a member without agents-access and insert a chat
|
||||
// owned by them via system context. This verifies the
|
||||
// RBAC filter actually excludes results rather than
|
||||
// returning empty because no chats exist.
|
||||
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
_, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
OwnerID: member.ID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "member chat",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
chats, err := memberClient.ListChats(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, chats)
|
||||
})
|
||||
|
||||
t.Run("Unauthenticated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1958,7 +2013,7 @@ func TestGetChat(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
|
||||
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
|
||||
_, err = otherClient.GetChat(ctx, createdChat.ID)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
@@ -3530,7 +3585,7 @@ func TestRegenerateChatTitle(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
|
||||
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
|
||||
_, err = otherClient.RegenerateChatTitle(ctx, createdChat.ID)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
@@ -3855,7 +3910,7 @@ func TestGetChatDiffStatus(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
|
||||
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
|
||||
_, err = otherClient.GetChat(ctx, createdChat.ID)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
@@ -4088,7 +4143,7 @@ func TestGetChatDiffContents(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
|
||||
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
|
||||
_, err = otherClient.GetChatDiffContents(ctx, createdChat.ID)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
@@ -4884,7 +4939,7 @@ func TestGetChatFile(t *testing.T) {
|
||||
uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data))
|
||||
require.NoError(t, err)
|
||||
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleAgentsAccess())
|
||||
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
|
||||
_, _, err = otherClient.GetChatFile(ctx, uploaded.ID)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
|
||||
+38
-4
@@ -21,6 +21,7 @@ const (
|
||||
templateAdmin string = "template-admin"
|
||||
userAdmin string = "user-admin"
|
||||
auditor string = "auditor"
|
||||
agentsAccess string = "agents-access"
|
||||
// customSiteRole is a placeholder for all custom site roles.
|
||||
// This is used for what roles can assign other roles.
|
||||
// TODO: Make this more dynamic to allow other roles to grant.
|
||||
@@ -142,6 +143,7 @@ func RoleTemplateAdmin() RoleIdentifier { return RoleIdentifier{Name: templateAd
|
||||
func RoleUserAdmin() RoleIdentifier { return RoleIdentifier{Name: userAdmin} }
|
||||
func RoleMember() RoleIdentifier { return RoleIdentifier{Name: member} }
|
||||
func RoleAuditor() RoleIdentifier { return RoleIdentifier{Name: auditor} }
|
||||
func RoleAgentsAccess() RoleIdentifier { return RoleIdentifier{Name: agentsAccess} }
|
||||
|
||||
func RoleOrgAdmin() string {
|
||||
return orgAdmin
|
||||
@@ -316,7 +318,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
denyPermissions...,
|
||||
),
|
||||
User: append(
|
||||
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception),
|
||||
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception, ResourceChat),
|
||||
Permissions(map[string][]policy.Action{
|
||||
// Users cannot do create/update/delete on themselves, but they
|
||||
// can read their own details.
|
||||
@@ -402,6 +404,21 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
ByOrgID: map[string]OrgPermissions{},
|
||||
}.withCachedRegoValue()
|
||||
|
||||
agentsAccessRole := Role{
|
||||
Identifier: RoleAgentsAccess(),
|
||||
DisplayName: "Coder Agents User",
|
||||
Site: []Permission{},
|
||||
User: Permissions(map[string][]policy.Action{
|
||||
ResourceChat.Type: {
|
||||
policy.ActionCreate,
|
||||
policy.ActionRead,
|
||||
policy.ActionUpdate,
|
||||
policy.ActionDelete,
|
||||
},
|
||||
}),
|
||||
ByOrgID: map[string]OrgPermissions{},
|
||||
}.withCachedRegoValue()
|
||||
|
||||
builtInRoles = map[string]func(orgID uuid.UUID) Role{
|
||||
// admin grants all actions to all resources.
|
||||
owner: func(_ uuid.UUID) Role {
|
||||
@@ -428,6 +445,13 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
return userAdminRole
|
||||
},
|
||||
|
||||
// agentsAccess grants all actions on chat resources owned
|
||||
// by the user. Without this role, members cannot create
|
||||
// or interact with chats.
|
||||
agentsAccess: func(_ uuid.UUID) Role {
|
||||
return agentsAccessRole
|
||||
},
|
||||
|
||||
// orgAdmin returns a role with all actions allows in a given
|
||||
// organization scope.
|
||||
orgAdmin: func(organizationID uuid.UUID) Role {
|
||||
@@ -600,6 +624,7 @@ var assignRoles = map[string]map[string]bool{
|
||||
userAdmin: true,
|
||||
customSiteRole: true,
|
||||
customOrganizationRole: true,
|
||||
agentsAccess: true,
|
||||
},
|
||||
owner: {
|
||||
owner: true,
|
||||
@@ -615,10 +640,12 @@ var assignRoles = map[string]map[string]bool{
|
||||
userAdmin: true,
|
||||
customSiteRole: true,
|
||||
customOrganizationRole: true,
|
||||
agentsAccess: true,
|
||||
},
|
||||
userAdmin: {
|
||||
member: true,
|
||||
orgMember: true,
|
||||
member: true,
|
||||
orgMember: true,
|
||||
agentsAccess: true,
|
||||
},
|
||||
orgAdmin: {
|
||||
orgAdmin: true,
|
||||
@@ -854,13 +881,20 @@ func SiteBuiltInRoles() []Role {
|
||||
for _, roleF := range builtInRoles {
|
||||
// Must provide some non-nil uuid to filter out org roles.
|
||||
role := roleF(uuid.New())
|
||||
if !role.Identifier.IsOrgRole() {
|
||||
if !role.Identifier.IsOrgRole() && role.Identifier != RoleAgentsAccess() {
|
||||
roles = append(roles, role)
|
||||
}
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
// AgentsAccessRole returns the agents-access role for use by callers
|
||||
// that need to include it conditionally (e.g. when the agents
|
||||
// experiment is enabled).
|
||||
func AgentsAccessRole() Role {
|
||||
return builtInRoles[agentsAccess](uuid.Nil)
|
||||
}
|
||||
|
||||
// ChangeRoleSet is a helper function that finds the difference of 2 sets of
|
||||
// roles. When setting a user's new roles, it is equivalent to adding and
|
||||
// removing roles. This set determines the changes, so that the appropriate
|
||||
|
||||
+87
-82
@@ -49,6 +49,11 @@ func TestBuiltInRoles(t *testing.T) {
|
||||
require.NoError(t, r.Valid(), "invalid role")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("agents-access", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.NoError(t, rbac.AgentsAccessRole().Valid(), "invalid role")
|
||||
})
|
||||
}
|
||||
|
||||
// permissionGranted checks whether a permission list contains a
|
||||
@@ -199,6 +204,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
orgUserAdmin := authSubject{Name: "org_user_admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgUserAdmin(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
|
||||
orgTemplateAdmin := authSubject{Name: "org_template_admin", Actor: rbac.Subject{ID: userAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgTemplateAdmin(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
|
||||
orgAdminBanWorkspace := authSubject{Name: "org_admin_workspace_ban", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgAdmin(orgID), rbac.ScopedRoleOrgWorkspaceCreationBan(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
|
||||
agentsAccessUser := authSubject{Name: "chat_access", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleAgentsAccess()}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
|
||||
setOrgNotMe := authSubjectSet{orgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin}
|
||||
|
||||
otherOrgAdmin := authSubject{Name: "org_admin_other", Actor: rbac.Subject{ID: uuid.NewString(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgAdmin(otherOrg)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
|
||||
@@ -210,7 +216,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
// requiredSubjects are required to be asserted in each test case. This is
|
||||
// to make sure one is not forgotten.
|
||||
requiredSubjects := []authSubject{
|
||||
memberMe, owner,
|
||||
memberMe, owner, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin,
|
||||
templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
|
||||
}
|
||||
@@ -233,7 +239,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionRead},
|
||||
Resource: rbac.ResourceUserObject(currentUser),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, memberMe, templateAdmin, userAdmin, orgUserAdmin, otherOrgAdmin, otherOrgUserAdmin, orgAdmin},
|
||||
true: {owner, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgUserAdmin, otherOrgAdmin, otherOrgUserAdmin, orgAdmin},
|
||||
false: {
|
||||
orgTemplateAdmin, orgAuditor,
|
||||
otherOrgAuditor, otherOrgTemplateAdmin,
|
||||
@@ -246,7 +252,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceUser,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -256,7 +262,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin, orgAdminBanWorkspace},
|
||||
false: {setOtherOrg, memberMe, userAdmin, orgAuditor, orgUserAdmin},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, orgAuditor, orgUserAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -266,7 +272,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, orgAdminBanWorkspace},
|
||||
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -276,7 +282,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin},
|
||||
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -286,7 +292,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspace.InOrg(orgID).WithOwner(policy.WildcardSymbol),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin},
|
||||
false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, userAdmin, templateAdmin, orgTemplateAdmin},
|
||||
false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -296,7 +302,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -306,7 +312,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -315,7 +321,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin},
|
||||
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -324,7 +330,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, orgAdminBanWorkspace},
|
||||
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -337,7 +343,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, orgAdminBanWorkspace},
|
||||
false: {
|
||||
memberMe, setOtherOrg,
|
||||
memberMe, agentsAccessUser, setOtherOrg,
|
||||
templateAdmin, userAdmin,
|
||||
orgTemplateAdmin, orgUserAdmin, orgAuditor,
|
||||
},
|
||||
@@ -354,7 +360,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
true: {},
|
||||
false: {
|
||||
orgAdmin, owner, setOtherOrg,
|
||||
userAdmin, memberMe,
|
||||
userAdmin, memberMe, agentsAccessUser,
|
||||
templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor,
|
||||
orgAdminBanWorkspace,
|
||||
},
|
||||
@@ -366,7 +372,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceTemplate.WithID(templateID).InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin},
|
||||
false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, userAdmin},
|
||||
false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, agentsAccessUser, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -375,7 +381,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceTemplate.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAuditor, orgAdmin, templateAdmin, orgTemplateAdmin},
|
||||
false: {setOtherOrg, orgUserAdmin, memberMe, userAdmin},
|
||||
false: {setOtherOrg, orgUserAdmin, memberMe, agentsAccessUser, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -386,7 +392,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
}),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin},
|
||||
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, userAdmin},
|
||||
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, agentsAccessUser, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -397,7 +403,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
true: {owner, templateAdmin},
|
||||
// Org template admins can only read org scoped files.
|
||||
// File scope is currently not org scoped :cry:
|
||||
false: {setOtherOrg, orgTemplateAdmin, orgAdmin, memberMe, userAdmin, orgAuditor, orgUserAdmin},
|
||||
false: {setOtherOrg, orgTemplateAdmin, orgAdmin, memberMe, agentsAccessUser, userAdmin, orgAuditor, orgUserAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -405,7 +411,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead},
|
||||
Resource: rbac.ResourceFile.WithID(fileID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, memberMe, templateAdmin},
|
||||
true: {owner, memberMe, agentsAccessUser, templateAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, userAdmin},
|
||||
},
|
||||
},
|
||||
@@ -415,7 +421,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceOrganization,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -424,7 +430,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceOrganization.WithID(orgID).InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin},
|
||||
false: {setOtherOrg, orgTemplateAdmin, orgUserAdmin, orgAuditor, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, orgTemplateAdmin, orgUserAdmin, orgAuditor, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -433,7 +439,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceOrganization.WithID(orgID).InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin, auditor, orgAuditor, userAdmin, orgUserAdmin},
|
||||
false: {setOtherOrg, memberMe},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -442,7 +448,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceAssignOrgRole,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, userAdmin, memberMe, templateAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, userAdmin, memberMe, agentsAccessUser, templateAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -451,7 +457,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceAssignRole,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -459,7 +465,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionRead},
|
||||
Resource: rbac.ResourceAssignRole,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {setOtherOrg, setOrgNotMe, owner, memberMe, templateAdmin, userAdmin},
|
||||
true: {setOtherOrg, setOrgNotMe, owner, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
false: {},
|
||||
},
|
||||
},
|
||||
@@ -469,7 +475,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceAssignOrgRole.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, userAdmin, orgUserAdmin},
|
||||
false: {setOtherOrg, memberMe, templateAdmin, orgTemplateAdmin, orgAuditor},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, templateAdmin, orgTemplateAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -478,7 +484,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceAssignOrgRole.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin},
|
||||
false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -487,7 +493,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceAssignOrgRole.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, orgUserAdmin, userAdmin, templateAdmin},
|
||||
false: {setOtherOrg, memberMe, orgAuditor, orgTemplateAdmin},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, orgAuditor, orgTemplateAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -495,7 +501,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete, policy.ActionUpdate},
|
||||
Resource: rbac.ResourceApiKey.WithID(apiKeyID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, memberMe},
|
||||
true: {owner, memberMe, agentsAccessUser},
|
||||
false: {setOtherOrg, setOrgNotMe, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
@@ -507,7 +513,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceInboxNotification.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin},
|
||||
false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, templateAdmin, userAdmin, memberMe},
|
||||
false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, templateAdmin, userAdmin, memberMe, agentsAccessUser},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -515,7 +521,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionReadPersonal, policy.ActionUpdatePersonal},
|
||||
Resource: rbac.ResourceUserObject(currentUser),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, memberMe, userAdmin},
|
||||
true: {owner, memberMe, agentsAccessUser, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, templateAdmin},
|
||||
},
|
||||
},
|
||||
@@ -525,7 +531,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, userAdmin, orgUserAdmin},
|
||||
false: {setOtherOrg, orgTemplateAdmin, orgAuditor, memberMe, templateAdmin},
|
||||
false: {setOtherOrg, orgTemplateAdmin, orgAuditor, memberMe, agentsAccessUser, templateAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -534,7 +540,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgUserAdmin, orgTemplateAdmin},
|
||||
false: {memberMe, setOtherOrg},
|
||||
false: {memberMe, agentsAccessUser, setOtherOrg},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -547,7 +553,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, templateAdmin, orgUserAdmin, orgTemplateAdmin, orgAuditor},
|
||||
false: {setOtherOrg, memberMe, userAdmin},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -560,7 +566,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
}),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, userAdmin, orgUserAdmin},
|
||||
false: {setOtherOrg, memberMe, templateAdmin, orgTemplateAdmin, orgAuditor},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, templateAdmin, orgTemplateAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -573,7 +579,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
}),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
false: {setOtherOrg, memberMe},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -582,7 +588,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceGroupMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin},
|
||||
false: {setOtherOrg, memberMe},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -591,7 +597,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceGroupMember.WithID(adminID).InOrg(orgID).WithOwner(adminID.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin},
|
||||
false: {setOtherOrg, memberMe},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -600,7 +606,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspaceDormant.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {orgAdmin, owner},
|
||||
false: {setOtherOrg, userAdmin, memberMe, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
false: {setOtherOrg, userAdmin, memberMe, agentsAccessUser, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -609,7 +615,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspaceDormant.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, userAdmin, owner, templateAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, userAdmin, owner, templateAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -618,7 +624,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspace.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin},
|
||||
false: {setOtherOrg, userAdmin, templateAdmin, memberMe, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
false: {setOtherOrg, userAdmin, templateAdmin, memberMe, agentsAccessUser, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -627,7 +633,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourcePrebuiltWorkspace.WithID(uuid.New()).InOrg(orgID).WithOwner(database.PrebuildsSystemUserID.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin},
|
||||
false: {setOtherOrg, userAdmin, memberMe, orgUserAdmin, orgAuditor},
|
||||
false: {setOtherOrg, userAdmin, memberMe, agentsAccessUser, orgUserAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -636,7 +642,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceTask.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin},
|
||||
false: {setOtherOrg, userAdmin, templateAdmin, memberMe, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
false: {setOtherOrg, userAdmin, templateAdmin, memberMe, agentsAccessUser, orgTemplateAdmin, orgUserAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
// Some admin style resources
|
||||
@@ -646,7 +652,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceLicense,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -655,7 +661,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceDeploymentStats,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -664,7 +670,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceDeploymentConfig,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -673,7 +679,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceDebugInfo,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -682,7 +688,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceReplicas,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -691,7 +697,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceTailnetCoordinator,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -700,7 +706,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceAuditLog,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -709,7 +715,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceProvisionerDaemon.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, templateAdmin, orgAdmin, orgTemplateAdmin},
|
||||
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, userAdmin},
|
||||
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, agentsAccessUser, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -718,7 +724,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceProvisionerDaemon.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, templateAdmin, orgAdmin, orgTemplateAdmin},
|
||||
false: {setOtherOrg, memberMe, userAdmin, orgAuditor, orgUserAdmin},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, orgAuditor, orgUserAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -727,7 +733,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceProvisionerDaemon.WithOwner(currentUser.String()).InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, templateAdmin, orgTemplateAdmin, orgAdmin},
|
||||
false: {setOtherOrg, memberMe, userAdmin, orgUserAdmin, orgAuditor},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, orgUserAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -736,7 +742,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceProvisionerJobs.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgTemplateAdmin, orgAdmin},
|
||||
false: {setOtherOrg, memberMe, templateAdmin, userAdmin, orgUserAdmin, orgAuditor},
|
||||
false: {setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgUserAdmin, orgAuditor},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -745,7 +751,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceSystem,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -754,7 +760,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceOauth2App,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -762,7 +768,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionRead},
|
||||
Resource: rbac.ResourceOauth2App,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin},
|
||||
true: {owner, setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
false: {},
|
||||
},
|
||||
},
|
||||
@@ -772,7 +778,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceOauth2AppSecret,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -781,7 +787,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceOauth2AppCodeToken,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -790,7 +796,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceWorkspaceProxy,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -798,7 +804,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionRead},
|
||||
Resource: rbac.ResourceWorkspaceProxy,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin},
|
||||
true: {owner, setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
false: {},
|
||||
},
|
||||
},
|
||||
@@ -809,7 +815,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate},
|
||||
Resource: rbac.ResourceNotificationPreference.WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {memberMe, owner},
|
||||
true: {memberMe, agentsAccessUser, owner},
|
||||
false: {
|
||||
userAdmin, orgUserAdmin, templateAdmin,
|
||||
orgAuditor, orgTemplateAdmin,
|
||||
@@ -826,7 +832,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {
|
||||
memberMe, userAdmin, orgUserAdmin, templateAdmin,
|
||||
memberMe, agentsAccessUser, userAdmin, orgUserAdmin, templateAdmin,
|
||||
orgAuditor, orgTemplateAdmin,
|
||||
otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
@@ -840,7 +846,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {
|
||||
memberMe,
|
||||
memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
@@ -858,7 +864,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {
|
||||
memberMe, templateAdmin, orgUserAdmin, userAdmin,
|
||||
memberMe, agentsAccessUser, templateAdmin, orgUserAdmin, userAdmin,
|
||||
orgAdmin, orgAuditor, orgTemplateAdmin,
|
||||
otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
|
||||
otherOrgAdmin,
|
||||
@@ -871,7 +877,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete},
|
||||
Resource: rbac.ResourceWebpushSubscription.WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, memberMe},
|
||||
true: {owner, memberMe, agentsAccessUser},
|
||||
false: {orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin},
|
||||
},
|
||||
},
|
||||
@@ -883,7 +889,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, userAdmin, orgAdmin, otherOrgAdmin, orgUserAdmin, otherOrgUserAdmin},
|
||||
false: {
|
||||
memberMe, templateAdmin,
|
||||
memberMe, agentsAccessUser, templateAdmin,
|
||||
orgTemplateAdmin, orgAuditor,
|
||||
otherOrgAuditor, otherOrgTemplateAdmin,
|
||||
},
|
||||
@@ -896,7 +902,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, orgAdmin, otherOrgAdmin},
|
||||
false: {
|
||||
userAdmin, memberMe,
|
||||
userAdmin, memberMe, agentsAccessUser,
|
||||
orgAuditor, orgUserAdmin,
|
||||
otherOrgAuditor, otherOrgUserAdmin,
|
||||
},
|
||||
@@ -909,7 +915,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, orgAdmin, otherOrgAdmin},
|
||||
false: {
|
||||
memberMe, userAdmin, templateAdmin,
|
||||
memberMe, agentsAccessUser, userAdmin, templateAdmin,
|
||||
orgAuditor, orgUserAdmin, orgTemplateAdmin,
|
||||
otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
|
||||
},
|
||||
@@ -921,7 +927,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceCryptoKey,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -932,7 +938,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
true: {owner, orgAdmin, orgUserAdmin, userAdmin},
|
||||
false: {
|
||||
otherOrgAdmin,
|
||||
memberMe, templateAdmin,
|
||||
memberMe, agentsAccessUser, templateAdmin,
|
||||
orgAuditor, orgTemplateAdmin,
|
||||
otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
|
||||
},
|
||||
@@ -947,7 +953,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
false: {
|
||||
orgAdmin, orgUserAdmin,
|
||||
otherOrgAdmin,
|
||||
memberMe, templateAdmin,
|
||||
memberMe, agentsAccessUser, templateAdmin,
|
||||
orgAuditor, orgTemplateAdmin,
|
||||
otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
|
||||
},
|
||||
@@ -960,7 +966,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {
|
||||
memberMe,
|
||||
memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
@@ -975,7 +981,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {
|
||||
memberMe,
|
||||
memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
@@ -989,7 +995,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Resource: rbac.ResourceConnectionLog,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
// Only the user themselves can access their own secrets — no one else.
|
||||
@@ -998,7 +1004,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
Resource: rbac.ResourceUserSecret.WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {memberMe},
|
||||
true: {memberMe, agentsAccessUser},
|
||||
false: {
|
||||
owner, orgAdmin,
|
||||
otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin,
|
||||
@@ -1014,7 +1020,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
true: {},
|
||||
false: {
|
||||
owner,
|
||||
memberMe,
|
||||
memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
@@ -1028,7 +1034,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate},
|
||||
Resource: rbac.ResourceAibridgeInterception.WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, memberMe},
|
||||
true: {owner, memberMe, agentsAccessUser},
|
||||
false: {
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor,
|
||||
@@ -1045,7 +1051,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, auditor},
|
||||
false: {
|
||||
memberMe,
|
||||
memberMe, agentsAccessUser,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
@@ -1058,7 +1064,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
Resource: rbac.ResourceBoundaryUsage,
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
false: {owner, setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin},
|
||||
false: {owner, setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1066,8 +1072,9 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
|
||||
Resource: rbac.ResourceChat.WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]hasAuthSubjects{
|
||||
true: {owner, memberMe},
|
||||
true: {owner, agentsAccessUser},
|
||||
false: {
|
||||
memberMe,
|
||||
orgAdmin, otherOrgAdmin,
|
||||
orgAuditor, otherOrgAuditor,
|
||||
templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin,
|
||||
@@ -1076,7 +1083,6 @@ func TestRolePermissions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Build coverage set from test case definitions statically,
|
||||
// so we don't need shared mutable state during execution.
|
||||
// This allows subtests to run in parallel.
|
||||
@@ -1217,7 +1223,6 @@ func TestListRoles(t *testing.T) {
|
||||
"user-admin",
|
||||
},
|
||||
siteRoleNames)
|
||||
|
||||
orgID := uuid.New()
|
||||
orgRoles := rbac.OrganizationRoles(orgID)
|
||||
orgRoleNames := make([]string, 0, len(orgRoles))
|
||||
|
||||
+11
-1
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
@@ -43,7 +44,16 @@ func (api *API) AssignableSiteRoles(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Roles, rbac.SiteBuiltInRoles(), dbCustomRoles))
|
||||
siteRoles := rbac.SiteBuiltInRoles()
|
||||
// Include the agents-access role only when the agents
|
||||
// experiment is enabled or this is a dev build, matching
|
||||
// the RequireExperimentWithDevBypass gate on chat routes.
|
||||
if api.Experiments.Enabled(codersdk.ExperimentAgents) || buildinfo.IsDev() {
|
||||
siteRoles = append(siteRoles, rbac.AgentsAccessRole())
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK,
|
||||
assignableRoles(actorRoles.Roles, siteRoles, dbCustomRoles))
|
||||
}
|
||||
|
||||
// assignableOrgRoles returns all org wide roles that can be assigned.
|
||||
|
||||
+72
-7
@@ -1244,17 +1244,57 @@ func (p *Server) EditMessage(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ArchiveChat archives a chat and all descendants, then broadcasts a deleted event.
|
||||
// ArchiveChat archives a chat and all descendants. If the target chat is
|
||||
// pending or running, it first transitions the chat back to waiting so active
|
||||
// processing stops before the archive is broadcast.
|
||||
func (p *Server) ArchiveChat(ctx context.Context, chat database.Chat) error {
|
||||
if chat.ID == uuid.Nil {
|
||||
return xerrors.New("chat_id is required")
|
||||
}
|
||||
|
||||
if err := p.db.ArchiveChatByID(ctx, chat.ID); err != nil {
|
||||
return xerrors.Errorf("archive chat: %w", err)
|
||||
statusChat := chat
|
||||
interrupted := false
|
||||
if err := p.db.InTx(func(tx database.Store) error {
|
||||
lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("lock chat for archive: %w", err)
|
||||
}
|
||||
statusChat = lockedChat
|
||||
|
||||
// We do not call setChatWaiting here because it intentionally preserves
|
||||
// pending chats so queued-message promotion can win. Archiving is a
|
||||
// harder stop: both pending and running chats must transition to waiting.
|
||||
if lockedChat.Status == database.ChatStatusPending || lockedChat.Status == database.ChatStatusRunning {
|
||||
statusChat, err = tx.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
WorkerID: uuid.NullUUID{},
|
||||
StartedAt: sql.NullTime{},
|
||||
HeartbeatAt: sql.NullTime{},
|
||||
LastError: sql.NullString{},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("set chat waiting before archive: %w", err)
|
||||
}
|
||||
interrupted = true
|
||||
}
|
||||
|
||||
if err := tx.ArchiveChatByID(ctx, chat.ID); err != nil {
|
||||
return xerrors.Errorf("archive chat: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindDeleted, nil)
|
||||
if interrupted {
|
||||
p.publishStatus(chat.ID, statusChat.Status, statusChat.WorkerID)
|
||||
p.publishChatPubsubEvent(statusChat, coderdpubsub.ChatEventKindStatusChange, nil)
|
||||
}
|
||||
|
||||
statusChat.Archived = true
|
||||
statusChat.PinOrder = 0
|
||||
p.publishChatPubsubEvent(statusChat, coderdpubsub.ChatEventKindDeleted, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3447,7 +3487,25 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
|
||||
chatCtx, cancel := context.WithCancelCause(ctx)
|
||||
defer cancel(nil)
|
||||
|
||||
controlCancel := p.subscribeChatControl(chatCtx, chat.ID, cancel, logger)
|
||||
// Gate the control subscriber behind a channel that is closed
|
||||
// after we publish "running" status. This prevents stale
|
||||
// pubsub notifications (e.g. the "pending" notification from
|
||||
// SendMessage that triggered this processing) from
|
||||
// interrupting us before we start work. Due to async
|
||||
// PostgreSQL NOTIFY delivery, a notification published before
|
||||
// subscribeChatControl registers its queue can still arrive
|
||||
// after registration.
|
||||
controlArmed := make(chan struct{})
|
||||
gatedCancel := func(cause error) {
|
||||
select {
|
||||
case <-controlArmed:
|
||||
cancel(cause)
|
||||
default:
|
||||
logger.Debug(ctx, "ignoring control notification before armed")
|
||||
}
|
||||
}
|
||||
|
||||
controlCancel := p.subscribeChatControl(chatCtx, chat.ID, gatedCancel, logger)
|
||||
defer func() {
|
||||
if controlCancel != nil {
|
||||
controlCancel()
|
||||
@@ -3508,6 +3566,12 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
|
||||
Valid: true,
|
||||
})
|
||||
|
||||
// Arm the control subscriber. Closing the channel is a
|
||||
// happens-before guarantee in the Go memory model — any
|
||||
// notification dispatched after this point will correctly
|
||||
// interrupt processing.
|
||||
close(controlArmed)
|
||||
|
||||
// Determine the final status and last error to set when we're done.
|
||||
status := database.ChatStatusWaiting
|
||||
wasInterrupted := false
|
||||
@@ -3563,9 +3627,10 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
|
||||
// the worker and let the processor pick it back up.
|
||||
if latestChat.Status == database.ChatStatusPending {
|
||||
status = database.ChatStatusPending
|
||||
} else if status == database.ChatStatusWaiting {
|
||||
} else if status == database.ChatStatusWaiting && !latestChat.Archived {
|
||||
// Queued messages were already admitted through SendMessage,
|
||||
// so auto-promotion only preserves FIFO order here.
|
||||
// so auto-promotion only preserves FIFO order here. Archived
|
||||
// chats skip promotion so archiving behaves like a hard stop.
|
||||
var promoteErr error
|
||||
promotedMessage, remainingQueuedMessages, shouldPublishQueueUpdate, promoteErr = p.tryAutoPromoteQueuedMessage(cleanupCtx, tx, latestChat)
|
||||
if promoteErr != nil {
|
||||
|
||||
@@ -2018,3 +2018,95 @@ func chatMessageWithParts(parts []codersdk.ChatMessagePart) database.ChatMessage
|
||||
Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true},
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessChat_IgnoresStaleControlNotification verifies that
|
||||
// processChat is not interrupted by a "pending" notification
|
||||
// published before processing begins. This is the race that caused
|
||||
// TestOpenAIReasoningWithWebSearchRoundTripStoreFalse to flake:
|
||||
// SendMessage publishes "pending" via PostgreSQL NOTIFY, and due
|
||||
// to async delivery the notification can arrive at the control
|
||||
// subscriber after it registers but before the processor publishes
|
||||
// "running".
|
||||
func TestProcessChat_IgnoresStaleControlNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
ps := dbpubsub.NewInMemory()
|
||||
clock := quartz.NewMock(t)
|
||||
|
||||
chatID := uuid.New()
|
||||
workerID := uuid.New()
|
||||
|
||||
server := &Server{
|
||||
db: db,
|
||||
logger: logger,
|
||||
pubsub: ps,
|
||||
clock: clock,
|
||||
workerID: workerID,
|
||||
chatHeartbeatInterval: time.Minute,
|
||||
configCache: newChatConfigCache(ctx, db, clock),
|
||||
}
|
||||
|
||||
// Publish a stale "pending" notification on the control channel
|
||||
// BEFORE processChat subscribes. In production this is the
|
||||
// notification from SendMessage that triggered the processing.
|
||||
staleNotify, err := json.Marshal(coderdpubsub.ChatStreamNotifyMessage{
|
||||
Status: string(database.ChatStatusPending),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = ps.Publish(coderdpubsub.ChatStreamNotifyChannel(chatID), staleNotify)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Track which status processChat writes during cleanup.
|
||||
var finalStatus database.ChatStatus
|
||||
cleanupDone := make(chan struct{})
|
||||
|
||||
// The deferred cleanup in processChat runs a transaction.
|
||||
db.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(fn func(database.Store) error, _ *database.TxOptions) error {
|
||||
return fn(db)
|
||||
},
|
||||
)
|
||||
db.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(
|
||||
database.Chat{ID: chatID, Status: database.ChatStatusRunning, WorkerID: uuid.NullUUID{UUID: workerID, Valid: true}}, nil,
|
||||
)
|
||||
db.EXPECT().UpdateChatStatus(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(_ context.Context, params database.UpdateChatStatusParams) (database.Chat, error) {
|
||||
finalStatus = params.Status
|
||||
close(cleanupDone)
|
||||
return database.Chat{ID: chatID, Status: params.Status}, nil
|
||||
},
|
||||
)
|
||||
|
||||
// resolveChatModel fails immediately — that's fine, we only
|
||||
// need processChat to get past initialization without being
|
||||
// interrupted by the stale notification.
|
||||
db.EXPECT().GetChatModelConfigByID(gomock.Any(), gomock.Any()).Return(
|
||||
database.ChatModelConfig{}, xerrors.New("no model configured"),
|
||||
).AnyTimes()
|
||||
db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
db.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(
|
||||
database.ChatUsageLimitConfig{}, sql.ErrNoRows,
|
||||
).AnyTimes()
|
||||
db.EXPECT().GetChatMessagesForPromptByChatID(gomock.Any(), chatID).Return(nil, nil).AnyTimes()
|
||||
|
||||
chat := database.Chat{ID: chatID, LastModelConfigID: uuid.New()}
|
||||
go server.processChat(ctx, chat)
|
||||
|
||||
select {
|
||||
case <-cleanupDone:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("processChat did not complete")
|
||||
}
|
||||
|
||||
// If the stale notification interrupted us, status would be
|
||||
// "waiting" (the ErrInterrupted path). Since the gate blocked
|
||||
// it, processChat reached runChat, which failed on model
|
||||
// resolution → status is "error".
|
||||
require.Equal(t, database.ChatStatusError, finalStatus,
|
||||
"processChat should have reached runChat (error), not been interrupted (waiting)")
|
||||
}
|
||||
|
||||
@@ -297,6 +297,180 @@ func TestInterruptChatClearsWorkerInDatabase(t *testing.T) {
|
||||
require.False(t, fromDB.WorkerID.Valid)
|
||||
}
|
||||
|
||||
func TestArchiveChatMovesPendingChatToWaiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
replica := newTestServer(t, db, ps, uuid.New())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "archive-pending",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusPending,
|
||||
WorkerID: uuid.NullUUID{},
|
||||
StartedAt: sql.NullTime{},
|
||||
HeartbeatAt: sql.NullTime{},
|
||||
LastError: sql.NullString{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = replica.ArchiveChat(ctx, chat)
|
||||
require.NoError(t, err)
|
||||
|
||||
fromDB, err := db.GetChatByID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, database.ChatStatusWaiting, fromDB.Status)
|
||||
require.False(t, fromDB.WorkerID.Valid)
|
||||
require.False(t, fromDB.StartedAt.Valid)
|
||||
require.False(t, fromDB.HeartbeatAt.Valid)
|
||||
require.True(t, fromDB.Archived)
|
||||
require.Zero(t, fromDB.PinOrder)
|
||||
}
|
||||
|
||||
func TestArchiveChatInterruptsActiveProcessing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
streamStarted := make(chan struct{})
|
||||
streamCanceled := make(chan struct{})
|
||||
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
if !req.Stream {
|
||||
return chattest.OpenAINonStreamingResponse("title")
|
||||
}
|
||||
chunks := make(chan chattest.OpenAIChunk, 1)
|
||||
go func() {
|
||||
defer close(chunks)
|
||||
chunks <- chattest.OpenAITextChunks("partial")[0]
|
||||
select {
|
||||
case <-streamStarted:
|
||||
default:
|
||||
close(streamStarted)
|
||||
}
|
||||
<-req.Context().Done()
|
||||
select {
|
||||
case <-streamCanceled:
|
||||
default:
|
||||
close(streamCanceled)
|
||||
}
|
||||
}()
|
||||
return chattest.OpenAIResponse{StreamingChunks: chunks}
|
||||
})
|
||||
|
||||
server := newActiveTestServer(t, db, ps)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
|
||||
|
||||
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "archive-interrupt",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
fromDB, dbErr := db.GetChatByID(ctx, chat.ID)
|
||||
if dbErr != nil {
|
||||
return false
|
||||
}
|
||||
return fromDB.Status == database.ChatStatusRunning && fromDB.WorkerID.Valid
|
||||
}, testutil.IntervalFast)
|
||||
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
select {
|
||||
case <-streamStarted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, testutil.IntervalFast)
|
||||
|
||||
_, events, cancel, ok := server.Subscribe(ctx, chat.ID, nil, 0)
|
||||
require.True(t, ok)
|
||||
defer cancel()
|
||||
|
||||
queuedResult, err := server.SendMessage(ctx, chatd.SendMessageOptions{
|
||||
ChatID: chat.ID,
|
||||
Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("queued")},
|
||||
BusyBehavior: chatd.SendMessageBusyBehaviorQueue,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, queuedResult.Queued)
|
||||
require.NotNil(t, queuedResult.QueuedMessage)
|
||||
|
||||
err = server.ArchiveChat(ctx, chat)
|
||||
require.NoError(t, err)
|
||||
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
select {
|
||||
case <-streamCanceled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, testutil.IntervalFast)
|
||||
|
||||
gotWaitingStatus := false
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
for {
|
||||
select {
|
||||
case ev := <-events:
|
||||
if ev.Type == codersdk.ChatStreamEventTypeStatus &&
|
||||
ev.Status != nil &&
|
||||
ev.Status.Status == codersdk.ChatStatusWaiting {
|
||||
gotWaitingStatus = true
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return gotWaitingStatus
|
||||
}
|
||||
}
|
||||
}, testutil.IntervalFast)
|
||||
require.True(t, gotWaitingStatus, "expected a waiting status event after archive")
|
||||
|
||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
||||
fromDB, dbErr := db.GetChatByID(ctx, chat.ID)
|
||||
if dbErr != nil {
|
||||
return false
|
||||
}
|
||||
return fromDB.Archived &&
|
||||
fromDB.Status == database.ChatStatusWaiting &&
|
||||
!fromDB.WorkerID.Valid &&
|
||||
!fromDB.StartedAt.Valid &&
|
||||
!fromDB.HeartbeatAt.Valid
|
||||
}, testutil.IntervalFast)
|
||||
|
||||
queuedMessages, err := db.GetChatQueuedMessages(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, queuedMessages, 1)
|
||||
require.Equal(t, queuedResult.QueuedMessage.ID, queuedMessages[0].ID)
|
||||
|
||||
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chat.ID,
|
||||
AfterID: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
userMessages := 0
|
||||
for _, msg := range messages {
|
||||
if msg.Role == database.ChatMessageRoleUser {
|
||||
userMessages++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, userMessages, "expected queued message to stay queued after archive")
|
||||
}
|
||||
|
||||
func TestUpdateChatHeartbeatRequiresOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
package chatd_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -597,306 +587,3 @@ func partTypeSet(parts []codersdk.ChatMessagePart) map[codersdk.ChatMessagePartT
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
type openAIStoreMode string
|
||||
|
||||
const (
|
||||
openAIStoreModeTrue openAIStoreMode = "store_true"
|
||||
openAIStoreModeFalse openAIStoreMode = "store_false"
|
||||
)
|
||||
|
||||
func TestOpenAIReasoningWithWebSearchRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
runOpenAIReasoningWithWebSearchRoundTripTest(t, openAIStoreModeTrue)
|
||||
}
|
||||
|
||||
func TestOpenAIReasoningWithWebSearchRoundTripStoreFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
runOpenAIReasoningWithWebSearchRoundTripTest(t, openAIStoreModeFalse)
|
||||
}
|
||||
|
||||
func runOpenAIReasoningWithWebSearchRoundTripTest(t *testing.T, storeMode openAIStoreMode) {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
store := storeMode == openAIStoreModeTrue
|
||||
|
||||
type capturedOpenAIRequest struct {
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Store *bool `json:"store,omitempty"`
|
||||
PreviousResponseID *string `json:"previous_response_id,omitempty"`
|
||||
Prompt []interface{} `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
streamRequestCount atomic.Int32
|
||||
firstReq *capturedOpenAIRequest
|
||||
secondReq *capturedOpenAIRequest
|
||||
mu sync.Mutex
|
||||
)
|
||||
upstreamOpenAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
if !req.Stream {
|
||||
return chattest.OpenAINonStreamingResponse("reasoning + web search title")
|
||||
}
|
||||
|
||||
switch req.Header.Get("X-Request-Ordinal") {
|
||||
case "1":
|
||||
return chattest.OpenAIResponse{
|
||||
ResponseID: "resp_first_test",
|
||||
StreamingChunks: chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAITextChunks("Here is what I found.")...,
|
||||
).StreamingChunks,
|
||||
Reasoning: &chattest.OpenAIReasoningItem{
|
||||
Summary: "thinking about the question",
|
||||
EncryptedContent: "encrypted_data_here",
|
||||
},
|
||||
WebSearch: &chattest.OpenAIWebSearchCall{
|
||||
Query: "latest AI news",
|
||||
},
|
||||
}
|
||||
default:
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAITextChunks("Follow-up answer.")...,
|
||||
)
|
||||
}
|
||||
})
|
||||
captureServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read OpenAI request body: %v", err)
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = r.Body.Close()
|
||||
|
||||
if r.URL.Path == "/responses" {
|
||||
var captured capturedOpenAIRequest
|
||||
if err := json.Unmarshal(body, &captured); err != nil {
|
||||
t.Errorf("decode OpenAI request body: %v", err)
|
||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if captured.Stream {
|
||||
requestCount := streamRequestCount.Add(1)
|
||||
r.Header.Set("X-Request-Ordinal", strconv.Itoa(int(requestCount)))
|
||||
|
||||
mu.Lock()
|
||||
switch requestCount {
|
||||
case 1:
|
||||
firstReq = &captured
|
||||
default:
|
||||
secondReq = &captured
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
upstreamReq, err := http.NewRequestWithContext(
|
||||
r.Context(),
|
||||
r.Method,
|
||||
upstreamOpenAIURL+r.URL.RequestURI(),
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
t.Errorf("create upstream OpenAI request: %v", err)
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
upstreamReq.Header = r.Header.Clone()
|
||||
|
||||
resp, err := http.DefaultClient.Do(upstreamReq)
|
||||
if err != nil {
|
||||
t.Errorf("forward OpenAI request: %v", err)
|
||||
http.Error(rw, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
rw.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
rw.WriteHeader(resp.StatusCode)
|
||||
if _, err := io.Copy(rw, resp.Body); err != nil {
|
||||
t.Errorf("copy OpenAI response body: %v", err)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(captureServer.Close)
|
||||
openAIURL := captureServer.URL
|
||||
|
||||
deploymentValues := coderdtest.DeploymentValues(t)
|
||||
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentValues: deploymentValues,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
expClient := codersdk.NewExperimentalClient(client)
|
||||
|
||||
_, err := expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
|
||||
Provider: "openai",
|
||||
APIKey: "test-api-key",
|
||||
BaseURL: openAIURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
contextLimit := int64(200000)
|
||||
isDefault := true
|
||||
reasoningEffort := "medium"
|
||||
reasoningSummary := "auto"
|
||||
_, err = expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
|
||||
Provider: "openai",
|
||||
Model: "o4-mini",
|
||||
ContextLimit: &contextLimit,
|
||||
IsDefault: &isDefault,
|
||||
ModelConfig: &codersdk.ChatModelCallConfig{
|
||||
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
||||
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{
|
||||
Store: ptr.Ref(store),
|
||||
ReasoningEffort: &reasoningEffort,
|
||||
ReasoningSummary: &reasoningSummary,
|
||||
WebSearchEnabled: ptr.Ref(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Logf("Creating chat with reasoning + web search query (store=%t)...", store)
|
||||
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "Search for the latest AI news and summarize it briefly.",
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
events, closer, err := expClient.StreamChat(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer closer.Close()
|
||||
|
||||
waitForChatDone(ctx, t, events, "step 1")
|
||||
|
||||
chatData, err := expClient.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
chatMsgs, err := expClient.GetChatMessages(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.ChatStatusWaiting, chatData.Status,
|
||||
"chat should be in waiting status after step 1")
|
||||
|
||||
assistantMsg := findAssistantWithText(t, chatMsgs.Messages)
|
||||
require.NotNil(t, assistantMsg,
|
||||
"expected an assistant message with text content after step 1")
|
||||
|
||||
partTypes := partTypeSet(assistantMsg.Content)
|
||||
require.Contains(t, partTypes, codersdk.ChatMessagePartTypeReasoning,
|
||||
"assistant message should contain reasoning parts")
|
||||
require.Contains(t, partTypes, codersdk.ChatMessagePartTypeToolCall,
|
||||
"assistant message should contain a provider-executed web search tool call")
|
||||
require.Contains(t, partTypes, codersdk.ChatMessagePartTypeToolResult,
|
||||
"assistant message should contain a provider-executed web search tool result")
|
||||
require.Contains(t, partTypes, codersdk.ChatMessagePartTypeText,
|
||||
"assistant message should contain a text part")
|
||||
|
||||
var foundReasoning, foundWebSearchCall, foundText bool
|
||||
for _, part := range assistantMsg.Content {
|
||||
switch part.Type {
|
||||
case codersdk.ChatMessagePartTypeReasoning:
|
||||
// fantasy emits a leading newline when the reasoning summary part is
|
||||
// added, so match the persisted summary text after trimming whitespace.
|
||||
if strings.TrimSpace(part.Text) == "thinking about the question" {
|
||||
foundReasoning = true
|
||||
}
|
||||
case codersdk.ChatMessagePartTypeToolCall:
|
||||
if part.ToolName == "web_search" {
|
||||
require.True(t, part.ProviderExecuted,
|
||||
"web search tool-call should be marked provider-executed")
|
||||
foundWebSearchCall = true
|
||||
}
|
||||
case codersdk.ChatMessagePartTypeText:
|
||||
if part.Text == "Here is what I found." {
|
||||
foundText = true
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, foundReasoning, "expected reasoning summary text to be persisted")
|
||||
require.True(t, foundWebSearchCall, "expected persisted web_search tool call")
|
||||
require.True(t, foundText, "expected streamed assistant text to be persisted")
|
||||
|
||||
t.Log("Sending follow-up message...")
|
||||
_, err = expClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "What is the follow-up takeaway?",
|
||||
}},
|
||||
})
|
||||
if !store && err != nil {
|
||||
require.NotContains(t, err.Error(),
|
||||
"Items are not persisted when store is set to false.",
|
||||
"follow-up should reconstruct store=false responses without stale provider item IDs")
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
events2, closer2, err := expClient.StreamChat(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer closer2.Close()
|
||||
|
||||
waitForChatDone(ctx, t, events2, "step 2")
|
||||
|
||||
chatData2, err := expClient.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
chatMsgs2, err := expClient.GetChatMessages(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.ChatStatusWaiting, chatData2.Status,
|
||||
"chat should be in waiting status after step 2")
|
||||
require.Greater(t, len(chatMsgs2.Messages), len(chatMsgs.Messages),
|
||||
"follow-up should have added more messages")
|
||||
require.NotNil(t, findLastAssistantWithText(t, chatMsgs2.Messages),
|
||||
"expected an assistant message with text after the follow-up")
|
||||
require.Equal(t, int32(2), streamRequestCount.Load(),
|
||||
"expected exactly two streamed OpenAI responses")
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
require.NotNil(t, firstReq, "expected first streaming request to be captured")
|
||||
if store {
|
||||
require.NotNil(t, firstReq.Store, "first request should have store field")
|
||||
require.True(t, *firstReq.Store, "store should be true")
|
||||
} else if firstReq.Store != nil {
|
||||
require.False(t, *firstReq.Store, "store should be false")
|
||||
}
|
||||
|
||||
require.NotNil(t, secondReq, "expected second streaming request to be captured")
|
||||
foundAssistantReplay := false
|
||||
for _, item := range secondReq.Prompt {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
role, _ := m["role"].(string)
|
||||
if role == "assistant" {
|
||||
foundAssistantReplay = true
|
||||
}
|
||||
if store {
|
||||
require.NotEqual(t, "assistant", role,
|
||||
"store=true chain-mode prompt should not replay assistant messages")
|
||||
require.NotEqual(t, "tool", role,
|
||||
"store=true chain-mode prompt should not replay tool messages")
|
||||
}
|
||||
}
|
||||
|
||||
if store {
|
||||
require.NotNil(t, secondReq.PreviousResponseID,
|
||||
"store=true follow-up should set previous_response_id")
|
||||
require.Equal(t, "resp_first_test", *secondReq.PreviousResponseID,
|
||||
"previous_response_id should match the first response's ID")
|
||||
} else {
|
||||
if secondReq.PreviousResponseID != nil {
|
||||
require.Empty(t, *secondReq.PreviousResponseID,
|
||||
"store=false follow-up should not set previous_response_id")
|
||||
}
|
||||
require.True(t, foundAssistantReplay,
|
||||
"store=false follow-up should replay prior assistant history")
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -3923,15 +3923,17 @@ Write out the current server config as YAML to stdout.`,
|
||||
YAML: "key_file",
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Proxy Domain Allowlist",
|
||||
Description: "Comma-separated list of AI provider domains for which HTTPS traffic will be decrypted and routed through AI Bridge. Requests to other domains will be tunneled directly without decryption. Supported domains: api.anthropic.com, api.openai.com, api.individual.githubcopilot.com.",
|
||||
Flag: "aibridge-proxy-domain-allowlist",
|
||||
Env: "CODER_AIBRIDGE_PROXY_DOMAIN_ALLOWLIST",
|
||||
Value: &c.AI.BridgeProxyConfig.DomainAllowlist,
|
||||
Default: "api.anthropic.com,api.openai.com,api.individual.githubcopilot.com",
|
||||
Hidden: true,
|
||||
Group: &deploymentGroupAIBridgeProxy,
|
||||
YAML: "domain_allowlist",
|
||||
Name: "AI Bridge Proxy Domain Allowlist",
|
||||
Description: "Comma-separated list of AI provider domains for which HTTPS traffic will be decrypted and routed through AI Bridge. " +
|
||||
"Requests to other domains will be tunneled directly without decryption. " +
|
||||
"Supported domains: api.anthropic.com, api.openai.com, api.individual.githubcopilot.com, api.business.githubcopilot.com, api.enterprise.githubcopilot.com, chatgpt.com.",
|
||||
Flag: "aibridge-proxy-domain-allowlist",
|
||||
Env: "CODER_AIBRIDGE_PROXY_DOMAIN_ALLOWLIST",
|
||||
Value: &c.AI.BridgeProxyConfig.DomainAllowlist,
|
||||
Default: "api.anthropic.com,api.openai.com,api.individual.githubcopilot.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,chatgpt.com",
|
||||
Hidden: true,
|
||||
Group: &deploymentGroupAIBridgeProxy,
|
||||
YAML: "domain_allowlist",
|
||||
},
|
||||
{
|
||||
Name: "AI Bridge Proxy Upstream Proxy",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package codersdk
|
||||
|
||||
// Ideally this roles would be generated from the rbac/roles.go package.
|
||||
// Ideally these roles would be generated from the rbac/roles.go package.
|
||||
const (
|
||||
RoleOwner string = "owner"
|
||||
RoleMember string = "member"
|
||||
RoleTemplateAdmin string = "template-admin"
|
||||
RoleUserAdmin string = "user-admin"
|
||||
RoleAuditor string = "auditor"
|
||||
RoleAgentsAccess string = "agents-access"
|
||||
|
||||
RoleOrganizationAdmin string = "organization-admin"
|
||||
RoleOrganizationMember string = "organization-member"
|
||||
|
||||
@@ -65,6 +65,9 @@ Once the server restarts with the experiment enabled:
|
||||
1. Navigate to the **Agents** page in the Coder dashboard.
|
||||
1. Open **Admin** settings and configure at least one LLM provider and model.
|
||||
See [Models](./models.md) for detailed setup instructions.
|
||||
1. Grant the **Coder Agents User** role to users who need to create chats.
|
||||
Go to **Admin** > **Users**, click the roles icon next to each user,
|
||||
and enable **Coder Agents User**.
|
||||
1. Developers can then start a new chat from the Agents page.
|
||||
|
||||
## Licensing and availability
|
||||
|
||||
@@ -24,6 +24,9 @@ Before you begin, confirm the following:
|
||||
for the agent to select when provisioning workspaces.
|
||||
- **Admin access** to the Coder deployment for enabling the experiment and
|
||||
configuring providers.
|
||||
- **Coder Agents User role** assigned to each user who needs to create or use chats.
|
||||
Owners can assign this from **Admin** > **Users**. See
|
||||
[Grant Coder Agents User](#step-3-grant-coder-agents-user) below.
|
||||
|
||||
## Step 1: Enable the experiment
|
||||
|
||||
@@ -69,7 +72,23 @@ Detailed instructions for each provider and model option are in the
|
||||
> Start with a single frontier model to validate your setup before adding
|
||||
> additional providers.
|
||||
|
||||
## Step 3: Start your first chat
|
||||
## Step 3: Grant Coder Agents User
|
||||
|
||||
The **Coder Agents User** role controls which users can create and use chats.
|
||||
Members do not have Coder Agents User by default.
|
||||
|
||||
1. Go to **Admin** > **Users** in the Coder dashboard.
|
||||
1. Click the roles icon next to the user you want to grant access to.
|
||||
1. Enable the **Coder Agents User** role and save.
|
||||
|
||||
Repeat for each user who needs access. Owners always have full access
|
||||
and do not need the role.
|
||||
|
||||
> [!NOTE]
|
||||
> Users who created chats before this role was introduced are
|
||||
> automatically granted the role during upgrade.
|
||||
|
||||
## Step 4: Start your first chat
|
||||
|
||||
1. Go to the **Agents** page in the Coder dashboard.
|
||||
1. Select a model from the dropdown (your default will be pre-selected).
|
||||
|
||||
+1
-2
@@ -324,8 +324,7 @@
|
||||
"title": "Workspace Sharing",
|
||||
"description": "Sharing workspaces",
|
||||
"path": "./user-guides/shared-workspaces.md",
|
||||
"icon_path": "./images/icons/generic.svg",
|
||||
"state": ["beta"]
|
||||
"icon_path": "./images/icons/generic.svg"
|
||||
},
|
||||
{
|
||||
"title": "Workspace Scheduling",
|
||||
|
||||
@@ -11,8 +11,8 @@ RUN cargo install jj-cli typos-cli watchexec-cli
|
||||
FROM ubuntu:jammy@sha256:ce4a593b4e323dcc3dd728e397e0a866a1bf516a1b7c31d6aa06991baec4f2e0 AS go
|
||||
|
||||
# Install Go manually, so that we can control the version
|
||||
ARG GO_VERSION=1.25.7
|
||||
ARG GO_CHECKSUM="12e6d6a191091ae27dc31f6efc630e3a3b8ba409baf3573d955b196fdf086005"
|
||||
ARG GO_VERSION=1.25.8
|
||||
ARG GO_CHECKSUM="ceb5e041bbc3893846bd1614d76cb4681c91dadee579426cf21a63f2d7e03be6"
|
||||
|
||||
# Boring Go is needed to build FIPS-compliant binaries.
|
||||
RUN apt-get update && \
|
||||
|
||||
@@ -776,6 +776,12 @@ func defaultAIBridgeProvider(host string) string {
|
||||
return aibridge.ProviderOpenAI
|
||||
case HostCopilot:
|
||||
return aibridge.ProviderCopilot
|
||||
case agplaibridge.HostCopilotBusiness:
|
||||
return agplaibridge.ProviderCopilotBusiness
|
||||
case agplaibridge.HostCopilotEnterprise:
|
||||
return agplaibridge.ProviderCopilotEnterprise
|
||||
case agplaibridge.HostChatGPT:
|
||||
return agplaibridge.ProviderChatGPT
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/coder/aibridge"
|
||||
"github.com/coder/aibridge/config"
|
||||
agplaibridge "github.com/coder/coder/v2/coderd/aibridge"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/aibridged"
|
||||
@@ -37,20 +38,39 @@ func newAIBridgeDaemon(coderAPI *coderd.API) (*aibridged.Server, error) {
|
||||
// Setup supported providers with circuit breaker config.
|
||||
providers := []aibridge.Provider{
|
||||
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
|
||||
Name: aibridge.ProviderOpenAI,
|
||||
BaseURL: cfg.OpenAI.BaseURL.String(),
|
||||
Key: cfg.OpenAI.Key.String(),
|
||||
CircuitBreaker: cbConfig,
|
||||
SendActorHeaders: cfg.SendActorHeaders.Value(),
|
||||
}),
|
||||
aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{
|
||||
Name: aibridge.ProviderAnthropic,
|
||||
BaseURL: cfg.Anthropic.BaseURL.String(),
|
||||
Key: cfg.Anthropic.Key.String(),
|
||||
CircuitBreaker: cbConfig,
|
||||
SendActorHeaders: cfg.SendActorHeaders.Value(),
|
||||
}, getBedrockConfig(cfg.Bedrock)),
|
||||
aibridge.NewCopilotProvider(aibridge.CopilotConfig{
|
||||
Name: aibridge.ProviderCopilot,
|
||||
CircuitBreaker: cbConfig,
|
||||
}),
|
||||
aibridge.NewCopilotProvider(aibridge.CopilotConfig{
|
||||
Name: agplaibridge.ProviderCopilotBusiness,
|
||||
BaseURL: "https://" + agplaibridge.HostCopilotBusiness,
|
||||
CircuitBreaker: cbConfig,
|
||||
}),
|
||||
aibridge.NewCopilotProvider(aibridge.CopilotConfig{
|
||||
Name: agplaibridge.ProviderCopilotEnterprise,
|
||||
BaseURL: "https://" + agplaibridge.HostCopilotEnterprise,
|
||||
CircuitBreaker: cbConfig,
|
||||
}),
|
||||
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
|
||||
Name: agplaibridge.ProviderChatGPT,
|
||||
BaseURL: agplaibridge.BaseURLChatGPT,
|
||||
CircuitBreaker: cbConfig,
|
||||
SendActorHeaders: cfg.SendActorHeaders.Value(),
|
||||
}),
|
||||
}
|
||||
|
||||
reg := prometheus.WrapRegistererWithPrefix("coder_aibridged_", coderAPI.PrometheusRegistry)
|
||||
|
||||
@@ -440,7 +440,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Client/Unknown",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Client: "Unknown"},
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Client: string(aiblib.ClientUnknown)},
|
||||
want: []codersdk.AIBridgeInterception{i1SDK},
|
||||
},
|
||||
{
|
||||
@@ -1213,6 +1213,302 @@ func TestAIBridgeListSessions(t *testing.T) {
|
||||
require.Contains(t, sdkErr.Message, "Invalid pagination limit value.")
|
||||
require.Empty(t, res.Sessions)
|
||||
})
|
||||
|
||||
t.Run("StartedBeforeFilter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// Session started recently.
|
||||
recentEndedAt := now.Add(time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now,
|
||||
}, &recentEndedAt)
|
||||
|
||||
// Session started 2 hours ago.
|
||||
oldEndedAt := now.Add(-2*time.Hour + time.Minute)
|
||||
old := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now.Add(-2 * time.Hour),
|
||||
}, &oldEndedAt)
|
||||
|
||||
// Only the old session should be returned when started_before
|
||||
// is set to 1 hour ago.
|
||||
//nolint:gocritic // Owner role is irrelevant; testing filter.
|
||||
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
|
||||
StartedBefore: now.Add(-time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, res.Count)
|
||||
require.Len(t, res.Sessions, 1)
|
||||
require.Equal(t, old.ID.String(), res.Sessions[0].ID)
|
||||
})
|
||||
|
||||
t.Run("NullClientCoalescesToUnknown", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// Session with explicit client.
|
||||
withClientEndedAt := now.Add(time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now,
|
||||
Client: sql.NullString{String: "claude-code", Valid: true},
|
||||
}, &withClientEndedAt)
|
||||
|
||||
// Session with NULL client (should COALESCE to ClientUnknown).
|
||||
nullClientEndedAt := now.Add(-time.Hour + time.Minute)
|
||||
nullClient := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now.Add(-time.Hour),
|
||||
// Client field deliberately omitted (NULL).
|
||||
}, &nullClientEndedAt)
|
||||
|
||||
// Filtering by ClientUnknown should return only the NULL-client
|
||||
// session.
|
||||
//nolint:gocritic // Owner role is irrelevant; testing COALESCE.
|
||||
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
|
||||
Client: string(aiblib.ClientUnknown),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, res.Count)
|
||||
require.Len(t, res.Sessions, 1)
|
||||
require.Equal(t, nullClient.ID.String(), res.Sessions[0].ID)
|
||||
})
|
||||
|
||||
t.Run("MetadataFromFirstInterception", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// First interception (chronologically) carries the expected
|
||||
// metadata for the session.
|
||||
i1EndedAt := now.Add(time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now,
|
||||
Metadata: json.RawMessage(`{"editor":"vscode"}`),
|
||||
Client: sql.NullString{String: "claude-code", Valid: true},
|
||||
ClientSessionID: sql.NullString{String: "meta-session", Valid: true},
|
||||
}, &i1EndedAt)
|
||||
|
||||
// Second interception has different metadata.
|
||||
i2EndedAt := now.Add(2 * time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now.Add(time.Minute),
|
||||
Metadata: json.RawMessage(`{"editor":"jetbrains"}`),
|
||||
Client: sql.NullString{String: "claude-code", Valid: true},
|
||||
ClientSessionID: sql.NullString{String: "meta-session", Valid: true},
|
||||
}, &i2EndedAt)
|
||||
|
||||
//nolint:gocritic // Owner role is irrelevant; testing metadata.
|
||||
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Sessions, 1)
|
||||
// Metadata should come from the first interception.
|
||||
require.Equal(t, "vscode", res.Sessions[0].Metadata["editor"])
|
||||
})
|
||||
|
||||
t.Run("SessionTimestamps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// Two interceptions in the same session with different
|
||||
// started_at and ended_at values. The session should report
|
||||
// MIN(started_at) and MAX(ended_at).
|
||||
i1StartedAt := now
|
||||
i1EndedAt := now.Add(time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: i1StartedAt,
|
||||
ClientSessionID: sql.NullString{String: "ts-session", Valid: true},
|
||||
}, &i1EndedAt)
|
||||
|
||||
i2StartedAt := now.Add(2 * time.Minute)
|
||||
i2EndedAt := now.Add(5 * time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: i2StartedAt,
|
||||
ClientSessionID: sql.NullString{String: "ts-session", Valid: true},
|
||||
}, &i2EndedAt)
|
||||
|
||||
//nolint:gocritic // Owner role is irrelevant; testing timestamps.
|
||||
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Sessions, 1)
|
||||
s := res.Sessions[0]
|
||||
require.WithinDuration(t, i1StartedAt, s.StartedAt, time.Millisecond,
|
||||
"session started_at should be MIN of interception started_at values")
|
||||
require.NotNil(t, s.EndedAt)
|
||||
require.WithinDuration(t, i2EndedAt, *s.EndedAt, time.Millisecond,
|
||||
"session ended_at should be MAX of interception ended_at values")
|
||||
})
|
||||
|
||||
t.Run("LastPromptAcrossInterceptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// Two interceptions in the same session.
|
||||
i1EndedAt := now.Add(time.Minute)
|
||||
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now,
|
||||
ClientSessionID: sql.NullString{String: "prompt-session", Valid: true},
|
||||
}, &i1EndedAt)
|
||||
i2EndedAt := now.Add(3 * time.Minute)
|
||||
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now.Add(2 * time.Minute),
|
||||
ClientSessionID: sql.NullString{String: "prompt-session", Valid: true},
|
||||
}, &i2EndedAt)
|
||||
|
||||
// Add prompts to both interceptions. The most recent prompt
|
||||
// overall belongs to the second interception.
|
||||
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
|
||||
InterceptionID: i1.ID,
|
||||
Prompt: "early prompt from i1",
|
||||
CreatedAt: now,
|
||||
})
|
||||
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
|
||||
InterceptionID: i2.ID,
|
||||
Prompt: "latest prompt from i2",
|
||||
CreatedAt: now.Add(2 * time.Minute),
|
||||
})
|
||||
|
||||
//nolint:gocritic // Owner role is irrelevant; testing lateral join.
|
||||
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Sessions, 1)
|
||||
require.NotNil(t, res.Sessions[0].LastPrompt)
|
||||
require.Equal(t, "latest prompt from i2", *res.Sessions[0].LastPrompt,
|
||||
"last_prompt should be the most recent prompt across all interceptions in the session")
|
||||
})
|
||||
|
||||
t.Run("CombinedFilters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, user2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// Session A: user1, anthropic, claude-4, started now.
|
||||
aEndedAt := now.Add(time.Minute)
|
||||
a := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
Provider: "anthropic",
|
||||
Model: "claude-4",
|
||||
StartedAt: now,
|
||||
}, &aEndedAt)
|
||||
|
||||
// Session B: user1, anthropic, gpt-4, started 2h ago.
|
||||
bEndedAt := now.Add(-2*time.Hour + time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
Provider: "anthropic",
|
||||
Model: "gpt-4",
|
||||
StartedAt: now.Add(-2 * time.Hour),
|
||||
}, &bEndedAt)
|
||||
|
||||
// Session C: user2, anthropic, claude-4, started 1h ago.
|
||||
cEndedAt := now.Add(-time.Hour + time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: user2.ID,
|
||||
Provider: "anthropic",
|
||||
Model: "claude-4",
|
||||
StartedAt: now.Add(-time.Hour),
|
||||
}, &cEndedAt)
|
||||
|
||||
// Combining provider + model + started_after should return
|
||||
// only session A (user1, anthropic, claude-4, recent).
|
||||
//nolint:gocritic // Owner role is irrelevant; testing combined filters.
|
||||
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
|
||||
Provider: "anthropic",
|
||||
Model: "claude-4",
|
||||
StartedAfter: now.Add(-30 * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, res.Count)
|
||||
require.Len(t, res.Sessions, 1)
|
||||
require.Equal(t, a.ID.String(), res.Sessions[0].ID)
|
||||
})
|
||||
|
||||
t.Run("CursorPaginationWithTiedStartedAt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
now := dbtime.Now()
|
||||
|
||||
// Create 3 standalone sessions all starting at the same time.
|
||||
// The tie-breaker is session_id DESC.
|
||||
for range 3 {
|
||||
endedAt := now.Add(time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now,
|
||||
}, &endedAt)
|
||||
}
|
||||
|
||||
// Fetch all to learn the sort order (started_at DESC,
|
||||
// session_id DESC).
|
||||
//nolint:gocritic // Owner role is irrelevant; testing cursor.
|
||||
all, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, all.Sessions, 3)
|
||||
|
||||
// Use the first result as cursor. The remaining 2 should be
|
||||
// returned.
|
||||
afterID := all.Sessions[0].ID
|
||||
page, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
|
||||
Pagination: codersdk.Pagination{Limit: 10},
|
||||
AfterSessionID: afterID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page.Sessions, 2)
|
||||
require.Equal(t, all.Sessions[1].ID, page.Sessions[0].ID)
|
||||
require.Equal(t, all.Sessions[2].ID, page.Sessions[1].ID)
|
||||
})
|
||||
|
||||
t.Run("DefaultLimit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
now := dbtime.Now()
|
||||
// Create 3 sessions. Without an explicit limit the default of
|
||||
// 100 should apply and return all 3.
|
||||
for i := range 3 {
|
||||
endedAt := now.Add(-time.Duration(i)*time.Hour + time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now.Add(-time.Duration(i) * time.Hour),
|
||||
}, &endedAt)
|
||||
}
|
||||
|
||||
// No Pagination.Limit set.
|
||||
//nolint:gocritic // Owner role is irrelevant; testing default limit.
|
||||
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Sessions, 3)
|
||||
require.EqualValues(t, 3, res.Count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAIBridgeListClients(t *testing.T) {
|
||||
|
||||
@@ -452,7 +452,13 @@ func TestCustomOrganizationRole(t *testing.T) {
|
||||
func TestListRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.Experiments = []string{string(codersdk.ExperimentAgents)}
|
||||
|
||||
client, owner := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureExternalProvisionerDaemons: 1,
|
||||
@@ -487,6 +493,7 @@ func TestListRoles(t *testing.T) {
|
||||
{Name: codersdk.RoleAuditor}: false,
|
||||
{Name: codersdk.RoleTemplateAdmin}: false,
|
||||
{Name: codersdk.RoleUserAdmin}: false,
|
||||
{Name: codersdk.RoleAgentsAccess}: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -520,6 +527,7 @@ func TestListRoles(t *testing.T) {
|
||||
{Name: codersdk.RoleAuditor}: false,
|
||||
{Name: codersdk.RoleTemplateAdmin}: false,
|
||||
{Name: codersdk.RoleUserAdmin}: false,
|
||||
{Name: codersdk.RoleAgentsAccess}: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -553,6 +561,7 @@ func TestListRoles(t *testing.T) {
|
||||
{Name: codersdk.RoleAuditor}: true,
|
||||
{Name: codersdk.RoleTemplateAdmin}: true,
|
||||
{Name: codersdk.RoleUserAdmin}: true,
|
||||
{Name: codersdk.RoleAgentsAccess}: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/coder/coder/v2
|
||||
|
||||
go 1.25.7
|
||||
go 1.25.8
|
||||
|
||||
// Required until a v3 of chroma is created to lazily initialize all XML files.
|
||||
// None of our dependencies seem to use the registries anyways, so this
|
||||
@@ -483,7 +483,7 @@ require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.19.0
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.0
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
|
||||
github.com/coder/aibridge v1.0.8-0.20260324203533-dd8c239e5566
|
||||
github.com/coder/aibridge v1.1.1-0.20260331154949-a011104f377d
|
||||
github.com/coder/aisdk-go v0.0.9
|
||||
github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab
|
||||
github.com/coder/preview v1.0.8
|
||||
|
||||
@@ -314,8 +314,8 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
|
||||
github.com/coder/aibridge v1.0.8-0.20260324203533-dd8c239e5566 h1:DK+a7Q9bPpTyq7ePaz81Ihauyp1ilXNhF8MI+7rmZpA=
|
||||
github.com/coder/aibridge v1.0.8-0.20260324203533-dd8c239e5566/go.mod h1:u6WvGLMQQbk3ByeOw+LBdVgDNc/v/ujAtUc6MfvzQb4=
|
||||
github.com/coder/aibridge v1.1.1-0.20260331154949-a011104f377d h1:yoDGndlvKP6fiKzivG7kYLYs7jDEt2phgGVagDmuAHY=
|
||||
github.com/coder/aibridge v1.1.1-0.20260331154949-a011104f377d/go.mod h1:u6WvGLMQQbk3ByeOw+LBdVgDNc/v/ujAtUc6MfvzQb4=
|
||||
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
|
||||
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
|
||||
github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab h1:HrlxyTmMQpOHfSKzRU1vf5TxrmV6vL5OiWq+Dvn5qh0=
|
||||
|
||||
@@ -118,5 +118,9 @@
|
||||
"viewOAuth2AppSecrets": {
|
||||
"object": { "resource_type": "oauth2_app_secret" },
|
||||
"action": "read"
|
||||
},
|
||||
"createChat": {
|
||||
"object": { "resource_type": "chat", "owner_id": "me" },
|
||||
"action": "create"
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -571,9 +571,16 @@ func init() {
|
||||
func (h *Handler) renderPermissions(ctx context.Context, actor rbac.Subject) string {
|
||||
response := make(codersdk.AuthorizationResponse)
|
||||
for k, v := range permissionChecks {
|
||||
// Resolve the "me" sentinel so permission checks
|
||||
// run against the actual actor, matching the
|
||||
// API-side handling in coderd/authorize.go.
|
||||
ownerID := v.Object.OwnerID
|
||||
if ownerID == codersdk.Me {
|
||||
ownerID = actor.ID
|
||||
}
|
||||
obj := rbac.Object{
|
||||
ID: v.Object.ResourceID,
|
||||
Owner: v.Object.OwnerID,
|
||||
Owner: ownerID,
|
||||
OrgID: v.Object.OrganizationID,
|
||||
AnyOrgOwner: v.Object.AnyOrgOwner,
|
||||
Type: string(v.Object.ResourceType),
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/maps"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/telemetry"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/site"
|
||||
@@ -79,6 +81,74 @@ func TestInjection(t *testing.T) {
|
||||
require.Equal(t, db2sdk.User(user, []uuid.UUID{}), got)
|
||||
}
|
||||
|
||||
func TestRenderPermissionsResolvesMe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// GIVEN: a site handler wired to a real RBAC authorizer and a
|
||||
// template that renders only the SSR permissions JSON.
|
||||
siteFS := fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("{{ .Permissions }}"),
|
||||
},
|
||||
}
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
||||
|
||||
handler, err := site.New(&site.Options{
|
||||
Telemetry: telemetry.NewNoop(),
|
||||
Database: db,
|
||||
SiteFS: siteFS,
|
||||
Authorizer: authorizer,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// GIVEN: a user with the agents-access role.
|
||||
userWithRole := dbgen.User(t, db, database.User{
|
||||
RBACRoles: []string{"agents-access"},
|
||||
})
|
||||
_, tokenWithRole := dbgen.APIKey(t, db, database.APIKey{
|
||||
UserID: userWithRole.ID,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
|
||||
// WHEN: the user loads the page.
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, tokenWithRole)
|
||||
rw := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rw, r)
|
||||
require.Equal(t, http.StatusOK, rw.Code)
|
||||
|
||||
// THEN: the SSR-rendered permissions include createChat = true
|
||||
// because the "me" sentinel in permissions.json was resolved to
|
||||
// the actor's ID, and the agents-access role grants user-scoped
|
||||
// chat create permission.
|
||||
var permsWithRole codersdk.AuthorizationResponse
|
||||
err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithRole)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, permsWithRole["createChat"], "user with agents-access role should have createChat = true")
|
||||
|
||||
// GIVEN: a user without the agents-access role.
|
||||
userWithoutRole := dbgen.User(t, db, database.User{})
|
||||
_, tokenWithoutRole := dbgen.APIKey(t, db, database.APIKey{
|
||||
UserID: userWithoutRole.ID,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
|
||||
// WHEN: the user loads the page.
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, tokenWithoutRole)
|
||||
rw = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rw, r)
|
||||
require.Equal(t, http.StatusOK, rw.Code)
|
||||
|
||||
// THEN: createChat = false because the member role does not
|
||||
// grant chat permissions.
|
||||
var permsWithoutRole codersdk.AuthorizationResponse
|
||||
err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithoutRole)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, permsWithoutRole["createChat"], "user without agents-access role should have createChat = false")
|
||||
}
|
||||
|
||||
func TestInjectionFailureProducesCleanHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { type AxiosError, type AxiosResponse, isAxiosError } from "axios";
|
||||
|
||||
const Language = {
|
||||
errorsByCode: {
|
||||
defaultErrorCode: "Invalid value",
|
||||
},
|
||||
};
|
||||
|
||||
export interface FieldError {
|
||||
field: string;
|
||||
detail: string;
|
||||
@@ -64,8 +58,7 @@ export const mapApiErrorToFieldErrors = (
|
||||
|
||||
if (apiErrorResponse.validations) {
|
||||
for (const error of apiErrorResponse.validations) {
|
||||
result[error.field] =
|
||||
error.detail || Language.errorsByCode.defaultErrorCode;
|
||||
result[error.field] = error.detail || "Invalid value";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+17
-11
@@ -5925,56 +5925,62 @@ export interface Role {
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleAgentsAccess = "agents-access";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleAuditor = "auditor";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleMember = "member";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleOrganizationAdmin = "organization-admin";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleOrganizationAuditor = "organization-auditor";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleOrganizationMember = "organization-member";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleOrganizationTemplateAdmin = "organization-template-admin";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleOrganizationUserAdmin = "organization-user-admin";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleOrganizationWorkspaceCreationBan =
|
||||
"organization-workspace-creation-ban";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleOwner = "owner";
|
||||
|
||||
@@ -5993,13 +5999,13 @@ export interface RoleSyncSettings {
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleTemplateAdmin = "template-admin";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally this roles would be generated from the rbac/roles.go package.
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleUserAdmin = "user-admin";
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
ChartTooltipContent,
|
||||
} from "#/components/Chart/Chart";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipContent,
|
||||
HelpTooltipIconTrigger,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "#/components/HelpTooltip/HelpTooltip";
|
||||
HelpPopover,
|
||||
HelpPopoverContent,
|
||||
HelpPopoverIconTrigger,
|
||||
HelpPopoverText,
|
||||
HelpPopoverTitle,
|
||||
} from "#/components/HelpPopover/HelpPopover";
|
||||
import { formatDate } from "#/utils/time";
|
||||
|
||||
const chartConfig = {
|
||||
@@ -120,18 +120,18 @@ export const ActiveUsersTitle: FC<ActiveUsersTitleProps> = ({ interval }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{interval === "day" ? "Daily" : "Weekly"} Active Users
|
||||
<HelpTooltip>
|
||||
<HelpTooltipIconTrigger size="small" />
|
||||
<HelpTooltipContent>
|
||||
<HelpTooltipTitle>How do we calculate active users?</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
<HelpPopover>
|
||||
<HelpPopoverIconTrigger size="small" />
|
||||
<HelpPopoverContent>
|
||||
<HelpPopoverTitle>How do we calculate active users?</HelpPopoverTitle>
|
||||
<HelpPopoverText>
|
||||
When a connection is initiated to a user's workspace they are
|
||||
considered an active user. e.g. apps, web terminal, SSH. This is for
|
||||
measuring user activity and has no connection to license
|
||||
consumption.
|
||||
</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
</HelpPopoverText>
|
||||
</HelpPopoverContent>
|
||||
</HelpPopover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export const AvatarCard: FC<AvatarCardProps> = ({
|
||||
*
|
||||
* @see {@link https://css-tricks.com/flexbox-truncated-text/}
|
||||
*/}
|
||||
<div css={{ marginRight: "auto", minWidth: 0 }}>
|
||||
<div className="mr-auto min-w-0">
|
||||
<h3
|
||||
// Lets users hover over truncated text to see whole thing
|
||||
title={header}
|
||||
|
||||
@@ -75,12 +75,7 @@ export const DeprecatedBadge: React.FC = () => {
|
||||
|
||||
export const Badges: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<Stack
|
||||
css={{ margin: "0 0 16px" }}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
>
|
||||
<Stack className="mb-4" direction="row" alignItems="center" spacing={1}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import type { FC } from "react";
|
||||
import { CoderIcon } from "#/components/Icons/CoderIcon";
|
||||
import { getApplicationName, getLogoURL } from "#/utils/appearance";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
/**
|
||||
* Enterprise customers can set a custom logo for their Coder application. Use
|
||||
* the custom logo wherever the Coder logo is used, if a custom one is provided.
|
||||
*/
|
||||
export const CustomLogo: FC<{ css?: Interpolation<Theme> }> = (props) => {
|
||||
export const CustomLogo: FC<{ className?: string }> = ({ className }) => {
|
||||
const applicationName = getApplicationName();
|
||||
const logoURL = getLogoURL();
|
||||
|
||||
return logoURL ? (
|
||||
<img
|
||||
{...props}
|
||||
alt={applicationName}
|
||||
src={logoURL}
|
||||
// This prevent browser to display the ugly error icon if the
|
||||
@@ -24,10 +23,9 @@ export const CustomLogo: FC<{ css?: Interpolation<Theme> }> = (props) => {
|
||||
onLoad={(e) => {
|
||||
e.currentTarget.style.display = "inline";
|
||||
}}
|
||||
css={{ maxWidth: 200 }}
|
||||
className="application-logo"
|
||||
className={cn("max-w-[200px] application-logo", className)}
|
||||
/>
|
||||
) : (
|
||||
<CoderIcon {...props} className="w-12 h-12" />
|
||||
<CoderIcon className={cn("w-12 h-12", className)} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,7 +78,7 @@ export const DeleteDialog: FC<DeleteDialogProps> = ({
|
||||
<TextField
|
||||
fullWidth
|
||||
autoFocus
|
||||
css={{ marginTop: 24 }}
|
||||
className="mt-6"
|
||||
name="confirmation"
|
||||
autoComplete="off"
|
||||
id={`${hookId}-confirm`}
|
||||
|
||||
@@ -77,12 +77,7 @@ export const DurationField: FC<DurationFieldProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
fullWidth
|
||||
|
||||
@@ -146,7 +146,7 @@ const BaseSkeleton: FC<SkeletonProps> = ({ children, ...skeletonProps }) => {
|
||||
};
|
||||
|
||||
export const MenuSkeleton: FC = () => {
|
||||
return <BaseSkeleton css={{ minWidth: 200, flexShrink: 0 }} />;
|
||||
return <BaseSkeleton className="min-w-[200px] shrink-0" />;
|
||||
};
|
||||
|
||||
type FilterProps = {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
HelpPopover,
|
||||
HelpPopoverLink,
|
||||
HelpPopoverLinksGroup,
|
||||
HelpPopoverText,
|
||||
HelpPopoverTitle,
|
||||
} from "./HelpPopover";
|
||||
|
||||
const meta: Meta<typeof HelpPopover> = {
|
||||
title: "components/HelpPopover",
|
||||
component: HelpPopover,
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<HelpPopoverTitle>What is a template?</HelpPopoverTitle>
|
||||
<HelpPopoverText>
|
||||
A template is a common configuration for your team's workspaces.
|
||||
</HelpPopoverText>
|
||||
<HelpPopoverLinksGroup>
|
||||
<HelpPopoverLink href="https://github.com/coder/coder/">
|
||||
Creating a template
|
||||
</HelpPopoverLink>
|
||||
<HelpPopoverLink href="https://github.com/coder/coder/">
|
||||
Updating a template
|
||||
</HelpPopoverLink>
|
||||
</HelpPopoverLinksGroup>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof HelpPopover>;
|
||||
|
||||
export const Example: Story = {};
|
||||
+23
-25
@@ -1,32 +1,29 @@
|
||||
import { CircleHelpIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import type { FC, HTMLAttributes, PropsWithChildren, ReactNode } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
type TooltipContentProps,
|
||||
type TooltipProps,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
type PopoverContentProps,
|
||||
PopoverTrigger,
|
||||
} from "#/components/Popover/Popover";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
type Icon = typeof CircleHelpIcon;
|
||||
|
||||
type Size = "small" | "medium";
|
||||
|
||||
export const HelpTooltipTrigger = TooltipTrigger;
|
||||
export const HelpPopoverTrigger = PopoverTrigger;
|
||||
|
||||
export const HelpTooltipIcon = CircleHelpIcon;
|
||||
export const HelpPopoverIcon = CircleHelpIcon;
|
||||
|
||||
export const HelpTooltip: FC<TooltipProps> = (props) => {
|
||||
return <Tooltip {...props} />;
|
||||
};
|
||||
export const HelpPopover = Popover;
|
||||
|
||||
export const HelpTooltipContent: FC<TooltipContentProps> = ({
|
||||
export const HelpPopoverContent: FC<PopoverContentProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<TooltipContent
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
collisionPadding={16}
|
||||
@@ -39,22 +36,23 @@ export const HelpTooltipContent: FC<TooltipContentProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
type HelpTooltipIconTriggerProps = React.ComponentPropsWithRef<"button"> & {
|
||||
type HelpPopoverIconTriggerProps = React.ComponentPropsWithRef<"button"> & {
|
||||
size?: Size;
|
||||
hoverEffect?: boolean;
|
||||
};
|
||||
|
||||
export const HelpTooltipIconTrigger: React.FC<HelpTooltipIconTriggerProps> = ({
|
||||
export const HelpPopoverIconTrigger: React.FC<HelpPopoverIconTriggerProps> = ({
|
||||
size = "medium",
|
||||
children = <HelpTooltipIcon />,
|
||||
children = <HelpPopoverIcon />,
|
||||
hoverEffect = true,
|
||||
className,
|
||||
...buttonProps
|
||||
}) => {
|
||||
return (
|
||||
<HelpTooltipTrigger asChild>
|
||||
<HelpPopoverTrigger asChild>
|
||||
<button
|
||||
{...buttonProps}
|
||||
type="button"
|
||||
aria-label="More info"
|
||||
className={cn(
|
||||
"flex items-center justify-center px-0 py-1",
|
||||
@@ -66,11 +64,11 @@ export const HelpTooltipIconTrigger: React.FC<HelpTooltipIconTriggerProps> = ({
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</HelpTooltipTrigger>
|
||||
</HelpPopoverTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export const HelpTooltipTitle: FC<HTMLAttributes<HTMLHeadingElement>> = ({
|
||||
export const HelpPopoverTitle: FC<HTMLAttributes<HTMLHeadingElement>> = ({
|
||||
children,
|
||||
className,
|
||||
...attrs
|
||||
@@ -88,7 +86,7 @@ export const HelpTooltipTitle: FC<HTMLAttributes<HTMLHeadingElement>> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const HelpTooltipText: FC<HTMLAttributes<HTMLParagraphElement>> = ({
|
||||
export const HelpPopoverText: FC<HTMLAttributes<HTMLParagraphElement>> = ({
|
||||
children,
|
||||
className,
|
||||
...attrs
|
||||
@@ -106,12 +104,12 @@ export const HelpTooltipText: FC<HTMLAttributes<HTMLParagraphElement>> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface HelpTooltipLink {
|
||||
interface HelpPopoverLink {
|
||||
children?: ReactNode;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const HelpTooltipLink: FC<HelpTooltipLink> = ({ children, href }) => {
|
||||
export const HelpPopoverLink: FC<HelpPopoverLink> = ({ children, href }) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@@ -125,14 +123,14 @@ export const HelpTooltipLink: FC<HelpTooltipLink> = ({ children, href }) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface HelpTooltipActionProps {
|
||||
interface HelpPopoverActionProps {
|
||||
children?: ReactNode;
|
||||
icon: Icon;
|
||||
onClick: () => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const HelpTooltipAction: FC<HelpTooltipActionProps> = ({
|
||||
export const HelpPopoverAction: FC<HelpPopoverActionProps> = ({
|
||||
children,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
@@ -151,6 +149,6 @@ export const HelpTooltipAction: FC<HelpTooltipActionProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const HelpTooltipLinksGroup: FC<PropsWithChildren> = ({ children }) => {
|
||||
export const HelpPopoverLinksGroup: FC<PropsWithChildren> = ({ children }) => {
|
||||
return <div className="flex flex-col gap-2 mt-4">{children}</div>;
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipLink,
|
||||
HelpTooltipLinksGroup,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "./HelpTooltip";
|
||||
|
||||
const meta: Meta<typeof HelpTooltip> = {
|
||||
title: "components/HelpTooltip",
|
||||
component: HelpTooltip,
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<HelpTooltipTitle>What is a template?</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
A template is a common configuration for your team's workspaces.
|
||||
</HelpTooltipText>
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipLink href="https://github.com/coder/coder/">
|
||||
Creating a template
|
||||
</HelpTooltipLink>
|
||||
<HelpTooltipLink href="https://github.com/coder/coder/">
|
||||
Updating a template
|
||||
</HelpTooltipLink>
|
||||
</HelpTooltipLinksGroup>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof HelpTooltip>;
|
||||
|
||||
const Example: Story = {};
|
||||
|
||||
export { Example as HelpTooltip };
|
||||
@@ -43,18 +43,7 @@ export const IconField: FC<IconFieldProps> = ({
|
||||
endAdornment: hasIcon ? (
|
||||
<InputAdornment
|
||||
position="end"
|
||||
css={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
"& img": {
|
||||
maxWidth: "100%",
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
className="w-6 h-6 flex items-center justify-center [&_img]:max-w-full [&_img]:object-contain"
|
||||
>
|
||||
<ExternalImage
|
||||
alt=""
|
||||
|
||||
@@ -24,7 +24,7 @@ export const JetBrainsIcon = (props: SvgIconProps): JSX.Element => (
|
||||
25.59l85.73-58.84a7.35 7.35 0 0 0 3.17-6.05z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path d="m60 60h60v60h-60z" css={{ fill: "#000 !important" }} />
|
||||
<path d="m60 60h60v60h-60z" className="![fill:#000]" />
|
||||
<g fill="#fff">
|
||||
<path d="m66.53 108.75h22.5v3.75h-22.5z" />
|
||||
<path
|
||||
|
||||
@@ -18,11 +18,9 @@ type Story = StoryObj<typeof InfoTooltip>;
|
||||
export const Example: Story = {
|
||||
play: async ({ step }) => {
|
||||
await step("activate hover trigger", async () => {
|
||||
await userEvent.hover(screen.getByRole("button"));
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
meta.args.message,
|
||||
),
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent(meta.args.message),
|
||||
);
|
||||
});
|
||||
},
|
||||
@@ -35,9 +33,9 @@ export const Notice = {
|
||||
},
|
||||
play: async ({ step }) => {
|
||||
await step("activate hover trigger", async () => {
|
||||
await userEvent.hover(screen.getByRole("button"));
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent(
|
||||
Notice.args.message,
|
||||
),
|
||||
);
|
||||
@@ -52,9 +50,9 @@ export const Warning = {
|
||||
},
|
||||
play: async ({ step }) => {
|
||||
await step("activate hover trigger", async () => {
|
||||
await userEvent.hover(screen.getByRole("button"));
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent(
|
||||
Warning.args.message,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipContent,
|
||||
HelpTooltipIcon,
|
||||
HelpTooltipIconTrigger,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "#/components/HelpTooltip/HelpTooltip";
|
||||
HelpPopover,
|
||||
HelpPopoverContent,
|
||||
HelpPopoverIcon,
|
||||
HelpPopoverIconTrigger,
|
||||
HelpPopoverText,
|
||||
HelpPopoverTitle,
|
||||
} from "#/components/HelpPopover/HelpPopover";
|
||||
import type { ThemeRole } from "#/theme/roles";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
@@ -34,14 +34,14 @@ export const InfoTooltip: FC<InfoTooltipProps> = ({
|
||||
type = "info",
|
||||
}) => {
|
||||
return (
|
||||
<HelpTooltip>
|
||||
<HelpTooltipIconTrigger size="small" hoverEffect={false}>
|
||||
<HelpTooltipIcon className={cn(tooltipColorClasses[type])} />
|
||||
</HelpTooltipIconTrigger>
|
||||
<HelpTooltipContent>
|
||||
{title && <HelpTooltipTitle>{title}</HelpTooltipTitle>}
|
||||
<HelpTooltipText>{message}</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
<HelpPopover>
|
||||
<HelpPopoverIconTrigger size="small" hoverEffect={false}>
|
||||
<HelpPopoverIcon className={cn(tooltipColorClasses[type])} />
|
||||
</HelpPopoverIconTrigger>
|
||||
<HelpPopoverContent>
|
||||
{title && <HelpPopoverTitle>{title}</HelpPopoverTitle>}
|
||||
<HelpPopoverText>{message}</HelpPopoverText>
|
||||
</HelpPopoverContent>
|
||||
</HelpPopover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const Logs: FC<LogsProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div css={styles.root} className={`${className} logs-container`}>
|
||||
<div css={{ minWidth: "fit-content" }}>
|
||||
<div className="min-w-fit">
|
||||
{lines.map((line) => (
|
||||
<LogLine key={line.id} level={line.level}>
|
||||
{!hideTimestamps && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
containerWidthMedium,
|
||||
sidePadding,
|
||||
} from "#/theme/constants";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
type Size = "regular" | "medium" | "small";
|
||||
|
||||
@@ -20,20 +21,19 @@ type MarginsProps = JSX.IntrinsicElements["div"] & {
|
||||
export const Margins: FC<MarginsProps> = ({
|
||||
size = "regular",
|
||||
children,
|
||||
className,
|
||||
...divProps
|
||||
}) => {
|
||||
const maxWidth = widthBySize[size];
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
css={{
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
style={{
|
||||
maxWidth: maxWidth,
|
||||
paddingLeft: sidePadding,
|
||||
paddingRight: sidePadding,
|
||||
width: "100%",
|
||||
}}
|
||||
className={cn("mx-auto w-full", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -14,13 +14,8 @@ const meta: Meta<typeof OverflowY> = {
|
||||
children: numbers.map((num, i) => (
|
||||
<p
|
||||
key={num}
|
||||
css={{
|
||||
height: "50px",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
color: "black",
|
||||
backgroundColor: i % 2 === 0 ? "white" : "gray",
|
||||
}}
|
||||
className="h-[50px] p-0 m-0 text-black"
|
||||
style={{ backgroundColor: i % 2 === 0 ? "white" : "gray" }}
|
||||
>
|
||||
Element {num}
|
||||
</p>
|
||||
|
||||
@@ -27,12 +27,10 @@ export const OverflowY: FC<OverflowYProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
width: "100%",
|
||||
className="w-full overflow-y-auto shrink"
|
||||
style={{
|
||||
height: computedHeight,
|
||||
maxHeight: computedMaxHeight,
|
||||
overflowY: "auto",
|
||||
flexShrink: 1,
|
||||
}}
|
||||
{...attrs}
|
||||
>
|
||||
|
||||
@@ -63,18 +63,7 @@ const _PageHeaderActions: FC<PropsWithChildren> = ({ children }) => {
|
||||
};
|
||||
|
||||
export const PageHeaderTitle: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<h1
|
||||
css={{
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
margin: 0,
|
||||
lineHeight: "24px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
return <h1 className="text-lg font-medium m-0 leading-6">{children}</h1>;
|
||||
};
|
||||
|
||||
export const PageHeaderSubtitle: FC<PropsWithChildren> = ({ children }) => {
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
export type PopoverContentProps = PopoverPrimitive.PopoverContentProps;
|
||||
export type PopoverContentProps = React.ComponentPropsWithRef<
|
||||
typeof PopoverPrimitive.Content
|
||||
> & {
|
||||
disablePortal?: boolean;
|
||||
};
|
||||
|
||||
export type PopoverTriggerProps = PopoverPrimitive.PopoverTriggerProps;
|
||||
|
||||
@@ -13,28 +17,36 @@ export const Popover = PopoverPrimitive.Root;
|
||||
|
||||
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
export const PopoverContent: React.FC<
|
||||
React.ComponentPropsWithRef<typeof PopoverPrimitive.Content>
|
||||
> = ({ className, align = "center", sideOffset = 4, ...props }) => {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={16}
|
||||
className={cn(
|
||||
`z-50 w-72 rounded-md border border-solid bg-surface-primary
|
||||
text-content-primary shadow-md outline-none
|
||||
max-h-[var(--radix-popper-available-height)] overflow-y-auto
|
||||
data-[state=open]:animate-in data-[state=closed]:animate-out
|
||||
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
||||
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
|
||||
data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2
|
||||
data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
export const PopoverContent: React.FC<PopoverContentProps> = ({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
disablePortal,
|
||||
...props
|
||||
}) => {
|
||||
const content = (
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={16}
|
||||
className={cn(
|
||||
`z-50 w-72 rounded-md border border-solid bg-surface-primary
|
||||
text-content-primary shadow-md outline-none
|
||||
max-h-[var(--radix-popper-available-height)] overflow-y-auto
|
||||
data-[state=open]:animate-in data-[state=closed]:animate-out
|
||||
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
||||
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
|
||||
data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2
|
||||
data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return disablePortal ? (
|
||||
content
|
||||
) : (
|
||||
<PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -242,7 +242,7 @@ export const RichParameterInput: FC<RichParameterInputProps> = ({
|
||||
data-testid={`parameter-field-${parameter.name}`}
|
||||
>
|
||||
<ParameterLabel parameter={parameter} isPreset={isPreset} />
|
||||
<div css={{ display: "flex", flexDirection: "column" }}>
|
||||
<div className="flex flex-col">
|
||||
<RichParameterField
|
||||
{...fieldProps}
|
||||
onChange={onChange}
|
||||
@@ -271,7 +271,7 @@ export const RichParameterInput: FC<RichParameterInputProps> = ({
|
||||
</FormHelperText>
|
||||
)}
|
||||
{autofillSource && autofillDescription[autofillSource] && (
|
||||
<div css={{ marginTop: 4, fontSize: 12 }}>
|
||||
<div className="mt-1 text-xs">
|
||||
🪄 Autofilled {autofillDescription[autofillSource]}
|
||||
</div>
|
||||
)}
|
||||
@@ -345,7 +345,7 @@ const RichParameterField: FC<RichParameterInputProps> = ({
|
||||
spacing={small ? 1 : 0}
|
||||
alignItems={small ? "center" : undefined}
|
||||
direction={small ? "row" : "column"}
|
||||
css={{ padding: small ? undefined : "4px 0" }}
|
||||
className={small ? undefined : "py-1"}
|
||||
>
|
||||
{small ? (
|
||||
<Tooltip>
|
||||
|
||||
@@ -33,13 +33,7 @@ export const SidebarHeader: FC<SidebarHeaderProps> = ({
|
||||
return (
|
||||
<Stack direction="row" spacing={1} className="mb-4">
|
||||
{avatar}
|
||||
<div
|
||||
css={{
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden flex flex-col">
|
||||
{linkTo ? (
|
||||
<Link className={cn(titleStyles.normal, "no-underline")} to={linkTo}>
|
||||
{title}
|
||||
|
||||
@@ -7,15 +7,13 @@ import { cn } from "#/utils/cn";
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
export type TooltipProps = TooltipPrimitive.TooltipProps;
|
||||
|
||||
export const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export const TooltipArrow = TooltipPrimitive.Arrow;
|
||||
|
||||
export type TooltipContentProps = React.ComponentPropsWithRef<
|
||||
type TooltipContentProps = React.ComponentPropsWithRef<
|
||||
typeof TooltipPrimitive.Content
|
||||
> & {
|
||||
disablePortal?: boolean;
|
||||
|
||||
@@ -36,11 +36,10 @@ export const AppStatusStateIcon: FC<AppStatusStateIconProps> = ({
|
||||
// remove the stroke so it is not overly thick.
|
||||
return (
|
||||
<PauseIcon
|
||||
css={{ strokeWidth: 0 }}
|
||||
className={cn([
|
||||
"text-content-secondary",
|
||||
className,
|
||||
"text-content-secondary stroke-0",
|
||||
disabled ? "fill-content-disabled" : "fill-content-secondary",
|
||||
className,
|
||||
])}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -79,7 +79,7 @@ export const DashboardLayout: FC = () => {
|
||||
}),
|
||||
}}
|
||||
message={
|
||||
<div css={{ display: "flex", gap: 16 }}>
|
||||
<div className="flex gap-4">
|
||||
<InfoIcon
|
||||
className="size-icon-xs"
|
||||
css={(theme) => ({
|
||||
@@ -114,16 +114,7 @@ export const DashboardFullPage: FC<HTMLAttributes<HTMLDivElement>> = ({
|
||||
...attrs
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
{...attrs}
|
||||
css={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexBasis: 0,
|
||||
minHeight: "100%",
|
||||
}}
|
||||
>
|
||||
<div {...attrs} className="flex-1 flex flex-col basis-0 min-h-full">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ import type {
|
||||
WorkspaceStatus,
|
||||
} from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { HelpTooltipTitle } from "#/components/HelpTooltip/HelpTooltip";
|
||||
import { HelpPopoverTitle } from "#/components/HelpPopover/HelpPopover";
|
||||
import { JetBrainsIcon } from "#/components/Icons/JetBrainsIcon";
|
||||
import { RocketIcon } from "#/components/Icons/RocketIcon";
|
||||
import { TerminalIcon } from "#/components/Icons/TerminalIcon";
|
||||
@@ -137,9 +137,9 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
|
||||
>
|
||||
{healthErrors.length > 0 ? (
|
||||
<>
|
||||
<HelpTooltipTitle>
|
||||
<HelpPopoverTitle>
|
||||
We have detected problems with your Coder deployment.
|
||||
</HelpTooltipTitle>
|
||||
</HelpPopoverTitle>
|
||||
<div className="flex flex-col gap-1">
|
||||
{healthErrors.map((error) => (
|
||||
<HealthIssue key={error}>{error}</HealthIssue>
|
||||
|
||||
@@ -64,7 +64,7 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
|
||||
<Skeleton
|
||||
width="110px"
|
||||
height={40}
|
||||
css={{ borderRadius: 6, transform: "none" }}
|
||||
className="rounded-[6px] transform-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { MockUserOwner } from "#/testHelpers/entities";
|
||||
import { render, waitForLoaderToBeRemoved } from "#/testHelpers/renderHelpers";
|
||||
import { Language, UserDropdownContent } from "./UserDropdownContent";
|
||||
import { UserDropdownContent } from "./UserDropdownContent";
|
||||
|
||||
const renderUserDropdownContent = (props: { onSignOut: () => void }) => {
|
||||
return render(
|
||||
@@ -28,7 +28,7 @@ describe("UserDropdownContent", () => {
|
||||
renderUserDropdownContent({ onSignOut: vi.fn() });
|
||||
await waitForLoaderToBeRemoved();
|
||||
|
||||
const link = screen.getByText(Language.accountLabel).closest("a");
|
||||
const link = screen.getByText("Account").closest("a");
|
||||
if (!link) {
|
||||
throw new Error("Anchor tag not found for the account menu item");
|
||||
}
|
||||
@@ -40,7 +40,7 @@ describe("UserDropdownContent", () => {
|
||||
const onSignOut = vi.fn();
|
||||
renderUserDropdownContent({ onSignOut });
|
||||
await waitForLoaderToBeRemoved();
|
||||
screen.getByText(Language.signOutLabel).click();
|
||||
screen.getByText("Sign Out").click();
|
||||
expect(onSignOut).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,12 +21,6 @@ import {
|
||||
import { useClipboard } from "#/hooks/useClipboard";
|
||||
import { SupportIcon } from "../SupportIcon";
|
||||
|
||||
export const Language = {
|
||||
accountLabel: "Account",
|
||||
signOutLabel: "Sign Out",
|
||||
copyrightText: `\u00a9 ${new Date().getFullYear()} Coder Technologies, Inc.`,
|
||||
};
|
||||
|
||||
interface UserDropdownContentProps {
|
||||
user: TypesGen.User;
|
||||
buildInfo?: TypesGen.BuildInfoResponse;
|
||||
@@ -126,7 +120,7 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenuItem className="text-xs" disabled>
|
||||
<span>{Language.copyrightText}</span>
|
||||
<span>© {new Date().getFullYear()} Coder Technologies, Inc.</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,7 @@ const DeploymentSettingsLayout: FC = () => {
|
||||
<section className="px-10 max-w-screen-2xl mx-auto">
|
||||
<div className="flex flex-row gap-28 py-10">
|
||||
<DeploymentSidebar />
|
||||
<div css={{ flexGrow: 1 }}>
|
||||
<div className="grow">
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
|
||||
@@ -45,47 +45,20 @@ export const Provisioner: FC<ProvisionerProps> = ({
|
||||
isWarning && { borderColor: theme.palette.warning.light },
|
||||
]}
|
||||
>
|
||||
<header
|
||||
css={{
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 24,
|
||||
objectFit: "fill",
|
||||
}}
|
||||
>
|
||||
<div css={{ lineHeight: "160%" }}>
|
||||
<h4 css={{ fontWeight: 500, margin: 0 }}>{provisioner.name}</h4>
|
||||
<header className="p-6 flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-6 object-fill">
|
||||
<div className="leading-[160%]">
|
||||
<h4 className="font-medium m-0">{provisioner.name}</h4>
|
||||
<span css={{ color: theme.palette.text.secondary }}>
|
||||
<code>{provisioner.version}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
css={{
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
justifyContent: "right",
|
||||
}}
|
||||
>
|
||||
<div className="ml-auto flex flex-wrap gap-3 justify-end">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Pill size="lg" icon={iconScope}>
|
||||
<span
|
||||
css={{
|
||||
":first-letter": { textTransform: "uppercase" },
|
||||
}}
|
||||
>
|
||||
<span className="[&::first-letter]:uppercase">
|
||||
{daemonScope}
|
||||
</span>
|
||||
</Pill>
|
||||
@@ -110,7 +83,7 @@ export const Provisioner: FC<ProvisionerProps> = ({
|
||||
}}
|
||||
>
|
||||
{warnings && warnings.length > 0 ? (
|
||||
<div css={{ display: "flex", flexDirection: "column" }}>
|
||||
<div className="flex flex-col">
|
||||
{warnings.map((warning) => (
|
||||
<span key={warning.code}>{warning.message}</span>
|
||||
))}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ProvisionerTag: FC<ProvisionerTagProps> = ({
|
||||
const { valid, value: boolValue } = parseBool(tagValue);
|
||||
const kv = (
|
||||
<>
|
||||
<span css={{ fontWeight: 600 }}>{tagName}</span> <span>{tagValue}</span>
|
||||
<span className="font-semibold">{tagName}</span> <span>{tagValue}</span>
|
||||
</>
|
||||
);
|
||||
const content = onDelete ? (
|
||||
|
||||
@@ -211,8 +211,8 @@ export const TerraformManagedDirty: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const outdatedStatus = canvas.getByText("Outdated");
|
||||
await userEvent.hover(outdatedStatus);
|
||||
await screen.findByRole("tooltip");
|
||||
await userEvent.click(outdatedStatus);
|
||||
await screen.findByRole("dialog");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { FC } from "react";
|
||||
import type { DERPRegion, WorkspaceAgent } from "#/api/typesGenerated";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipContent,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
HelpTooltipTrigger,
|
||||
} from "#/components/HelpTooltip/HelpTooltip";
|
||||
HelpPopover,
|
||||
HelpPopoverContent,
|
||||
HelpPopoverText,
|
||||
HelpPopoverTitle,
|
||||
HelpPopoverTrigger,
|
||||
} from "#/components/HelpPopover/HelpPopover";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { getLatencyColor } from "#/utils/latency";
|
||||
|
||||
@@ -41,8 +41,8 @@ export const AgentLatency: FC<AgentLatencyProps> = ({ agent }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<HelpTooltip>
|
||||
<HelpTooltipTrigger asChild>
|
||||
<HelpPopover>
|
||||
<HelpPopoverTrigger asChild>
|
||||
<span
|
||||
role="presentation"
|
||||
aria-label="latency"
|
||||
@@ -50,13 +50,13 @@ export const AgentLatency: FC<AgentLatencyProps> = ({ agent }) => {
|
||||
>
|
||||
{Math.round(latency.latency_ms)}ms
|
||||
</span>
|
||||
</HelpTooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
<HelpTooltipTitle>Latency</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
</HelpPopoverTrigger>
|
||||
<HelpPopoverContent>
|
||||
<HelpPopoverTitle>Latency</HelpPopoverTitle>
|
||||
<HelpPopoverText>
|
||||
This is the latency overhead on non peer to peer connections. The
|
||||
first row is the preferred relay.
|
||||
</HelpTooltipText>
|
||||
</HelpPopoverText>
|
||||
<div className="flex-col gap-1 mt-4">
|
||||
{Object.entries(agent.latency)
|
||||
.sort(([, a], [, b]) => a.latency_ms - b.latency_ms)
|
||||
@@ -73,7 +73,7 @@ export const AgentLatency: FC<AgentLatencyProps> = ({ agent }) => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
</HelpPopoverContent>
|
||||
</HelpPopover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ export const AgentLogLine: FC<AgentLogLineProps> = ({
|
||||
}, [line.output]);
|
||||
|
||||
return (
|
||||
<LogLine css={{ paddingLeft: 16 }} level={line.level} style={style}>
|
||||
<LogLine className="pl-4" level={line.level} style={style}>
|
||||
{sourceIcon}
|
||||
<LogLinePrefix
|
||||
css={styles.number}
|
||||
|
||||
@@ -2,14 +2,14 @@ import { RotateCcwIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { WorkspaceAgent } from "#/api/typesGenerated";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipAction,
|
||||
HelpTooltipContent,
|
||||
HelpTooltipLinksGroup,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
HelpTooltipTrigger,
|
||||
} from "#/components/HelpTooltip/HelpTooltip";
|
||||
HelpPopover,
|
||||
HelpPopoverAction,
|
||||
HelpPopoverContent,
|
||||
HelpPopoverLinksGroup,
|
||||
HelpPopoverText,
|
||||
HelpPopoverTitle,
|
||||
HelpPopoverTrigger,
|
||||
} from "#/components/HelpPopover/HelpPopover";
|
||||
import { Stack } from "#/components/Stack/Stack";
|
||||
import { agentVersionStatus } from "../../utils/workspace";
|
||||
|
||||
@@ -39,17 +39,17 @@ export const AgentOutdatedTooltip: FC<AgentOutdatedTooltipProps> = ({
|
||||
const text = `${opener} This can happen after you update Coder with running workspaces. To fix this, you can stop and start the workspace.`;
|
||||
|
||||
return (
|
||||
<HelpTooltip open={isOpen} onOpenChange={setIsOpen}>
|
||||
<HelpTooltipTrigger asChild>
|
||||
<HelpPopover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<HelpPopoverTrigger asChild>
|
||||
<span role="status" className="cursor-pointer">
|
||||
{status === agentVersionStatus.Outdated ? "Outdated" : "Deprecated"}
|
||||
</span>
|
||||
</HelpTooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
</HelpPopoverTrigger>
|
||||
<HelpPopoverContent>
|
||||
<Stack spacing={1}>
|
||||
<div>
|
||||
<HelpTooltipTitle>{title}</HelpTooltipTitle>
|
||||
<HelpTooltipText>{text}</HelpTooltipText>
|
||||
<HelpPopoverTitle>{title}</HelpPopoverTitle>
|
||||
<HelpPopoverText>{text}</HelpPopoverText>
|
||||
</div>
|
||||
|
||||
<Stack spacing={0.5}>
|
||||
@@ -66,8 +66,8 @@ export const AgentOutdatedTooltip: FC<AgentOutdatedTooltipProps> = ({
|
||||
<span>{serverVersion}</span>
|
||||
</Stack>
|
||||
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipAction
|
||||
<HelpPopoverLinksGroup>
|
||||
<HelpPopoverAction
|
||||
icon={RotateCcwIcon}
|
||||
onClick={() => {
|
||||
onUpdate();
|
||||
@@ -76,10 +76,10 @@ export const AgentOutdatedTooltip: FC<AgentOutdatedTooltipProps> = ({
|
||||
ariaLabel="Update workspace"
|
||||
>
|
||||
Update workspace
|
||||
</HelpTooltipAction>
|
||||
</HelpTooltipLinksGroup>
|
||||
</HelpPopoverAction>
|
||||
</HelpPopoverLinksGroup>
|
||||
</Stack>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
</HelpPopoverContent>
|
||||
</HelpPopover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,8 +68,8 @@ const statusBorderClassByLifecycle: Partial<
|
||||
ready: "border-border-success",
|
||||
start_timeout: "border-border-warning",
|
||||
shutdown_timeout: "border-border-warning",
|
||||
start_error: "border-border-destructive",
|
||||
shutdown_error: "border-border-destructive",
|
||||
start_error: "border-border-warning",
|
||||
shutdown_error: "border-border-warning",
|
||||
off: "border-border",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
|
||||
import { MockWorkspaceAgent } from "#/testHelpers/entities";
|
||||
import {
|
||||
agentConnectionMessages,
|
||||
agentScriptMessages,
|
||||
} from "../workspaces/health";
|
||||
import { AgentStatus } from "./AgentStatus";
|
||||
|
||||
const meta: Meta<typeof AgentStatus> = {
|
||||
title: "modules/resources/AgentStatus",
|
||||
component: AgentStatus,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentStatus>;
|
||||
|
||||
/**
|
||||
* Shared play helper that hovers the status icon, then asserts the
|
||||
* tooltip contains the expected title and detail text, plus a
|
||||
* troubleshoot link when the agent has a troubleshooting URL.
|
||||
*/
|
||||
async function expectTooltip(
|
||||
ariaLabel: string,
|
||||
title: string,
|
||||
detail: string,
|
||||
hasTroubleshootLink: boolean,
|
||||
) {
|
||||
const icon = screen.getByRole("status", { name: ariaLabel });
|
||||
await userEvent.click(icon);
|
||||
await waitFor(() => {
|
||||
const tooltip = screen.getByRole("dialog");
|
||||
expect(tooltip).toHaveTextContent(title);
|
||||
expect(tooltip).toHaveTextContent(detail);
|
||||
if (hasTroubleshootLink) {
|
||||
expect(
|
||||
within(tooltip).getByRole("link", { name: "Troubleshoot" }),
|
||||
).toBeInTheDocument();
|
||||
} else {
|
||||
expect(
|
||||
within(tooltip).queryByRole("link", { name: "Troubleshoot" }),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const Ready: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connected",
|
||||
lifecycle_state: "ready",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const StartupScriptFailed: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connected",
|
||||
lifecycle_state: "start_error",
|
||||
},
|
||||
},
|
||||
play: async () => {
|
||||
await expectTooltip(
|
||||
"Startup script failed",
|
||||
agentScriptMessages.start_error.title,
|
||||
agentScriptMessages.start_error.detail,
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const StartupScriptTimeout: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connected",
|
||||
lifecycle_state: "start_timeout",
|
||||
},
|
||||
},
|
||||
play: async () => {
|
||||
await expectTooltip(
|
||||
"Startup script timeout",
|
||||
agentScriptMessages.start_timeout.title,
|
||||
agentScriptMessages.start_timeout.detail,
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ShutdownScriptFailed: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connected",
|
||||
lifecycle_state: "shutdown_error",
|
||||
},
|
||||
},
|
||||
play: async () => {
|
||||
await expectTooltip(
|
||||
"Shutdown script failed",
|
||||
agentScriptMessages.shutdown_error.title,
|
||||
agentScriptMessages.shutdown_error.detail,
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ShutdownScriptTimeout: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connected",
|
||||
lifecycle_state: "shutdown_timeout",
|
||||
},
|
||||
},
|
||||
play: async () => {
|
||||
await expectTooltip(
|
||||
"Shutdown script timeout",
|
||||
agentScriptMessages.shutdown_timeout.title,
|
||||
agentScriptMessages.shutdown_timeout.detail,
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ConnectionTimeout: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "timeout",
|
||||
},
|
||||
},
|
||||
play: async () => {
|
||||
await expectTooltip(
|
||||
"Timeout",
|
||||
agentConnectionMessages.timeout.title,
|
||||
agentConnectionMessages.timeout.detail,
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const StartupScriptFailedNoTroubleshootURL: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connected",
|
||||
lifecycle_state: "start_error",
|
||||
troubleshooting_url: "",
|
||||
},
|
||||
},
|
||||
play: async () => {
|
||||
await expectTooltip(
|
||||
"Startup script failed",
|
||||
agentScriptMessages.start_error.title,
|
||||
agentScriptMessages.start_error.detail,
|
||||
false,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Disconnected: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "disconnected",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Connecting: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
status: "connecting",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -8,17 +8,22 @@ import type {
|
||||
} from "#/api/typesGenerated";
|
||||
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipContent,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
HelpTooltipTrigger,
|
||||
} from "#/components/HelpTooltip/HelpTooltip";
|
||||
HelpPopover,
|
||||
HelpPopoverContent,
|
||||
HelpPopoverText,
|
||||
HelpPopoverTitle,
|
||||
HelpPopoverTrigger,
|
||||
} from "#/components/HelpPopover/HelpPopover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import {
|
||||
agentConnectionMessages,
|
||||
agentScriptMessages,
|
||||
} from "../workspaces/health";
|
||||
|
||||
// If we think in the agent status and lifecycle into a single enum/state I'd
|
||||
// say we would have: connecting, timeout, disconnected, connected:created,
|
||||
@@ -26,6 +31,56 @@ import {
|
||||
// connected:ready, connected:shutting_down, connected:shutdown_timeout,
|
||||
// connected:shutdown_error, connected:off.
|
||||
|
||||
interface AgentWarningTooltipProps {
|
||||
ariaLabel: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
troubleshootingURL?: string;
|
||||
variant?: "warning" | "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared tooltip for agent warning/error states. Renders an alert
|
||||
* icon with a help tooltip showing the title, detail, and an
|
||||
* optional troubleshooting link.
|
||||
*/
|
||||
const AgentWarningTooltip: FC<AgentWarningTooltipProps> = ({
|
||||
ariaLabel,
|
||||
title,
|
||||
detail,
|
||||
troubleshootingURL,
|
||||
variant = "warning",
|
||||
}) => {
|
||||
return (
|
||||
<HelpPopover>
|
||||
<HelpPopoverTrigger asChild role="status" aria-label={ariaLabel}>
|
||||
<TriangleAlertIcon
|
||||
className={cn(
|
||||
"relative size-3.5",
|
||||
variant === "warning"
|
||||
? "text-content-warning"
|
||||
: "text-content-destructive",
|
||||
)}
|
||||
/>
|
||||
</HelpPopoverTrigger>
|
||||
<HelpPopoverContent>
|
||||
<HelpPopoverTitle>{title}</HelpPopoverTitle>
|
||||
<HelpPopoverText>
|
||||
{detail}
|
||||
{troubleshootingURL && (
|
||||
<>
|
||||
{" "}
|
||||
<Link target="_blank" rel="noreferrer" href={troubleshootingURL}>
|
||||
Troubleshoot
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</HelpPopoverText>
|
||||
</HelpPopoverContent>
|
||||
</HelpPopover>
|
||||
);
|
||||
};
|
||||
|
||||
const ReadyLifecycle: FC = () => {
|
||||
return (
|
||||
<div
|
||||
@@ -66,54 +121,24 @@ interface DevcontainerStatusProps {
|
||||
agent?: WorkspaceAgent;
|
||||
}
|
||||
|
||||
const StartTimeoutLifecycle: FC<AgentStatusProps> = ({ agent }) => {
|
||||
return (
|
||||
<HelpTooltip>
|
||||
<HelpTooltipTrigger asChild role="status" aria-label="Agent timeout">
|
||||
<TriangleAlertIcon css={styles.timeoutWarning} />
|
||||
</HelpTooltipTrigger>
|
||||
const StartTimeoutLifecycle: FC<AgentStatusProps> = ({ agent }) => (
|
||||
<AgentWarningTooltip
|
||||
ariaLabel="Startup script timeout"
|
||||
title={agentScriptMessages.start_timeout.title}
|
||||
detail={agentScriptMessages.start_timeout.detail}
|
||||
troubleshootingURL={agent.troubleshooting_url}
|
||||
/>
|
||||
);
|
||||
|
||||
<HelpTooltipContent>
|
||||
<HelpTooltipTitle>Agent is taking too long to start</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
We noticed this agent is taking longer than expected to start.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={agent.troubleshooting_url}
|
||||
>
|
||||
Troubleshoot
|
||||
</Link>
|
||||
.
|
||||
</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const StartErrorLifecycle: FC<AgentStatusProps> = ({ agent }) => {
|
||||
return (
|
||||
<HelpTooltip>
|
||||
<HelpTooltipTrigger asChild role="status" aria-label="Start error">
|
||||
<TriangleAlertIcon css={styles.errorWarning} />
|
||||
</HelpTooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
<HelpTooltipTitle>Error starting the agent</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
Something went wrong during the agent startup.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={agent.troubleshooting_url}
|
||||
>
|
||||
Troubleshoot
|
||||
</Link>
|
||||
.
|
||||
</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
);
|
||||
};
|
||||
const StartErrorLifecycle: FC<AgentStatusProps> = ({ agent }) => (
|
||||
<AgentWarningTooltip
|
||||
ariaLabel="Startup script failed"
|
||||
title={agentScriptMessages.start_error.title}
|
||||
detail={agentScriptMessages.start_error.detail}
|
||||
troubleshootingURL={agent.troubleshooting_url}
|
||||
variant="warning"
|
||||
/>
|
||||
);
|
||||
|
||||
const ShuttingDownLifecycle: FC = () => {
|
||||
return (
|
||||
@@ -130,53 +155,24 @@ const ShuttingDownLifecycle: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ShutdownTimeoutLifecycle: FC<AgentStatusProps> = ({ agent }) => {
|
||||
return (
|
||||
<HelpTooltip>
|
||||
<HelpTooltipTrigger asChild role="status" aria-label="Stop timeout">
|
||||
<TriangleAlertIcon css={styles.timeoutWarning} />
|
||||
</HelpTooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
<HelpTooltipTitle>Agent is taking too long to stop</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
We noticed this agent is taking longer than expected to stop.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={agent.troubleshooting_url}
|
||||
>
|
||||
Troubleshoot
|
||||
</Link>
|
||||
.
|
||||
</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
);
|
||||
};
|
||||
const ShutdownTimeoutLifecycle: FC<AgentStatusProps> = ({ agent }) => (
|
||||
<AgentWarningTooltip
|
||||
ariaLabel="Shutdown script timeout"
|
||||
title={agentScriptMessages.shutdown_timeout.title}
|
||||
detail={agentScriptMessages.shutdown_timeout.detail}
|
||||
troubleshootingURL={agent.troubleshooting_url}
|
||||
/>
|
||||
);
|
||||
|
||||
const ShutdownErrorLifecycle: FC<AgentStatusProps> = ({ agent }) => {
|
||||
return (
|
||||
<HelpTooltip>
|
||||
<HelpTooltipTrigger asChild role="status" aria-label="Stop error">
|
||||
<TriangleAlertIcon css={styles.errorWarning} />
|
||||
</HelpTooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
<HelpTooltipTitle>Error stopping the agent</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
Something went wrong while trying to stop the agent.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={agent.troubleshooting_url}
|
||||
>
|
||||
Troubleshoot
|
||||
</Link>
|
||||
.
|
||||
</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
);
|
||||
};
|
||||
const ShutdownErrorLifecycle: FC<AgentStatusProps> = ({ agent }) => (
|
||||
<AgentWarningTooltip
|
||||
ariaLabel="Shutdown script failed"
|
||||
title={agentScriptMessages.shutdown_error.title}
|
||||
detail={agentScriptMessages.shutdown_error.detail}
|
||||
troubleshootingURL={agent.troubleshooting_url}
|
||||
variant="warning"
|
||||
/>
|
||||
);
|
||||
|
||||
const OffLifecycle: FC = () => {
|
||||
return (
|
||||
@@ -259,29 +255,14 @@ const ConnectingStatus: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const TimeoutStatus: FC<AgentStatusProps> = ({ agent }) => {
|
||||
return (
|
||||
<HelpTooltip>
|
||||
<HelpTooltipTrigger asChild role="status" aria-label="Timeout">
|
||||
<TriangleAlertIcon css={styles.timeoutWarning} />
|
||||
</HelpTooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
<HelpTooltipTitle>Agent is taking too long to connect</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
We noticed this agent is taking longer than expected to connect.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={agent.troubleshooting_url}
|
||||
>
|
||||
Troubleshoot
|
||||
</Link>
|
||||
.
|
||||
</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
);
|
||||
};
|
||||
const TimeoutStatus: FC<AgentStatusProps> = ({ agent }) => (
|
||||
<AgentWarningTooltip
|
||||
ariaLabel="Timeout"
|
||||
title={agentConnectionMessages.timeout.title}
|
||||
detail={agentConnectionMessages.timeout.detail}
|
||||
troubleshootingURL={agent.troubleshooting_url}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AgentStatus: FC<AgentStatusProps> = ({ agent }) => {
|
||||
return (
|
||||
@@ -324,31 +305,15 @@ const SubAgentStatus: FC<SubAgentStatusProps> = ({ agent }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const DevcontainerStartError: FC<AgentStatusProps> = ({ agent }) => {
|
||||
return (
|
||||
<HelpTooltip>
|
||||
<HelpTooltipTrigger asChild role="status" aria-label="Start error">
|
||||
<TriangleAlertIcon css={styles.errorWarning} />
|
||||
</HelpTooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
<HelpTooltipTitle>
|
||||
Error starting the devcontainer agent
|
||||
</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
Something went wrong during the devcontainer agent startup.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={agent.troubleshooting_url}
|
||||
>
|
||||
Troubleshoot
|
||||
</Link>
|
||||
.
|
||||
</HelpTooltipText>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
);
|
||||
};
|
||||
const DevcontainerStartError: FC<AgentStatusProps> = ({ agent }) => (
|
||||
<AgentWarningTooltip
|
||||
ariaLabel="Start error"
|
||||
title="Error starting the devcontainer agent"
|
||||
detail="Something went wrong during the devcontainer agent startup."
|
||||
troubleshootingURL={agent.troubleshooting_url}
|
||||
variant="error"
|
||||
/>
|
||||
);
|
||||
|
||||
export const DevcontainerStatus: FC<DevcontainerStatusProps> = ({
|
||||
devcontainer,
|
||||
@@ -398,18 +363,4 @@ const styles = {
|
||||
backgroundColor: theme.palette.info.light,
|
||||
animation: "$pulse 1.5s 0.5s ease-in-out forwards infinite",
|
||||
}),
|
||||
|
||||
timeoutWarning: (theme) => ({
|
||||
color: theme.palette.warning.light,
|
||||
width: 14,
|
||||
height: 14,
|
||||
position: "relative",
|
||||
}),
|
||||
|
||||
errorWarning: (theme) => ({
|
||||
color: theme.palette.error.main,
|
||||
width: 14,
|
||||
height: 14,
|
||||
position: "relative",
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -37,10 +37,10 @@ import {
|
||||
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
HelpTooltipLink,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "#/components/HelpTooltip/HelpTooltip";
|
||||
HelpPopoverLink,
|
||||
HelpPopoverText,
|
||||
HelpPopoverTitle,
|
||||
} from "#/components/HelpPopover/HelpPopover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -251,42 +251,26 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
css={{
|
||||
maxHeight: 320,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="column"
|
||||
css={{
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<Stack direction="column" className="p-5">
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="start"
|
||||
>
|
||||
<HelpTooltipTitle>Listening Ports</HelpTooltipTitle>
|
||||
<HelpTooltipLink
|
||||
<HelpPopoverTitle>Listening Ports</HelpPopoverTitle>
|
||||
<HelpPopoverLink
|
||||
href={docs("/admin/networking/port-forwarding#dashboard")}
|
||||
>
|
||||
Learn more
|
||||
</HelpTooltipLink>
|
||||
</HelpPopoverLink>
|
||||
</Stack>
|
||||
<Stack direction="column" gap={1}>
|
||||
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
|
||||
<HelpPopoverText css={{ color: theme.palette.text.secondary }}>
|
||||
The listening ports are exclusively accessible to you. Selecting
|
||||
HTTP/S will change the protocol for all listening ports.
|
||||
</HelpTooltipText>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={2}
|
||||
css={{
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
>
|
||||
</HelpPopoverText>
|
||||
<Stack direction="row" gap={2} className="pb-2">
|
||||
<FormControl size="small" css={styles.protocolFormControl}>
|
||||
<Select
|
||||
css={styles.listeningPortProtocol}
|
||||
@@ -346,9 +330,9 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
</Stack>
|
||||
</Stack>
|
||||
{filteredListeningPorts.length === 0 && (
|
||||
<HelpTooltipText css={styles.noPortText}>
|
||||
<HelpPopoverText css={styles.noPortText}>
|
||||
No open ports were detected.
|
||||
</HelpTooltipText>
|
||||
</HelpPopoverText>
|
||||
)}
|
||||
{filteredListeningPorts.map((port) => {
|
||||
const url = portForwardURL(
|
||||
@@ -431,12 +415,12 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<HelpTooltipTitle>Shared Ports</HelpTooltipTitle>
|
||||
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
|
||||
<HelpPopoverTitle>Shared Ports</HelpPopoverTitle>
|
||||
<HelpPopoverText css={{ color: theme.palette.text.secondary }}>
|
||||
{canSharePorts
|
||||
? "Ports can be shared with organization members, other Coder users, or with the public."
|
||||
: "This workspace template does not allow sharing ports. Contact a template administrator to enable port sharing."}
|
||||
</HelpTooltipText>
|
||||
</HelpPopoverText>
|
||||
{canSharePorts && (
|
||||
<div>
|
||||
{filteredSharedPorts?.map((share) => {
|
||||
|
||||
@@ -114,12 +114,9 @@ export const ResourceCard: FC<ResourceCardProps> = ({ resource, agentRow }) => {
|
||||
</Stack>
|
||||
|
||||
<div
|
||||
css={{
|
||||
flexGrow: 2,
|
||||
display: "grid",
|
||||
className="grow-[2] grid gap-x-10 gap-y-6"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridWidth}, minmax(0, 1fr))`,
|
||||
gap: 40,
|
||||
rowGap: 24,
|
||||
}}
|
||||
>
|
||||
{resource.daily_cost > 0 && (
|
||||
|
||||
@@ -5,10 +5,10 @@ import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { CodeExample } from "#/components/CodeExample/CodeExample";
|
||||
import {
|
||||
HelpTooltipLink,
|
||||
HelpTooltipLinksGroup,
|
||||
HelpTooltipText,
|
||||
} from "#/components/HelpTooltip/HelpTooltip";
|
||||
HelpPopoverLink,
|
||||
HelpPopoverLinksGroup,
|
||||
HelpPopoverText,
|
||||
} from "#/components/HelpPopover/HelpPopover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -44,9 +44,9 @@ export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
|
||||
align="end"
|
||||
className="py-4 px-6 w-80 text-content-secondary mt-[2px] bg-surface-secondary"
|
||||
>
|
||||
<HelpTooltipText>
|
||||
<HelpPopoverText>
|
||||
Run the following commands to connect with SSH:
|
||||
</HelpTooltipText>
|
||||
</HelpPopoverText>
|
||||
|
||||
<ol style={{ margin: 0, padding: 0 }}>
|
||||
<Stack spacing={0.5} className="mt-3">
|
||||
@@ -61,25 +61,25 @@ export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
|
||||
</Stack>
|
||||
</ol>
|
||||
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipLink href={docs("/install")}>
|
||||
<HelpPopoverLinksGroup>
|
||||
<HelpPopoverLink href={docs("/install")}>
|
||||
Install Coder CLI
|
||||
</HelpTooltipLink>
|
||||
<HelpTooltipLink href={docs("/user-guides/workspace-access/vscode")}>
|
||||
</HelpPopoverLink>
|
||||
<HelpPopoverLink href={docs("/user-guides/workspace-access/vscode")}>
|
||||
Connect via VS Code Remote SSH
|
||||
</HelpTooltipLink>
|
||||
<HelpTooltipLink
|
||||
</HelpPopoverLink>
|
||||
<HelpPopoverLink
|
||||
href={docs("/user-guides/workspace-access/jetbrains")}
|
||||
>
|
||||
Connect via JetBrains IDEs
|
||||
</HelpTooltipLink>
|
||||
<HelpTooltipLink href={docs("/user-guides/desktop")}>
|
||||
</HelpPopoverLink>
|
||||
<HelpPopoverLink href={docs("/user-guides/desktop")}>
|
||||
Connect via Coder Desktop
|
||||
</HelpTooltipLink>
|
||||
<HelpTooltipLink href={docs("/user-guides/workspace-access#ssh")}>
|
||||
</HelpPopoverLink>
|
||||
<HelpPopoverLink href={docs("/user-guides/workspace-access#ssh")}>
|
||||
SSH configuration
|
||||
</HelpTooltipLink>
|
||||
</HelpTooltipLinksGroup>
|
||||
</HelpPopoverLink>
|
||||
</HelpPopoverLinksGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
@@ -92,9 +92,9 @@ interface SSHStepProps {
|
||||
|
||||
const SSHStep: FC<SSHStepProps> = ({ helpText, codeExample }) => (
|
||||
<li style={{ listStylePosition: "inside" }}>
|
||||
<HelpTooltipText style={{ display: "inline" }}>
|
||||
<HelpPopoverText style={{ display: "inline" }}>
|
||||
<strong className="text-xs">{helpText}</strong>
|
||||
</HelpTooltipText>
|
||||
</HelpPopoverText>
|
||||
<CodeExample secret={false} code={codeExample} />
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
|
||||
const Language = {
|
||||
showLabel: "Show value",
|
||||
hideLabel: "Hide value",
|
||||
};
|
||||
|
||||
interface SensitiveValueProps {
|
||||
value: string;
|
||||
}
|
||||
@@ -20,7 +15,7 @@ interface SensitiveValueProps {
|
||||
export const SensitiveValue: FC<SensitiveValueProps> = ({ value }) => {
|
||||
const [shouldDisplay, setShouldDisplay] = useState(false);
|
||||
const displayValue = shouldDisplay ? value : "••••••••";
|
||||
const buttonLabel = shouldDisplay ? Language.hideLabel : Language.showLabel;
|
||||
const buttonLabel = shouldDisplay ? "Hide value" : "Show value";
|
||||
const icon = shouldDisplay ? (
|
||||
<EyeOffIcon className="size-icon-xs" />
|
||||
) : (
|
||||
|
||||
@@ -5,14 +5,14 @@ import type {
|
||||
WorkspaceAgentDevcontainer,
|
||||
} from "#/api/typesGenerated";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipAction,
|
||||
HelpTooltipContent,
|
||||
HelpTooltipLinksGroup,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "#/components/HelpTooltip/HelpTooltip";
|
||||
import { TooltipTrigger } from "#/components/Tooltip/Tooltip";
|
||||
HelpPopover,
|
||||
HelpPopoverAction,
|
||||
HelpPopoverContent,
|
||||
HelpPopoverLinksGroup,
|
||||
HelpPopoverText,
|
||||
HelpPopoverTitle,
|
||||
HelpPopoverTrigger,
|
||||
} from "#/components/HelpPopover/HelpPopover";
|
||||
|
||||
type SubAgentOutdatedTooltipProps = {
|
||||
devcontainer: WorkspaceAgentDevcontainer;
|
||||
@@ -33,34 +33,34 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<HelpTooltip>
|
||||
<TooltipTrigger className="px-0 py-1 bg-transparent text-inherit border-none opacity-50 hover:opacity-100">
|
||||
<HelpPopover>
|
||||
<HelpPopoverTrigger className="px-0 py-1 bg-transparent text-inherit border-none opacity-50 hover:opacity-100">
|
||||
<span role="status" className="cursor-pointer">
|
||||
Outdated
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<HelpTooltipContent>
|
||||
</HelpPopoverTrigger>
|
||||
<HelpPopoverContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<HelpTooltipTitle>Dev Container Outdated</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
<HelpPopoverTitle>Dev Container Outdated</HelpPopoverTitle>
|
||||
<HelpPopoverText>
|
||||
This Dev Container is outdated. This can happen if you modify your
|
||||
devcontainer.json file after the Dev Container has been created.
|
||||
To fix this, you can rebuild the Dev Container.
|
||||
</HelpTooltipText>
|
||||
</HelpPopoverText>
|
||||
</div>
|
||||
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipAction
|
||||
<HelpPopoverLinksGroup>
|
||||
<HelpPopoverAction
|
||||
icon={RotateCcwIcon}
|
||||
onClick={onUpdate}
|
||||
ariaLabel="Rebuild Dev Container"
|
||||
>
|
||||
Rebuild Dev Container
|
||||
</HelpTooltipAction>
|
||||
</HelpTooltipLinksGroup>
|
||||
</HelpPopoverAction>
|
||||
</HelpPopoverLinksGroup>
|
||||
</div>
|
||||
</HelpTooltipContent>
|
||||
</HelpTooltip>
|
||||
</HelpPopoverContent>
|
||||
</HelpPopover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,21 +78,21 @@ export const VSCodeDesktopButton: FC<VSCodeDesktopButtonProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
css={{ fontSize: 14 }}
|
||||
className="text-sm"
|
||||
onClick={() => {
|
||||
selectVariant("vscode");
|
||||
}}
|
||||
>
|
||||
<VSCodeIcon css={{ width: 12, height: 12 }} />
|
||||
<VSCodeIcon className="w-3 h-3" />
|
||||
{DisplayAppNameMap.vscode}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
css={{ fontSize: 14 }}
|
||||
className="text-sm"
|
||||
onClick={() => {
|
||||
selectVariant("vscode-insiders");
|
||||
}}
|
||||
>
|
||||
<VSCodeInsidersIcon css={{ width: 12, height: 12 }} />
|
||||
<VSCodeInsidersIcon className="w-3 h-3" />
|
||||
{DisplayAppNameMap.vscode_insiders}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -82,21 +82,21 @@ export const VSCodeDevContainerButton: FC<VSCodeDevContainerButtonProps> = (
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
css={{ fontSize: 14 }}
|
||||
className="text-sm"
|
||||
onClick={() => {
|
||||
selectVariant("vscode");
|
||||
}}
|
||||
>
|
||||
<VSCodeIcon css={{ width: 12, height: 12 }} />
|
||||
<VSCodeIcon className="w-3 h-3" />
|
||||
{DisplayAppNameMap.vscode}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
css={{ fontSize: 14 }}
|
||||
className="text-sm"
|
||||
onClick={() => {
|
||||
selectVariant("vscode-insiders");
|
||||
}}
|
||||
>
|
||||
<VSCodeInsidersIcon css={{ width: 12, height: 12 }} />
|
||||
<VSCodeInsidersIcon className="w-3 h-3" />
|
||||
{DisplayAppNameMap.vscode_insiders}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const TemplateExampleCard: FC<TemplateExampleCardProps> = ({
|
||||
<div css={styles.icon}>
|
||||
<ExternalImage
|
||||
src={example.icon}
|
||||
css={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -39,15 +39,13 @@ export const TemplateExampleCard: FC<TemplateExampleCardProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 css={{ fontSize: 14, fontWeight: 600, margin: 0, marginBottom: 4 }}>
|
||||
{example.name}
|
||||
</h4>
|
||||
<h4 className="text-sm font-semibold m-0 mb-1">{example.name}</h4>
|
||||
<span css={styles.description}>
|
||||
{example.description}{" "}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/starter-templates/${example.id}`}
|
||||
css={{ display: "inline-block", fontSize: 13, marginTop: 4 }}
|
||||
className="inline-block text-[13px] mt-1"
|
||||
>
|
||||
Read more
|
||||
</Link>
|
||||
|
||||
@@ -30,7 +30,7 @@ export const WorkspaceBuildData = ({ build }: { build: WorkspaceBuild }) => {
|
||||
color: theme.roles[statusType].fill.solid,
|
||||
}}
|
||||
/>
|
||||
<div css={{ overflow: "hidden" }}>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
css={{
|
||||
color: theme.palette.text.primary,
|
||||
@@ -42,9 +42,8 @@ export const WorkspaceBuildData = ({ build }: { build: WorkspaceBuild }) => {
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span css={{ textTransform: "capitalize" }}>{build.transition}</span>{" "}
|
||||
by{" "}
|
||||
<span css={{ fontWeight: 500 }}>
|
||||
<span className="capitalize">{build.transition}</span> by{" "}
|
||||
<span className="font-medium">
|
||||
{getDisplayWorkspaceBuildInitiatedBy(build)}
|
||||
</span>
|
||||
{!systemBuildReasons.includes(build.reason) &&
|
||||
@@ -83,12 +82,7 @@ export const WorkspaceBuildDataSkeleton = () => {
|
||||
<Skeleton variant="circular" width={16} height={16} />
|
||||
<div>
|
||||
<Skeleton variant="text" width={94} height={16} />
|
||||
<Skeleton
|
||||
variant="text"
|
||||
width={60}
|
||||
height={14}
|
||||
css={{ marginTop: 2 }}
|
||||
/>
|
||||
<Skeleton variant="text" width={60} height={14} className="mt-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,10 +13,6 @@ import { DEFAULT_LOG_LINE_SIDE_PADDING, Logs } from "#/components/Logs/Logs";
|
||||
import { BODY_FONT_FAMILY } from "#/theme/constants";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
const Language = {
|
||||
seconds: "seconds",
|
||||
};
|
||||
|
||||
type Stage = ProvisionerJobLog["stage"];
|
||||
type LogsGroupedByStage = Record<Stage, ProvisionerJobLog[]>;
|
||||
type GroupLogsByStageFn = (logs: ProvisionerJobLog[]) => LogsGroupedByStage;
|
||||
@@ -98,9 +94,7 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
|
||||
>
|
||||
<div>{stage}</div>
|
||||
{shouldDisplayDuration && (
|
||||
<div css={styles.duration}>
|
||||
{duration} {Language.seconds}
|
||||
</div>
|
||||
<div css={styles.duration}>{duration} seconds</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEmpty && <Logs hideTimestamps={hideTimestamps} lines={lines} />}
|
||||
|
||||
@@ -142,7 +142,7 @@ export const DownloadLogsDialog: FC<DownloadLogsDialogProps> = ({
|
||||
}
|
||||
}}
|
||||
description={
|
||||
<Stack css={{ paddingBottom: 16 }}>
|
||||
<Stack className="pb-4">
|
||||
<p>
|
||||
Downloading logs will create a zip file containing all logs from all
|
||||
jobs in this workspace. This may take a while.
|
||||
|
||||
@@ -61,7 +61,7 @@ export const UpdateBuildParametersDialog: FC<
|
||||
Workspace parameters
|
||||
</DialogTitle>
|
||||
<DialogContent css={styles.content}>
|
||||
<DialogContentText css={{ margin: 0 }}>
|
||||
<DialogContentText className="m-0">
|
||||
This template has new parameters that must be configured to complete
|
||||
the update
|
||||
</DialogContentText>
|
||||
|
||||
@@ -74,7 +74,7 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
|
||||
<p className="name">{workspace.name}</p>
|
||||
<p className="label">workspace</p>
|
||||
</div>
|
||||
<div css={{ textAlign: "right" }}>
|
||||
<div className="text-right">
|
||||
<p className="info">{dayjs(workspace.created_at).fromNow()}</p>
|
||||
<p className="label">created</p>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
|
||||
<TextField
|
||||
fullWidth
|
||||
autoFocus
|
||||
css={{ marginTop: 32 }}
|
||||
className="mt-8"
|
||||
name="confirmation"
|
||||
autoComplete="off"
|
||||
id={`${hookId}-confirm`}
|
||||
@@ -113,9 +113,9 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
|
||||
/>
|
||||
{hasTask && (
|
||||
<div css={styles.warnContainer}>
|
||||
<div css={{ flexDirection: "column" }}>
|
||||
<div className="flex-col">
|
||||
<p className="info">This workspace is related to a task</p>
|
||||
<span css={{ fontSize: 12, marginTop: 4, display: "block" }}>
|
||||
<span className="text-xs mt-1 block">
|
||||
Deleting this workspace will also delete{" "}
|
||||
<Link
|
||||
href={`/tasks/${workspace.owner_name}/${workspace.task_id}`}
|
||||
@@ -129,7 +129,7 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
|
||||
)}
|
||||
{canOrphan && (
|
||||
<div css={styles.warnContainer}>
|
||||
<div css={{ flexDirection: "column" }}>
|
||||
<div className="flex-col">
|
||||
<Checkbox
|
||||
id="orphan_resources"
|
||||
size="small"
|
||||
@@ -143,9 +143,9 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
|
||||
data-testid="orphan-checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div css={{ flexDirection: "column" }}>
|
||||
<div className="flex-col">
|
||||
<p className="info">Orphan Resources</p>
|
||||
<span css={{ fontSize: 12, marginTop: 4, display: "block" }}>
|
||||
<span className="text-xs mt-1 block">
|
||||
As a Template Admin, you may skip resource cleanup to delete
|
||||
a failed workspace. Resources such as volumes and virtual
|
||||
machines will not be destroyed.
|
||||
|
||||
@@ -45,6 +45,7 @@ type WorkspaceMoreActionsProps = {
|
||||
disabled: boolean;
|
||||
onStop?: () => void;
|
||||
isStopping?: boolean;
|
||||
onActionSuccess?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
|
||||
@@ -52,6 +53,7 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
|
||||
disabled,
|
||||
onStop,
|
||||
isStopping,
|
||||
onActionSuccess,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -97,8 +99,13 @@ export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
|
||||
|
||||
// Delete
|
||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
|
||||
const deleteWorkspaceOptions = deleteWorkspace(workspace, queryClient);
|
||||
const deleteWorkspaceMutation = useMutation({
|
||||
...deleteWorkspace(workspace, queryClient),
|
||||
...deleteWorkspaceOptions,
|
||||
onSuccess: async (build) => {
|
||||
await deleteWorkspaceOptions.onSuccess?.(build);
|
||||
await onActionSuccess?.();
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
handleError(error);
|
||||
},
|
||||
|
||||
+2
-2
@@ -37,9 +37,9 @@ const Example: Story = {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
await step("activate hover trigger", async () => {
|
||||
await userEvent.hover(body.getByRole("button"));
|
||||
await userEvent.click(body.getByRole("button"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent(
|
||||
MockTemplateVersion.message,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,15 +9,15 @@ import { getErrorDetail, getErrorMessage } from "#/api/errors";
|
||||
import { templateVersion } from "#/api/queries/templates";
|
||||
import type { Workspace } from "#/api/typesGenerated";
|
||||
import {
|
||||
HelpTooltip,
|
||||
HelpTooltipAction,
|
||||
HelpTooltipContent,
|
||||
HelpTooltipIconTrigger,
|
||||
HelpTooltipLinksGroup,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
HelpTooltipTrigger,
|
||||
} from "#/components/HelpTooltip/HelpTooltip";
|
||||
HelpPopover,
|
||||
HelpPopoverAction,
|
||||
HelpPopoverContent,
|
||||
HelpPopoverIconTrigger,
|
||||
HelpPopoverLinksGroup,
|
||||
HelpPopoverText,
|
||||
HelpPopoverTitle,
|
||||
HelpPopoverTrigger,
|
||||
} from "#/components/HelpPopover/HelpPopover";
|
||||
import { linkToTemplate, useLinks } from "#/modules/navigation";
|
||||
import {
|
||||
useWorkspaceUpdate,
|
||||
@@ -36,22 +36,22 @@ export const WorkspaceOutdatedTooltip: FC<WorkspaceOutdatedTooltipProps> = ({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<HelpTooltip open={isOpen} onOpenChange={setIsOpen}>
|
||||
<HelpPopover open={isOpen} onOpenChange={setIsOpen}>
|
||||
{children ? (
|
||||
<HelpTooltipTrigger asChild>
|
||||
<HelpPopoverTrigger asChild>
|
||||
<span className="flex items-center gap-1.5 cursor-help">
|
||||
<InfoIcon css={styles.icon} size={14} />
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
</HelpTooltipTrigger>
|
||||
</HelpPopoverTrigger>
|
||||
) : (
|
||||
<HelpTooltipIconTrigger size="small" hoverEffect={false}>
|
||||
<HelpPopoverIconTrigger size="small" hoverEffect={false}>
|
||||
<InfoIcon css={styles.icon} />
|
||||
<span className="sr-only">Outdated info</span>
|
||||
</HelpTooltipIconTrigger>
|
||||
</HelpPopoverIconTrigger>
|
||||
)}
|
||||
<WorkspaceOutdatedTooltipContent isOpen={isOpen} workspace={workspace} />
|
||||
</HelpTooltip>
|
||||
</HelpPopover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -86,14 +86,14 @@ const WorkspaceOutdatedTooltipContent: FC<TooltipContentProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<HelpTooltipContent disablePortal={false}>
|
||||
<HelpTooltipTitle>Outdated</HelpTooltipTitle>
|
||||
<HelpTooltipText>
|
||||
<HelpPopoverContent disablePortal={false}>
|
||||
<HelpPopoverTitle>Outdated</HelpPopoverTitle>
|
||||
<HelpPopoverText>
|
||||
This workspace version is outdated and a newer version is available.
|
||||
</HelpTooltipText>
|
||||
</HelpPopoverText>
|
||||
|
||||
<div css={styles.container}>
|
||||
<div css={{ lineHeight: "1.6" }}>
|
||||
<div className="leading-[1.6]">
|
||||
<div css={styles.bold}>New version</div>
|
||||
<div>
|
||||
{activeVersion ? (
|
||||
@@ -110,7 +110,7 @@ const WorkspaceOutdatedTooltipContent: FC<TooltipContentProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div css={{ lineHeight: "1.6" }}>
|
||||
<div className="leading-[1.6]">
|
||||
<div css={styles.bold}>Message</div>
|
||||
<div>
|
||||
{activeVersion ? (
|
||||
@@ -122,15 +122,15 @@ const WorkspaceOutdatedTooltipContent: FC<TooltipContentProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipAction
|
||||
<HelpPopoverLinksGroup>
|
||||
<HelpPopoverAction
|
||||
icon={RotateCcwIcon}
|
||||
onClick={updateWorkspace.update}
|
||||
>
|
||||
Update
|
||||
</HelpTooltipAction>
|
||||
</HelpTooltipLinksGroup>
|
||||
</HelpTooltipContent>
|
||||
</HelpPopoverAction>
|
||||
</HelpPopoverLinksGroup>
|
||||
</HelpPopoverContent>
|
||||
<WorkspaceUpdateDialogs {...updateWorkspace.dialogs} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -177,13 +177,7 @@ export const StagesChart: FC<StagesChartProps> = ({
|
||||
}}
|
||||
>
|
||||
{t.error && (
|
||||
<CircleAlertIcon
|
||||
className="size-icon-sm"
|
||||
css={{
|
||||
color: "#F87171",
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
<CircleAlertIcon className="size-icon-sm text-[#F87171] mr-1" />
|
||||
)}
|
||||
<Blocks count={t.visibleResources} />
|
||||
</ClickableBar>
|
||||
|
||||
@@ -285,7 +285,7 @@ export const InvalidTimeRange: Story = {
|
||||
export const MultipleAgents: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div css={{ "--collapse-body-height": "600px" }}>
|
||||
<div style={{ "--collapse-body-height": "600px" } as React.CSSProperties}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
import type { Workspace, WorkspaceAgentStatus } from "#/api/typesGenerated";
|
||||
|
||||
/**
|
||||
* Canonical messages for startup and shutdown script issues.
|
||||
* Used by the per-agent-row tooltips in AgentStatus; the
|
||||
* start-related entries are also shared with the workspace-level
|
||||
* health classification in getAgentHealthIssue.
|
||||
*/
|
||||
export const agentScriptMessages = {
|
||||
start_error: {
|
||||
title: "Startup script failed",
|
||||
detail:
|
||||
"A startup script exited with an error. Check the agent logs for details.",
|
||||
},
|
||||
start_timeout: {
|
||||
title: "Startup script is taking longer than expected",
|
||||
detail:
|
||||
"A startup script has exceeded the expected time. Check the agent logs for details.",
|
||||
},
|
||||
shutdown_error: {
|
||||
title: "Shutdown script failed",
|
||||
detail:
|
||||
"A shutdown script exited with an error. Check the agent logs for details.",
|
||||
},
|
||||
shutdown_timeout: {
|
||||
title: "Shutdown script is taking longer than expected",
|
||||
detail:
|
||||
"A shutdown script has exceeded the expected time. Check the agent logs for details.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Canonical messages for agent connection issues (the agent
|
||||
* process connecting to the Coder control plane).
|
||||
*/
|
||||
export const agentConnectionMessages = {
|
||||
timeout: {
|
||||
title: "Agent is taking longer than expected to connect",
|
||||
detail:
|
||||
"Continue to wait and check the log output for errors. If agents do not connect, try restarting the workspace.",
|
||||
},
|
||||
disconnected: {
|
||||
title: "Workspace agent has disconnected",
|
||||
detail:
|
||||
"Check the log output for errors. If agents do not reconnect, try restarting the workspace.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface AgentHealthIssue {
|
||||
title: string;
|
||||
detail: string;
|
||||
@@ -53,9 +99,8 @@ export function getAgentHealthIssue(workspace: Workspace): AgentHealthIssue {
|
||||
return {
|
||||
title: plural
|
||||
? `${failingAgentCount} workspace agents have disconnected`
|
||||
: "Workspace agent has disconnected",
|
||||
detail:
|
||||
"Check the log output for errors. If agents do not reconnect, try restarting the workspace.",
|
||||
: agentConnectionMessages.disconnected.title,
|
||||
detail: agentConnectionMessages.disconnected.detail,
|
||||
severity: "warning",
|
||||
prominent: true,
|
||||
};
|
||||
@@ -65,9 +110,8 @@ export function getAgentHealthIssue(workspace: Workspace): AgentHealthIssue {
|
||||
return {
|
||||
title: plural
|
||||
? `${failingAgentCount} agents are taking longer than expected to connect`
|
||||
: "Agent is taking longer than expected to connect",
|
||||
detail:
|
||||
"Continue to wait and check the log output for errors. If agents do not connect, try restarting the workspace.",
|
||||
: agentConnectionMessages.timeout.title,
|
||||
detail: agentConnectionMessages.timeout.detail,
|
||||
severity: "warning",
|
||||
prominent: false,
|
||||
};
|
||||
@@ -88,9 +132,8 @@ export function getAgentHealthIssue(workspace: Workspace): AgentHealthIssue {
|
||||
return {
|
||||
title: plural
|
||||
? `Startup scripts failed on ${failingAgentCount} agents`
|
||||
: "Startup script failed",
|
||||
detail:
|
||||
"A startup script exited with an error. Check the agent logs for details.",
|
||||
: agentScriptMessages.start_error.title,
|
||||
detail: agentScriptMessages.start_error.detail,
|
||||
severity: "warning",
|
||||
prominent: true,
|
||||
};
|
||||
@@ -105,9 +148,8 @@ export function getAgentHealthIssue(workspace: Workspace): AgentHealthIssue {
|
||||
return {
|
||||
title: plural
|
||||
? `Startup scripts are taking longer than expected on ${failingAgentCount} agents`
|
||||
: "Startup script is taking longer than expected",
|
||||
detail:
|
||||
"A startup script has exceeded the expected time. Check the agent logs for details.",
|
||||
: agentScriptMessages.start_timeout.title,
|
||||
detail: agentScriptMessages.start_timeout.detail,
|
||||
severity: "warning",
|
||||
prominent: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
HelpPopover,
|
||||
HelpPopoverContent,
|
||||
HelpPopoverIconTrigger,
|
||||
HelpPopoverLink,
|
||||
HelpPopoverLinksGroup,
|
||||
HelpPopoverText,
|
||||
HelpPopoverTitle,
|
||||
} from "#/components/HelpPopover/HelpPopover";
|
||||
import { docs } from "#/utils/docs";
|
||||
|
||||
export const AIBridgeHelpPopover: FC = () => {
|
||||
return (
|
||||
<HelpPopover>
|
||||
<HelpPopoverIconTrigger />
|
||||
|
||||
<HelpPopoverContent>
|
||||
<HelpPopoverTitle>What is AI Bridge?</HelpPopoverTitle>
|
||||
<HelpPopoverText>
|
||||
AI Bridge is a smart gateway for AI that provides centralized
|
||||
management, auditing, and attribution for LLM usage.
|
||||
</HelpPopoverText>
|
||||
<HelpPopoverLinksGroup>
|
||||
<HelpPopoverLink href={docs("/ai-coder/ai-bridge")}>
|
||||
Read the docs
|
||||
</HelpPopoverLink>
|
||||
</HelpPopoverLinksGroup>
|
||||
</HelpPopoverContent>
|
||||
</HelpPopover>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user