From afdb48dd136f7c7a2e817f1378b6521dd5e9ca64 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 24 Jun 2025 04:23:26 +0000 Subject: [PATCH] feat: add `connectionlogs` API --- coderd/apidoc/docs.go | 179 +++++++++++++ coderd/apidoc/swagger.json | 175 ++++++++++++ coderd/audit.go | 2 +- coderd/database/modelqueries.go | 13 + coderd/database/querier_test.go | 246 ++++++++++++++++- coderd/database/queries.sql.go | 131 ++++++++- coderd/database/queries/connectionlogs.sql | 92 ++++++- coderd/members.go | 2 +- coderd/pagination.go | 4 +- ...on_internal_test.go => pagination_test.go} | 5 +- coderd/searchquery/search.go | 40 +++ coderd/searchquery/search_test.go | 66 +++++ coderd/templateversions.go | 2 +- coderd/users.go | 2 +- coderd/workspacebuilds.go | 2 +- coderd/workspaces.go | 2 +- codersdk/agentsdk/agentsdk.go | 12 - codersdk/connectionlog.go | 126 +++++++++ docs/reference/api/enterprise.md | 92 +++++++ docs/reference/api/schemas.md | 222 ++++++++++++++++ enterprise/coderd/coderd.go | 7 + .../coderd/coderdenttest/coderdenttest.go | 2 + enterprise/coderd/connectionlog.go | 149 +++++++++++ enterprise/coderd/connectionlog_test.go | 251 ++++++++++++++++++ site/src/api/typesGenerated.ts | 69 +++++ 25 files changed, 1863 insertions(+), 30 deletions(-) rename coderd/{pagination_internal_test.go => pagination_test.go} (96%) create mode 100644 codersdk/connectionlog.go create mode 100644 enterprise/coderd/connectionlog.go create mode 100644 enterprise/coderd/connectionlog_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bcc7443c1c928..ab5a27dd3b163 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -383,6 +383,52 @@ const docTemplate = `{ } } }, + "/connectionlog": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get connection logs", + "operationId": "get-connection-logs", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ConnectionLogResponse" + } + } + } + } + }, "/csp/reports": { "post": { "security": [ @@ -11444,6 +11490,139 @@ const docTemplate = `{ } } }, + "codersdk.ConnectionLog": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + }, + "connect_time": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ip": { + "type": "string" + }, + "organization": { + "$ref": "#/definitions/codersdk.MinimalOrganization" + }, + "ssh_info": { + "description": "SSHInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypeSSH` + "`" + `\n- ` + "`" + `ConnectionTypeReconnectingPTY` + "`" + `\n- ` + "`" + `ConnectionTypeVSCode` + "`" + `\n- ` + "`" + `ConnectionTypeJetBrains` + "`" + `", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ConnectionLogSSHInfo" + } + ] + }, + "type": { + "$ref": "#/definitions/codersdk.ConnectionType" + }, + "web_info": { + "description": "WebInfo is only set when ` + "`" + `type` + "`" + ` is one of:\n- ` + "`" + `ConnectionTypePortForwarding` + "`" + `\n- ` + "`" + `ConnectionTypeWorkspaceApp` + "`" + `", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ConnectionLogWebInfo" + } + ] + }, + "workspace_id": { + "type": "string", + "format": "uuid" + }, + "workspace_name": { + "type": "string" + }, + "workspace_owner_id": { + "type": "string", + "format": "uuid" + }, + "workspace_owner_username": { + "type": "string" + } + } + }, + "codersdk.ConnectionLogResponse": { + "type": "object", + "properties": { + "connection_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ConnectionLog" + } + }, + "count": { + "type": "integer" + } + } + }, + "codersdk.ConnectionLogSSHInfo": { + "type": "object", + "properties": { + "connection_id": { + "type": "string", + "format": "uuid" + }, + "disconnect_reason": { + "description": "DisconnectReason is omitted if a disconnect event with the same connection ID\nhas not yet been seen.", + "type": "string" + }, + "disconnect_time": { + "description": "DisconnectTime is omitted if a disconnect event with the same connection ID\nhas not yet been seen.", + "type": "string", + "format": "date-time" + }, + "exit_code": { + "description": "ExitCode is the exit code of the SSH session. It is omitted if a\ndisconnect event with the same connection ID has not yet been seen.", + "type": "integer" + } + } + }, + "codersdk.ConnectionLogWebInfo": { + "type": "object", + "properties": { + "slug_or_port": { + "type": "string" + }, + "status_code": { + "description": "StatusCode is the HTTP status code of the request.", + "type": "integer" + }, + "user": { + "description": "User is omitted if the connection event was from an unauthenticated user.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.User" + } + ] + }, + "user_agent": { + "type": "string" + } + } + }, + "codersdk.ConnectionType": { + "type": "string", + "enum": [ + "ssh", + "vscode", + "jetbrains", + "reconnecting_pty", + "workspace_app", + "port_forwarding" + ], + "x-enum-varnames": [ + "ConnectionTypeSSH", + "ConnectionTypeVSCode", + "ConnectionTypeJetBrains", + "ConnectionTypeReconnectingPTY", + "ConnectionTypeWorkspaceApp", + "ConnectionTypePortForwarding" + ] + }, "codersdk.ConvertLoginRequest": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8485df8f2a745..f14b86b549065 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -323,6 +323,48 @@ } } }, + "/connectionlog": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get connection logs", + "operationId": "get-connection-logs", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ConnectionLogResponse" + } + } + } + } + }, "/csp/reports": { "post": { "security": [ @@ -10174,6 +10216,139 @@ } } }, + "codersdk.ConnectionLog": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + }, + "connect_time": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ip": { + "type": "string" + }, + "organization": { + "$ref": "#/definitions/codersdk.MinimalOrganization" + }, + "ssh_info": { + "description": "SSHInfo is only set when `type` is one of:\n- `ConnectionTypeSSH`\n- `ConnectionTypeReconnectingPTY`\n- `ConnectionTypeVSCode`\n- `ConnectionTypeJetBrains`", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ConnectionLogSSHInfo" + } + ] + }, + "type": { + "$ref": "#/definitions/codersdk.ConnectionType" + }, + "web_info": { + "description": "WebInfo is only set when `type` is one of:\n- `ConnectionTypePortForwarding`\n- `ConnectionTypeWorkspaceApp`", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ConnectionLogWebInfo" + } + ] + }, + "workspace_id": { + "type": "string", + "format": "uuid" + }, + "workspace_name": { + "type": "string" + }, + "workspace_owner_id": { + "type": "string", + "format": "uuid" + }, + "workspace_owner_username": { + "type": "string" + } + } + }, + "codersdk.ConnectionLogResponse": { + "type": "object", + "properties": { + "connection_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ConnectionLog" + } + }, + "count": { + "type": "integer" + } + } + }, + "codersdk.ConnectionLogSSHInfo": { + "type": "object", + "properties": { + "connection_id": { + "type": "string", + "format": "uuid" + }, + "disconnect_reason": { + "description": "DisconnectReason is omitted if a disconnect event with the same connection ID\nhas not yet been seen.", + "type": "string" + }, + "disconnect_time": { + "description": "DisconnectTime is omitted if a disconnect event with the same connection ID\nhas not yet been seen.", + "type": "string", + "format": "date-time" + }, + "exit_code": { + "description": "ExitCode is the exit code of the SSH session. It is omitted if a\ndisconnect event with the same connection ID has not yet been seen.", + "type": "integer" + } + } + }, + "codersdk.ConnectionLogWebInfo": { + "type": "object", + "properties": { + "slug_or_port": { + "type": "string" + }, + "status_code": { + "description": "StatusCode is the HTTP status code of the request.", + "type": "integer" + }, + "user": { + "description": "User is omitted if the connection event was from an unauthenticated user.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.User" + } + ] + }, + "user_agent": { + "type": "string" + } + } + }, + "codersdk.ConnectionType": { + "type": "string", + "enum": [ + "ssh", + "vscode", + "jetbrains", + "reconnecting_pty", + "workspace_app", + "port_forwarding" + ], + "x-enum-varnames": [ + "ConnectionTypeSSH", + "ConnectionTypeVSCode", + "ConnectionTypeJetBrains", + "ConnectionTypeReconnectingPTY", + "ConnectionTypeWorkspaceApp", + "ConnectionTypePortForwarding" + ] + }, "codersdk.ConvertLoginRequest": { "type": "object", "required": ["password", "to_type"], diff --git a/coderd/audit.go b/coderd/audit.go index 786707768c05e..e8d7c4dfe9bca 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -40,7 +40,7 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - page, ok := parsePagination(rw, r) + page, ok := ParsePagination(rw, r) if !ok { return } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index c0892aebdeb01..193ac3daa46bf 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -630,6 +630,19 @@ func (q *sqlQuerier) GetAuthorizedConnectionLogsOffset(ctx context.Context, arg query := fmt.Sprintf("-- name: GetAuthorizedConnectionLogsOffset :many\n%s", filtered) rows, err := q.db.QueryContext(ctx, query, + arg.OrganizationID, + arg.WorkspaceOwner, + arg.WorkspaceOwnerID, + arg.WorkspaceOwnerEmail, + arg.Type, + arg.UserID, + arg.Username, + arg.UserEmail, + arg.ConnectedAfter, + arg.ConnectedBefore, + arg.WorkspaceID, + arg.ConnectionID, + arg.Status, arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 298813276f902..a3d48e46b4fe7 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -32,6 +32,7 @@ import ( "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" ) @@ -2245,6 +2246,249 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { }) } +func TestConnectionLogsOffsetFilters(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + db, _ := dbtestutil.NewDB(t) + + orgA := dbfake.Organization(t, db).Do() + orgB := dbfake.Organization(t, db).Do() + + user1 := dbgen.User(t, db, database.User{ + Username: "user1", + Email: "user1@test.com", + }) + user2 := dbgen.User(t, db, database.User{ + Username: "user2", + Email: "user2@test.com", + }) + user3 := dbgen.User(t, db, database.User{ + Username: "user3", + Email: "user3@test.com", + }) + + ws1Tpl := dbgen.Template(t, db, database.Template{OrganizationID: orgA.Org.ID, CreatedBy: user1.ID}) + ws1 := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user1.ID, + OrganizationID: orgA.Org.ID, + TemplateID: ws1Tpl.ID, + }) + ws2Tpl := dbgen.Template(t, db, database.Template{OrganizationID: orgB.Org.ID, CreatedBy: user2.ID}) + ws2 := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user2.ID, + OrganizationID: orgB.Org.ID, + TemplateID: ws2Tpl.ID, + }) + + now := dbtime.Now() + log1ConnID := uuid.New() + log1 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-4 * time.Hour), + OrganizationID: ws1.OrganizationID, + WorkspaceOwnerID: ws1.OwnerID, + WorkspaceID: ws1.ID, + WorkspaceName: ws1.Name, + Type: database.ConnectionTypeWorkspaceApp, + ConnectionStatus: database.ConnectionStatusConnected, + UserID: uuid.NullUUID{UUID: user1.ID, Valid: true}, + UserAgent: sql.NullString{String: "Mozilla/5.0", Valid: true}, + SlugOrPort: sql.NullString{String: "code-server", Valid: true}, + ConnectionID: uuid.NullUUID{UUID: log1ConnID, Valid: true}, + }) + + log2ConnID := uuid.New() + log2 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-3 * time.Hour), + OrganizationID: ws1.OrganizationID, + WorkspaceOwnerID: ws1.OwnerID, + WorkspaceID: ws1.ID, + WorkspaceName: ws1.Name, + Type: database.ConnectionTypeVscode, + ConnectionStatus: database.ConnectionStatusConnected, + ConnectionID: uuid.NullUUID{UUID: log2ConnID, Valid: true}, + }) + + // Mark log2 as disconnected + log2 = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-2 * time.Hour), + ConnectionID: log2.ConnectionID, + WorkspaceID: ws1.ID, + WorkspaceOwnerID: ws1.OwnerID, + AgentName: log2.AgentName, + ConnectionStatus: database.ConnectionStatusDisconnected, + + OrganizationID: log2.OrganizationID, + }) + + log3ConnID := uuid.New() + log3 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-2 * time.Hour), + OrganizationID: ws2.OrganizationID, + WorkspaceOwnerID: ws2.OwnerID, + WorkspaceID: ws2.ID, + WorkspaceName: ws2.Name, + Type: database.ConnectionTypeSsh, + ConnectionStatus: database.ConnectionStatusConnected, + UserID: uuid.NullUUID{UUID: user2.ID, Valid: true}, + ConnectionID: uuid.NullUUID{UUID: log3ConnID, Valid: true}, + }) + + // Mark log3 as disconnected + log3 = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-1 * time.Hour), + ConnectionID: log3.ConnectionID, + WorkspaceOwnerID: log3.WorkspaceOwnerID, + WorkspaceID: ws2.ID, + AgentName: log3.AgentName, + ConnectionStatus: database.ConnectionStatusDisconnected, + + OrganizationID: log3.OrganizationID, + }) + + log4 := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-1 * time.Hour), + OrganizationID: ws2.OrganizationID, + WorkspaceOwnerID: ws2.OwnerID, + WorkspaceID: ws2.ID, + WorkspaceName: ws2.Name, + Type: database.ConnectionTypeVscode, + ConnectionStatus: database.ConnectionStatusConnected, + UserID: uuid.NullUUID{UUID: user3.ID, Valid: true}, + }) + + testCases := []struct { + name string + params database.GetConnectionLogsOffsetParams + expectedLogIDs []uuid.UUID + }{ + { + name: "NoFilter", + params: database.GetConnectionLogsOffsetParams{}, + expectedLogIDs: []uuid.UUID{ + log1.ID, log2.ID, log3.ID, log4.ID, + }, + }, + { + name: "OrganizationID", + params: database.GetConnectionLogsOffsetParams{ + OrganizationID: orgB.Org.ID, + }, + expectedLogIDs: []uuid.UUID{log3.ID, log4.ID}, + }, + { + name: "WorkspaceOwner", + params: database.GetConnectionLogsOffsetParams{ + WorkspaceOwner: user1.Username, + }, + expectedLogIDs: []uuid.UUID{log1.ID, log2.ID}, + }, + { + name: "WorkspaceOwnerID", + params: database.GetConnectionLogsOffsetParams{ + WorkspaceOwnerID: user1.ID, + }, + expectedLogIDs: []uuid.UUID{log1.ID, log2.ID}, + }, + { + name: "WorkspaceOwnerEmail", + params: database.GetConnectionLogsOffsetParams{ + WorkspaceOwnerEmail: user2.Email, + }, + expectedLogIDs: []uuid.UUID{log3.ID, log4.ID}, + }, + { + name: "Type", + params: database.GetConnectionLogsOffsetParams{ + Type: string(database.ConnectionTypeVscode), + }, + expectedLogIDs: []uuid.UUID{log2.ID, log4.ID}, + }, + { + name: "UserID", + params: database.GetConnectionLogsOffsetParams{ + UserID: user1.ID, + }, + expectedLogIDs: []uuid.UUID{log1.ID}, + }, + { + name: "Username", + params: database.GetConnectionLogsOffsetParams{ + Username: user1.Username, + }, + expectedLogIDs: []uuid.UUID{log1.ID}, + }, + { + name: "UserEmail", + params: database.GetConnectionLogsOffsetParams{ + UserEmail: user3.Email, + }, + expectedLogIDs: []uuid.UUID{log4.ID}, + }, + { + name: "ConnectedAfter", + params: database.GetConnectionLogsOffsetParams{ + ConnectedAfter: now.Add(-90 * time.Minute), // 1.5 hours ago + }, + expectedLogIDs: []uuid.UUID{log4.ID}, + }, + { + name: "ConnectedBefore", + params: database.GetConnectionLogsOffsetParams{ + ConnectedBefore: now.Add(-150 * time.Minute), + }, + expectedLogIDs: []uuid.UUID{log1.ID, log2.ID}, + }, + { + name: "WorkspaceID", + params: database.GetConnectionLogsOffsetParams{ + WorkspaceID: ws2.ID, + }, + expectedLogIDs: []uuid.UUID{log3.ID, log4.ID}, + }, + { + name: "ConnectionID", + params: database.GetConnectionLogsOffsetParams{ + ConnectionID: log1.ConnectionID.UUID, + }, + expectedLogIDs: []uuid.UUID{log1.ID}, + }, + { + name: "StatusOngoing", + params: database.GetConnectionLogsOffsetParams{ + Status: string(codersdk.ConnectionLogStatusOngoing), + }, + expectedLogIDs: []uuid.UUID{log4.ID}, + }, + { + name: "StatusCompleted", + params: database.GetConnectionLogsOffsetParams{ + Status: string(codersdk.ConnectionLogStatusCompleted), + }, + expectedLogIDs: []uuid.UUID{log2.ID, log3.ID}, + }, + { + name: "OrganizationAndTypeAndStatus", + params: database.GetConnectionLogsOffsetParams{ + OrganizationID: orgA.Org.ID, + Type: string(database.ConnectionTypeVscode), + Status: string(codersdk.ConnectionLogStatusCompleted), + }, + expectedLogIDs: []uuid.UUID{log2.ID}, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + logs, err := db.GetConnectionLogsOffset(ctx, tc.params) + require.NoError(t, err) + require.ElementsMatch(t, tc.expectedLogIDs, connectionOnlyIDs(logs)) + }) + } +} + func connectionOnlyIDs[T database.ConnectionLog | database.GetConnectionLogsOffsetRow](logs []T) []uuid.UUID { ids := make([]uuid.UUID, 0, len(logs)) for _, log := range logs { @@ -2313,7 +2557,7 @@ func TestUpsertConnectionLog(t *testing.T) { log1, err := db.UpsertConnectionLog(ctx, connectParams) require.NoError(t, err) require.Equal(t, connectParams.ID, log1.ID) - require.False(t, log1.DisconnectTime.Valid, "CloseTime should not be set on connect") + require.False(t, log1.DisconnectTime.Valid, "DisconnectTime should not be set on connect") // Check that one row exists. rows, err := db.GetConnectionLogsOffset(ctx, database.GetConnectionLogsOffsetParams{LimitOpt: 10}) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 23f7cf3bfbca0..cef983eb0f1b9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -910,7 +910,97 @@ LEFT JOIN users ON connection_logs.user_id = users.id JOIN organizations ON connection_logs.organization_id = organizations.id -WHERE TRUE +WHERE + -- Filter organization_id + CASE + WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.organization_id = $1 + ELSE true + END + -- Filter by workspace owner username + AND CASE + WHEN $2 :: text != '' THEN + workspace_owner_id = ( + SELECT id FROM users + WHERE lower(username) = lower($2) AND deleted = false + ) + ELSE true + END + -- Filter by workspace_owner_id + AND CASE + WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspace_owner_id = $3 + ELSE true + END + -- Filter by workspace_owner_email + AND CASE + WHEN $4 :: text != '' THEN + workspace_owner_id = ( + SELECT id FROM users + WHERE email = $4 AND deleted = false + ) + ELSE true + END + -- Filter by type + AND CASE + WHEN $5 :: text != '' THEN + type = $5 :: connection_type + ELSE true + END + -- Filter by user_id + AND CASE + WHEN $6 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + user_id = $6 + ELSE true + END + -- Filter by username + AND CASE + WHEN $7 :: text != '' THEN + user_id = ( + SELECT id FROM users + WHERE lower(username) = lower($7) AND deleted = false + ) + ELSE true + END + -- Filter by user_email + AND CASE + WHEN $8 :: text != '' THEN + users.email = $8 + ELSE true + END + -- Filter by connected_after + AND CASE + WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + connect_time >= $9 + ELSE true + END + -- Filter by connected_before + AND CASE + WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + connect_time <= $10 + ELSE true + END + -- Filter by workspace_id + AND CASE + WHEN $11 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.workspace_id = $11 + ELSE true + END + -- Filter by connection_id + AND CASE + WHEN $12 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.connection_id = $12 + ELSE true + END + -- Filter by whether the session has a disconnect_time + AND CASE + WHEN $13 :: text != '' THEN + (($13 = 'ongoing' AND disconnect_time IS NULL) OR + ($13 = 'completed' AND disconnect_time IS NOT NULL)) AND + -- Exclude web events, since we don't know their close time. + "type" NOT IN ('workspace_app', 'port_forwarding') + ELSE true + END -- Authorize Filter clause will be injected below in -- GetAuthorizedConnectionLogsOffset -- @authorize_filter @@ -920,14 +1010,27 @@ LIMIT -- a limit of 0 means "no limit". The connection log table is unbounded -- in size, and is expected to be quite large. Implement a default -- limit of 100 to prevent accidental excessively large queries. - COALESCE(NULLIF($2 :: int, 0), 100) + COALESCE(NULLIF($15 :: int, 0), 100) OFFSET - $1 + $14 ` type GetConnectionLogsOffsetParams struct { - OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` - LimitOpt int32 `db:"limit_opt" json:"limit_opt"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + WorkspaceOwner string `db:"workspace_owner" json:"workspace_owner"` + WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"` + WorkspaceOwnerEmail string `db:"workspace_owner_email" json:"workspace_owner_email"` + Type string `db:"type" json:"type"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Username string `db:"username" json:"username"` + UserEmail string `db:"user_email" json:"user_email"` + ConnectedAfter time.Time `db:"connected_after" json:"connected_after"` + ConnectedBefore time.Time `db:"connected_before" json:"connected_before"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + ConnectionID uuid.UUID `db:"connection_id" json:"connection_id"` + Status string `db:"status" json:"status"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } type GetConnectionLogsOffsetRow struct { @@ -951,7 +1054,23 @@ type GetConnectionLogsOffsetRow struct { } func (q *sqlQuerier) GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) { - rows, err := q.db.QueryContext(ctx, getConnectionLogsOffset, arg.OffsetOpt, arg.LimitOpt) + rows, err := q.db.QueryContext(ctx, getConnectionLogsOffset, + arg.OrganizationID, + arg.WorkspaceOwner, + arg.WorkspaceOwnerID, + arg.WorkspaceOwnerEmail, + arg.Type, + arg.UserID, + arg.Username, + arg.UserEmail, + arg.ConnectedAfter, + arg.ConnectedBefore, + arg.WorkspaceID, + arg.ConnectionID, + arg.Status, + arg.OffsetOpt, + arg.LimitOpt, + ) if err != nil { return nil, err } diff --git a/coderd/database/queries/connectionlogs.sql b/coderd/database/queries/connectionlogs.sql index 172a7c533d7d5..e3f231a6b738e 100644 --- a/coderd/database/queries/connectionlogs.sql +++ b/coderd/database/queries/connectionlogs.sql @@ -28,7 +28,97 @@ LEFT JOIN users ON connection_logs.user_id = users.id JOIN organizations ON connection_logs.organization_id = organizations.id -WHERE TRUE +WHERE + -- Filter organization_id + CASE + WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.organization_id = @organization_id + ELSE true + END + -- Filter by workspace owner username + AND CASE + WHEN @workspace_owner :: text != '' THEN + workspace_owner_id = ( + SELECT id FROM users + WHERE lower(username) = lower(@workspace_owner) AND deleted = false + ) + ELSE true + END + -- Filter by workspace_owner_id + AND CASE + WHEN @workspace_owner_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspace_owner_id = @workspace_owner_id + ELSE true + END + -- Filter by workspace_owner_email + AND CASE + WHEN @workspace_owner_email :: text != '' THEN + workspace_owner_id = ( + SELECT id FROM users + WHERE email = @workspace_owner_email AND deleted = false + ) + ELSE true + END + -- Filter by type + AND CASE + WHEN @type :: text != '' THEN + type = @type :: connection_type + ELSE true + END + -- Filter by user_id + AND CASE + WHEN @user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + user_id = @user_id + ELSE true + END + -- Filter by username + AND CASE + WHEN @username :: text != '' THEN + user_id = ( + SELECT id FROM users + WHERE lower(username) = lower(@username) AND deleted = false + ) + ELSE true + END + -- Filter by user_email + AND CASE + WHEN @user_email :: text != '' THEN + users.email = @user_email + ELSE true + END + -- Filter by connected_after + AND CASE + WHEN @connected_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + connect_time >= @connected_after + ELSE true + END + -- Filter by connected_before + AND CASE + WHEN @connected_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + connect_time <= @connected_before + ELSE true + END + -- Filter by workspace_id + AND CASE + WHEN @workspace_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.workspace_id = @workspace_id + ELSE true + END + -- Filter by connection_id + AND CASE + WHEN @connection_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + connection_logs.connection_id = @connection_id + ELSE true + END + -- Filter by whether the session has a disconnect_time + AND CASE + WHEN @status :: text != '' THEN + ((@status = 'ongoing' AND disconnect_time IS NULL) OR + (@status = 'completed' AND disconnect_time IS NOT NULL)) AND + -- Exclude web events, since we don't know their close time. + "type" NOT IN ('workspace_app', 'port_forwarding') + ELSE true + END -- Authorize Filter clause will be injected below in -- GetAuthorizedConnectionLogsOffset -- @authorize_filter diff --git a/coderd/members.go b/coderd/members.go index 5a031fe7eab90..0bd5bb1fbc8bd 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -195,7 +195,7 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() organization = httpmw.OrganizationParam(r) - paginationParams, ok = parsePagination(rw, r) + paginationParams, ok = ParsePagination(rw, r) ) if !ok { return diff --git a/coderd/pagination.go b/coderd/pagination.go index 0d01220d195e7..011f8df9e7bd4 100644 --- a/coderd/pagination.go +++ b/coderd/pagination.go @@ -9,9 +9,9 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// parsePagination extracts pagination query params from the http request. +// ParsePagination extracts pagination query params from the http request. // If an error is encountered, the error is written to w and ok is set to false. -func parsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Pagination, ok bool) { +func ParsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Pagination, ok bool) { ctx := r.Context() queryParams := r.URL.Query() parser := httpapi.NewQueryParamParser() diff --git a/coderd/pagination_internal_test.go b/coderd/pagination_test.go similarity index 96% rename from coderd/pagination_internal_test.go rename to coderd/pagination_test.go index 18d98c2fab319..f6e1aab7067f4 100644 --- a/coderd/pagination_internal_test.go +++ b/coderd/pagination_test.go @@ -1,4 +1,4 @@ -package coderd +package coderd_test import ( "context" @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/codersdk" ) @@ -123,7 +124,7 @@ func TestPagination(t *testing.T) { query.Set("offset", c.Offset) r.URL.RawQuery = query.Encode() - params, ok := parsePagination(rw, r) + params, ok := coderd.ParsePagination(rw, r) if c.ExpectedError == "" { require.True(t, ok, "expect ok") require.Equal(t, c.ExpectedParams, params, "expected params") diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 634d4b6632ed3..c17b3db77bdc5 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -86,6 +86,46 @@ func AuditLogs(ctx context.Context, db database.Store, query string) (database.G return filter, countFilter, parser.Errors } +func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey database.APIKey) (database.GetConnectionLogsOffsetParams, []codersdk.ValidationError) { + // Always lowercase for all searches. + query = strings.ToLower(query) + values, errors := searchTerms(query, func(term string, values url.Values) error { + values.Add("search", term) + return nil + }) + if len(errors) > 0 { + return database.GetConnectionLogsOffsetParams{}, errors + } + + parser := httpapi.NewQueryParamParser() + filter := database.GetConnectionLogsOffsetParams{ + OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), + WorkspaceOwner: parser.String(values, "", "workspace_owner"), + WorkspaceOwnerEmail: parser.String(values, "", "workspace_owner_email"), + Type: string(httpapi.ParseCustom(parser, values, "", "type", httpapi.ParseEnum[database.ConnectionType])), + Username: parser.String(values, "", "username"), + UserEmail: parser.String(values, "", "user_email"), + ConnectedAfter: parser.Time3339Nano(values, time.Time{}, "connected_after"), + ConnectedBefore: parser.Time3339Nano(values, time.Time{}, "connected_before"), + WorkspaceID: parser.UUID(values, uuid.Nil, "workspace_id"), + ConnectionID: parser.UUID(values, uuid.Nil, "connection_id"), + Status: string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[codersdk.ConnectionLogStatus])), + } + + if filter.Username == "me" { + filter.UserID = apiKey.UserID + filter.Username = "" + } + + if filter.WorkspaceOwner == "me" { + filter.WorkspaceOwnerID = apiKey.UserID + filter.WorkspaceOwner = "" + } + + parser.ErrorExcessParams(values) + return filter, parser.Errors +} + func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { // Always lowercase for all searches. query = strings.ToLower(query) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index ad5f2df966ef9..c251a4cd5bd90 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -408,6 +408,72 @@ func TestSearchAudit(t *testing.T) { } } +func TestSearchConnectionLogs(t *testing.T) { + t.Parallel() + t.Run("All", func(t *testing.T) { + t.Parallel() + + orgID := uuid.New() + workspaceOwnerID := uuid.New() + workspaceID := uuid.New() + connectionID := uuid.New() + + db, _ := dbtestutil.NewDB(t) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + Name: "testorg", + }) + dbgen.User(t, db, database.User{ + ID: workspaceOwnerID, + Username: "testowner", + Email: "owner@example.com", + }) + + query := fmt.Sprintf(`organization:testorg workspace_owner:testowner `+ + `workspace_owner_email:owner@example.com type:port_forwarding username:testuser `+ + `user_email:test@example.com connected_after:"2023-01-01T00:00:00Z" `+ + `connected_before:"2023-01-16T12:00:00+12:00" workspace_id:%s connection_id:%s status:ongoing`, + workspaceID.String(), connectionID.String()) + + values, errs := searchquery.ConnectionLogs(context.Background(), db, query, database.APIKey{}) + require.Len(t, errs, 0) + + expected := database.GetConnectionLogsOffsetParams{ + OrganizationID: orgID, + WorkspaceOwner: "testowner", + WorkspaceOwnerEmail: "owner@example.com", + Type: string(database.ConnectionTypePortForwarding), + Username: "testuser", + UserEmail: "test@example.com", + ConnectedAfter: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + ConnectedBefore: time.Date(2023, 1, 16, 0, 0, 0, 0, time.UTC), + WorkspaceID: workspaceID, + ConnectionID: connectionID, + Status: string(codersdk.ConnectionLogStatusOngoing), + } + + require.Equal(t, expected, values) + }) + + t.Run("Me", func(t *testing.T) { + t.Parallel() + + userID := uuid.New() + db, _ := dbtestutil.NewDB(t) + + query := `username:me workspace_owner:me` + values, errs := searchquery.ConnectionLogs(context.Background(), db, query, database.APIKey{UserID: userID}) + require.Len(t, errs, 0) + + expected := database.GetConnectionLogsOffsetParams{ + UserID: userID, + WorkspaceOwnerID: userID, + } + + require.Equal(t, expected, values) + }) +} + func TestSearchUsers(t *testing.T) { t.Parallel() testCases := []struct { diff --git a/coderd/templateversions.go b/coderd/templateversions.go index fa5a7ed1fe757..de069b5ca4723 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -807,7 +807,7 @@ func (api *API) templateVersionsByTemplate(rw http.ResponseWriter, r *http.Reque ctx := r.Context() template := httpmw.TemplateParam(r) - paginationParams, ok := parsePagination(rw, r) + paginationParams, ok := ParsePagination(rw, r) if !ok { return } diff --git a/coderd/users.go b/coderd/users.go index e2f6fd79c7d75..7fbb8e7d04cdf 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -290,7 +290,7 @@ func (api *API) GetUsers(rw http.ResponseWriter, r *http.Request) ([]database.Us return nil, -1, false } - paginationParams, ok := parsePagination(rw, r) + paginationParams, ok := ParsePagination(rw, r) if !ok { return nil, -1, false } diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index c8b1008280b09..88774c63368ca 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -119,7 +119,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspace := httpmw.WorkspaceParam(r) - paginationParams, ok := parsePagination(rw, r) + paginationParams, ok := ParsePagination(rw, r) if !ok { return } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index ecb624d1bc09f..05eae8f5145e6 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -146,7 +146,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - page, ok := parsePagination(rw, r) + page, ok := ParsePagination(rw, r) if !ok { return } diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index f44c19b998e21..a78ee3c5608dd 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -37,18 +37,6 @@ import ( // log-source. This should be removed in the future. var ExternalLogSourceID = uuid.MustParse("3b579bf4-1ed8-4b99-87a8-e9a1e3410410") -// ConnectionType is the type of connection that the agent is receiving. -type ConnectionType string - -// Connection type enums. -const ( - ConnectionTypeUnspecified ConnectionType = "Unspecified" - ConnectionTypeSSH ConnectionType = "SSH" - ConnectionTypeVSCode ConnectionType = "VS Code" - ConnectionTypeJetBrains ConnectionType = "JetBrains" - ConnectionTypeReconnectingPTY ConnectionType = "Web Terminal" -) - // New returns a client that is used to interact with the // Coder API from a workspace agent. func New(serverURL *url.URL) *Client { diff --git a/codersdk/connectionlog.go b/codersdk/connectionlog.go new file mode 100644 index 0000000000000..9dd78694b4e08 --- /dev/null +++ b/codersdk/connectionlog.go @@ -0,0 +1,126 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" + "net/netip" + "strings" + "time" + + "github.com/google/uuid" +) + +type ConnectionLog struct { + ID uuid.UUID `json:"id" format:"uuid"` + ConnectTime time.Time `json:"connect_time" format:"date-time"` + Organization MinimalOrganization `json:"organization"` + WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"` + WorkspaceOwnerUsername string `json:"workspace_owner_username"` + WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"` + WorkspaceName string `json:"workspace_name"` + AgentName string `json:"agent_name"` + IP netip.Addr `json:"ip"` + Type ConnectionType `json:"type"` + + // WebInfo is only set when `type` is one of: + // - `ConnectionTypePortForwarding` + // - `ConnectionTypeWorkspaceApp` + WebInfo *ConnectionLogWebInfo `json:"web_info,omitempty"` + + // SSHInfo is only set when `type` is one of: + // - `ConnectionTypeSSH` + // - `ConnectionTypeReconnectingPTY` + // - `ConnectionTypeVSCode` + // - `ConnectionTypeJetBrains` + SSHInfo *ConnectionLogSSHInfo `json:"ssh_info,omitempty"` +} + +// ConnectionType is the type of connection that the agent is receiving. +type ConnectionType string + +const ( + ConnectionTypeSSH ConnectionType = "ssh" + ConnectionTypeVSCode ConnectionType = "vscode" + ConnectionTypeJetBrains ConnectionType = "jetbrains" + ConnectionTypeReconnectingPTY ConnectionType = "reconnecting_pty" + ConnectionTypeWorkspaceApp ConnectionType = "workspace_app" + ConnectionTypePortForwarding ConnectionType = "port_forwarding" +) + +// ConnectionLogStatus is the status of a connection log entry. +// It's the argument to the `status` filter when fetching connection logs. +type ConnectionLogStatus string + +const ( + ConnectionLogStatusOngoing ConnectionLogStatus = "ongoing" + ConnectionLogStatusCompleted ConnectionLogStatus = "completed" +) + +func (s ConnectionLogStatus) Valid() bool { + switch s { + case ConnectionLogStatusOngoing, ConnectionLogStatusCompleted: + return true + default: + return false + } +} + +type ConnectionLogWebInfo struct { + UserAgent string `json:"user_agent"` + // User is omitted if the connection event was from an unauthenticated user. + User *User `json:"user"` + SlugOrPort string `json:"slug_or_port"` + // StatusCode is the HTTP status code of the request. + StatusCode int32 `json:"status_code"` +} + +type ConnectionLogSSHInfo struct { + ConnectionID uuid.UUID `json:"connection_id" format:"uuid"` + // DisconnectTime is omitted if a disconnect event with the same connection ID + // has not yet been seen. + DisconnectTime *time.Time `json:"disconnect_time,omitempty" format:"date-time"` + // DisconnectReason is omitted if a disconnect event with the same connection ID + // has not yet been seen. + DisconnectReason string `json:"disconnect_reason,omitempty"` + // ExitCode is the exit code of the SSH session. It is omitted if a + // disconnect event with the same connection ID has not yet been seen. + ExitCode *int32 `json:"exit_code,omitempty"` +} + +type ConnectionLogsRequest struct { + SearchQuery string `json:"q,omitempty"` + Pagination +} + +type ConnectionLogResponse struct { + ConnectionLogs []ConnectionLog `json:"connection_logs"` + Count int64 `json:"count"` +} + +func (c *Client) ConnectionLogs(ctx context.Context, req ConnectionLogsRequest) (ConnectionLogResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/connectionlog", nil, req.Pagination.asRequestOption(), func(r *http.Request) { + q := r.URL.Query() + var params []string + if req.SearchQuery != "" { + params = append(params, req.SearchQuery) + } + q.Set("q", strings.Join(params, " ")) + r.URL.RawQuery = q.Encode() + }) + if err != nil { + return ConnectionLogResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return ConnectionLogResponse{}, ReadBodyAsError(res) + } + + var logRes ConnectionLogResponse + err = json.NewDecoder(res.Body).Decode(&logRes) + if err != nil { + return ConnectionLogResponse{}, err + } + return logRes, nil +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 70821aa64f063..38e22bd85e277 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -207,6 +207,98 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get connection logs + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/connectionlog?limit=0 \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /connectionlog` + +### Parameters + +| Name | In | Type | Required | Description | +|----------|-------|---------|----------|--------------| +| `q` | query | string | false | Search query | +| `limit` | query | integer | true | Page limit | +| `offset` | query | integer | false | Page offset | + +### Example responses + +> 200 Response + +```json +{ + "connection_logs": [ + { + "agent_name": "string", + "connect_time": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "ip": "string", + "organization": { + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "ssh_info": { + "connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9", + "disconnect_reason": "string", + "disconnect_time": "2019-08-24T14:15:22Z", + "exit_code": 0 + }, + "type": "ssh", + "web_info": { + "slug_or_port": "string", + "status_code": 0, + "user": { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + }, + "user_agent": "string" + }, + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string", + "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", + "workspace_owner_username": "string" + } + ], + "count": 0 +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ConnectionLogResponse](schemas.md#codersdkconnectionlogresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get entitlements ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 3788d97753457..0000d93548008 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1085,6 +1085,228 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `p50` | number | false | | | | `p95` | number | false | | | +## codersdk.ConnectionLog + +```json +{ + "agent_name": "string", + "connect_time": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "ip": "string", + "organization": { + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "ssh_info": { + "connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9", + "disconnect_reason": "string", + "disconnect_time": "2019-08-24T14:15:22Z", + "exit_code": 0 + }, + "type": "ssh", + "web_info": { + "slug_or_port": "string", + "status_code": 0, + "user": { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + }, + "user_agent": "string" + }, + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string", + "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", + "workspace_owner_username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------|----------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent_name` | string | false | | | +| `connect_time` | string | false | | | +| `id` | string | false | | | +| `ip` | string | false | | | +| `organization` | [codersdk.MinimalOrganization](#codersdkminimalorganization) | false | | | +| `ssh_info` | [codersdk.ConnectionLogSSHInfo](#codersdkconnectionlogsshinfo) | false | | Ssh info is only set when `type` is one of: - `ConnectionTypeSSH` - `ConnectionTypeReconnectingPTY` - `ConnectionTypeVSCode` - `ConnectionTypeJetBrains` | +| `type` | [codersdk.ConnectionType](#codersdkconnectiontype) | false | | | +| `web_info` | [codersdk.ConnectionLogWebInfo](#codersdkconnectionlogwebinfo) | false | | Web info is only set when `type` is one of: - `ConnectionTypePortForwarding` - `ConnectionTypeWorkspaceApp` | +| `workspace_id` | string | false | | | +| `workspace_name` | string | false | | | +| `workspace_owner_id` | string | false | | | +| `workspace_owner_username` | string | false | | | + +## codersdk.ConnectionLogResponse + +```json +{ + "connection_logs": [ + { + "agent_name": "string", + "connect_time": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "ip": "string", + "organization": { + "display_name": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "ssh_info": { + "connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9", + "disconnect_reason": "string", + "disconnect_time": "2019-08-24T14:15:22Z", + "exit_code": 0 + }, + "type": "ssh", + "web_info": { + "slug_or_port": "string", + "status_code": 0, + "user": { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + }, + "user_agent": "string" + }, + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string", + "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", + "workspace_owner_username": "string" + } + ], + "count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------|-----------------------------------------------------------|----------|--------------|-------------| +| `connection_logs` | array of [codersdk.ConnectionLog](#codersdkconnectionlog) | false | | | +| `count` | integer | false | | | + +## codersdk.ConnectionLogSSHInfo + +```json +{ + "connection_id": "d3547de1-d1f2-4344-b4c2-17169b7526f9", + "disconnect_reason": "string", + "disconnect_time": "2019-08-24T14:15:22Z", + "exit_code": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------| +| `connection_id` | string | false | | | +| `disconnect_reason` | string | false | | Disconnect reason is omitted if a disconnect event with the same connection ID has not yet been seen. | +| `disconnect_time` | string | false | | Disconnect time is omitted if a disconnect event with the same connection ID has not yet been seen. | +| `exit_code` | integer | false | | Exit code is the exit code of the SSH session. It is omitted if a disconnect event with the same connection ID has not yet been seen. | + +## codersdk.ConnectionLogWebInfo + +```json +{ + "slug_or_port": "string", + "status_code": 0, + "user": { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "organization_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + }, + "user_agent": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|--------------------------------|----------|--------------|---------------------------------------------------------------------------| +| `slug_or_port` | string | false | | | +| `status_code` | integer | false | | Status code is the HTTP status code of the request. | +| `user` | [codersdk.User](#codersdkuser) | false | | User is omitted if the connection event was from an unauthenticated user. | +| `user_agent` | string | false | | | + +## codersdk.ConnectionType + +```json +"ssh" +``` + +### Properties + +#### Enumerated Values + +| Value | +|--------------------| +| `ssh` | +| `vscode` | +| `jetbrains` | +| `reconnecting_pty` | +| `workspace_app` | +| `port_forwarding` | + ## codersdk.ConvertLoginRequest ```json diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 6d523e9226b88..0d176567713a2 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -226,6 +226,13 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Use(apiKeyMiddleware) r.Get("/", api.replicas) }) + r.Route("/connectionlog", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + api.RequireFeatureMW(codersdk.FeatureConnectionLog), + ) + r.Get("/", api.connectionLogs) + }) r.Route("/licenses", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Post("/refresh-entitlements", api.postRefreshEntitlements) diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index e4088e83d09f5..54dcb9c582628 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -59,6 +59,7 @@ func init() { type Options struct { *coderdtest.Options + ConnectionLogging bool AuditLogging bool BrowserOnly bool EntitlementsUpdateInterval time.Duration @@ -100,6 +101,7 @@ func NewWithAPI(t *testing.T, options *Options) ( setHandler, cancelFunc, serverURL, oop := coderdtest.NewOptions(t, options.Options) coderAPI, err := coderd.New(context.Background(), &coderd.Options{ RBAC: true, + ConnectionLogging: options.ConnectionLogging, AuditLogging: options.AuditLogging, BrowserOnly: options.BrowserOnly, SCIMAPIKey: options.SCIMAPIKey, diff --git a/enterprise/coderd/connectionlog.go b/enterprise/coderd/connectionlog.go new file mode 100644 index 0000000000000..75413b82708fb --- /dev/null +++ b/enterprise/coderd/connectionlog.go @@ -0,0 +1,149 @@ +package coderd + +import ( + "net/http" + "net/netip" + + "github.com/google/uuid" + + agpl "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/searchquery" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Get connection logs +// @ID get-connection-logs +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param q query string false "Search query" +// @Param limit query int true "Page limit" +// @Param offset query int false "Page offset" +// @Success 200 {object} codersdk.ConnectionLogResponse +// @Router /connectionlog [get] +func (api *API) connectionLogs(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + page, ok := agpl.ParsePagination(rw, r) + if !ok { + return + } + + queryStr := r.URL.Query().Get("q") + filter, errs := searchquery.ConnectionLogs(ctx, api.Database, queryStr, apiKey) + if len(errs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid connection search query.", + Validations: errs, + }) + return + } + // #nosec G115 - Safe conversion as pagination offset is expected to be within int32 range + filter.OffsetOpt = int32(page.Offset) + // #nosec G115 - Safe conversion as pagination limit is expected to be within int32 range + filter.LimitOpt = int32(page.Limit) + + dblogs, err := api.Database.GetConnectionLogsOffset(ctx, filter) + if dbauthz.IsNotAuthorizedError(err) { + httpapi.Forbidden(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ConnectionLogResponse{ + ConnectionLogs: convertConnectionLogs(dblogs), + Count: 0, // TODO(ethanndickson): Set count + }) +} + +func convertConnectionLogs(dblogs []database.GetConnectionLogsOffsetRow) []codersdk.ConnectionLog { + clogs := make([]codersdk.ConnectionLog, 0, len(dblogs)) + + for _, dblog := range dblogs { + clogs = append(clogs, convertConnectionLog(dblog)) + } + return clogs +} + +func convertConnectionLog(dblog database.GetConnectionLogsOffsetRow) codersdk.ConnectionLog { + ip, _ := netip.AddrFromSlice(dblog.ConnectionLog.Ip.IPNet.IP) + + var user *codersdk.User + if dblog.ConnectionLog.UserID.Valid { + sdkUser := db2sdk.User(database.User{ + ID: dblog.ConnectionLog.UserID.UUID, + Email: dblog.UserEmail.String, + Username: dblog.UserUsername.String, + CreatedAt: dblog.UserCreatedAt.Time, + UpdatedAt: dblog.UserUpdatedAt.Time, + Status: dblog.UserStatus.UserStatus, + RBACRoles: dblog.UserRoles, + LoginType: dblog.UserLoginType.LoginType, + AvatarURL: dblog.UserAvatarUrl.String, + Deleted: dblog.UserDeleted.Bool, + LastSeenAt: dblog.UserLastSeenAt.Time, + QuietHoursSchedule: dblog.UserQuietHoursSchedule.String, + Name: dblog.UserName.String, + }, []uuid.UUID{}) + user = &sdkUser + } + + var ( + webInfo *codersdk.ConnectionLogWebInfo + sshInfo *codersdk.ConnectionLogSSHInfo + ) + + switch dblog.ConnectionLog.Type { + case database.ConnectionTypeWorkspaceApp, + database.ConnectionTypePortForwarding: + webInfo = &codersdk.ConnectionLogWebInfo{ + UserAgent: dblog.ConnectionLog.UserAgent.String, + User: user, + SlugOrPort: dblog.ConnectionLog.SlugOrPort.String, + StatusCode: dblog.ConnectionLog.Code.Int32, + } + case database.ConnectionTypeSsh, + database.ConnectionTypeReconnectingPty, + database.ConnectionTypeJetbrains, + database.ConnectionTypeVscode: + sshInfo = &codersdk.ConnectionLogSSHInfo{ + ConnectionID: dblog.ConnectionLog.ConnectionID.UUID, + DisconnectReason: dblog.ConnectionLog.DisconnectReason.String, + } + if dblog.ConnectionLog.DisconnectTime.Valid { + sshInfo.DisconnectTime = &dblog.ConnectionLog.DisconnectTime.Time + } + if dblog.ConnectionLog.Code.Valid { + sshInfo.ExitCode = &dblog.ConnectionLog.Code.Int32 + } + } + + return codersdk.ConnectionLog{ + ID: dblog.ConnectionLog.ID, + ConnectTime: dblog.ConnectionLog.ConnectTime, + Organization: codersdk.MinimalOrganization{ + ID: dblog.ConnectionLog.OrganizationID, + Name: dblog.OrganizationName, + DisplayName: dblog.OrganizationDisplayName, + Icon: dblog.OrganizationIcon, + }, + WorkspaceOwnerID: dblog.ConnectionLog.WorkspaceOwnerID, + WorkspaceOwnerUsername: dblog.WorkspaceOwnerUsername, + WorkspaceID: dblog.ConnectionLog.WorkspaceID, + WorkspaceName: dblog.ConnectionLog.WorkspaceName, + AgentName: dblog.ConnectionLog.AgentName, + Type: codersdk.ConnectionType(dblog.ConnectionLog.Type), + IP: ip, + WebInfo: webInfo, + SSHInfo: sshInfo, + } +} diff --git a/enterprise/coderd/connectionlog_test.go b/enterprise/coderd/connectionlog_test.go new file mode 100644 index 0000000000000..b94b2449f37c4 --- /dev/null +++ b/enterprise/coderd/connectionlog_test.go @@ -0,0 +1,251 @@ +package coderd_test + +import ( + "context" + "database/sql" + "fmt" + "net" + "testing" + "time" + + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" +) + +func TestConnectionLogs(t *testing.T) { + t.Parallel() + + createWorkspace := func(t *testing.T, db database.Store) database.WorkspaceTable { + u := dbgen.User(t, db, database.User{}) + o := dbgen.Organization(t, db, database.Organization{}) + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: o.ID, + CreatedBy: u.ID, + }) + return dbgen.Workspace(t, db, database.WorkspaceTable{ + ID: uuid.New(), + OwnerID: u.ID, + OrganizationID: o.ID, + AutomaticUpdates: database.AutomaticUpdatesNever, + TemplateID: tpl.ID, + }) + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ConnectionLogging: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + codersdk.FeatureConnectionLog: 1, + }, + }, + }) + + ws := createWorkspace(t, db) + _ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + }) + + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.Equal(t, codersdk.ConnectionTypeSSH, logs.ConnectionLogs[0].Type) + }) + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, _, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ConnectionLogging: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + codersdk.FeatureConnectionLog: 1, + }, + }, + }) + + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 0) + }) + + t.Run("ByOrganizationIDAndName", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ConnectionLogging: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + codersdk.FeatureConnectionLog: 1, + }, + }, + }) + + org := dbgen.Organization(t, db, database.Organization{}) + ws := createWorkspace(t, db) + _ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: org.ID, + WorkspaceOwnerID: ws.OwnerID, + }) + _ = dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + }) + + // By name + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{ + SearchQuery: fmt.Sprintf("organization:%s", org.Name), + }) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.Equal(t, org.ID, logs.ConnectionLogs[0].Organization.ID) + + // By ID + logs, err = client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{ + SearchQuery: fmt.Sprintf("organization:%s", ws.OrganizationID), + }) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.Equal(t, ws.OrganizationID, logs.ConnectionLogs[0].Organization.ID) + }) + + t.Run("WebInfo", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ConnectionLogging: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + codersdk.FeatureConnectionLog: 1, + }, + }, + }) + + now := dbtime.Now() + connID := uuid.New() + ws := createWorkspace(t, db) + clog := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-time.Hour), + Type: database.ConnectionTypeWorkspaceApp, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + ConnectionID: uuid.NullUUID{UUID: connID, Valid: true}, + UserAgent: sql.NullString{String: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36", Valid: true}, + UserID: uuid.NullUUID{UUID: ws.OwnerID, Valid: true}, + SlugOrPort: sql.NullString{String: "code-server", Valid: true}, + }) + + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.NotNil(t, logs.ConnectionLogs[0].WebInfo) + require.Equal(t, clog.SlugOrPort.String, logs.ConnectionLogs[0].WebInfo.SlugOrPort) + require.Equal(t, clog.UserAgent.String, logs.ConnectionLogs[0].WebInfo.UserAgent) + require.Equal(t, ws.OwnerID, logs.ConnectionLogs[0].WebInfo.User.ID) + }) + + t.Run("SSHInfo", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + ConnectionLogging: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAuditLog: 1, + codersdk.FeatureConnectionLog: 1, + }, + }, + }) + + now := dbtime.Now() + connID := uuid.New() + ws := createWorkspace(t, db) + clog := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now.Add(-time.Hour), + Type: database.ConnectionTypeSsh, + WorkspaceID: ws.ID, + OrganizationID: ws.OrganizationID, + WorkspaceOwnerID: ws.OwnerID, + ConnectionID: uuid.NullUUID{UUID: connID, Valid: true}, + }) + + logs, err := client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.NotNil(t, logs.ConnectionLogs[0].SSHInfo) + require.Empty(t, logs.ConnectionLogs[0].WebInfo) + require.Empty(t, logs.ConnectionLogs[0].SSHInfo.ExitCode) + require.Empty(t, logs.ConnectionLogs[0].SSHInfo.DisconnectTime) + require.Empty(t, logs.ConnectionLogs[0].SSHInfo.DisconnectReason) + + // Mark log as closed + updatedClog := dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ + Time: now, + OrganizationID: clog.OrganizationID, + Type: clog.Type, + WorkspaceID: clog.WorkspaceID, + WorkspaceOwnerID: clog.WorkspaceOwnerID, + WorkspaceName: clog.WorkspaceName, + AgentName: clog.AgentName, + Code: sql.NullInt32{ + Int32: 0, + Valid: false, + }, + Ip: pqtype.Inet{IPNet: net.IPNet{ + IP: net.ParseIP("192.168.0.1"), + Mask: net.CIDRMask(8, 32), + }, Valid: true}, + + ConnectionID: clog.ConnectionID, + ConnectionStatus: database.ConnectionStatusDisconnected, + DisconnectReason: sql.NullString{ + String: "example close reason", + Valid: true, + }, + }) + + logs, err = client.ConnectionLogs(ctx, codersdk.ConnectionLogsRequest{}) + require.NoError(t, err) + + require.Len(t, logs.ConnectionLogs, 1) + require.NotNil(t, logs.ConnectionLogs[0].SSHInfo) + require.Nil(t, logs.ConnectionLogs[0].WebInfo) + require.Equal(t, codersdk.ConnectionTypeSSH, logs.ConnectionLogs[0].Type) + require.Equal(t, clog.ConnectionID.UUID, logs.ConnectionLogs[0].SSHInfo.ConnectionID) + require.True(t, logs.ConnectionLogs[0].SSHInfo.DisconnectTime.Equal(now)) + require.Equal(t, updatedClog.DisconnectReason.String, logs.ConnectionLogs[0].SSHInfo.DisconnectReason) + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 23a739df063de..0b6148f796f6b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -322,6 +322,75 @@ export interface ConnectionLatency { readonly p95: number; } +// From codersdk/connectionlog.go +export interface ConnectionLog { + readonly id: string; + readonly connect_time: string; + readonly organization: MinimalOrganization; + readonly workspace_owner_id: string; + readonly workspace_owner_username: string; + readonly workspace_id: string; + readonly workspace_name: string; + readonly agent_name: string; + readonly ip: string; + readonly type: ConnectionType; + readonly web_info?: ConnectionLogWebInfo; + readonly ssh_info?: ConnectionLogSSHInfo; +} + +// From codersdk/connectionlog.go +export interface ConnectionLogResponse { + readonly connection_logs: readonly ConnectionLog[]; + readonly count: number; +} + +// From codersdk/connectionlog.go +export interface ConnectionLogSSHInfo { + readonly connection_id: string; + readonly disconnect_time?: string; + readonly disconnect_reason?: string; + readonly exit_code?: number; +} + +// From codersdk/connectionlog.go +export type ConnectionLogStatus = "completed" | "ongoing"; + +export const ConnectionLogStatuses: ConnectionLogStatus[] = [ + "completed", + "ongoing", +]; + +// From codersdk/connectionlog.go +export interface ConnectionLogWebInfo { + readonly user_agent: string; + readonly user: User | null; + readonly slug_or_port: string; + readonly status_code: number; +} + +// From codersdk/connectionlog.go +export interface ConnectionLogsRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/connectionlog.go +export type ConnectionType = + | "jetbrains" + | "port_forwarding" + | "reconnecting_pty" + | "ssh" + | "vscode" + | "workspace_app"; + +export const ConnectionTypes: ConnectionType[] = [ + "jetbrains", + "port_forwarding", + "reconnecting_pty", + "ssh", + "vscode", + "workspace_app", +]; + // From codersdk/files.go export const ContentTypeTar = "application/x-tar"; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy