mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add connectionlogs API (#18628)
This is the second PR for moving connection events out of the audit log.
This PR:
- Adds the `/api/v2/connectionlog` endpoint
- Adds filtering for `GetAuthorizedConnectionLogsOffset` and thus the endpoint.
There's quite a few, but I was aiming for feature parity with the audit log.
1. `organization:<id|name>`
2. `workspace_owner:<username>`
3. `workspace_owner_email:<email>`
4. `type:<ssh|vscode|jetbrains|reconnecting_pty|workspace_app|port_forwarding>`
5. `username:<username>`
- Only includes web-based connection events (workspace apps, web port forwarding) as only those include user metadata.
6. `user_email:<email>`
7. `connected_after:<time>`
8. `connected_before:<time>`
9. `workspace_id:<id>`
10. `connection_id:<id>`
- If you have one snapshot of the connection log, and some sessions are ongoing in that snapshot, you could use this filter to check if they've been closed since.
11. `status:<connected|disconnected>`
- If `connected` only sessions with a null `close_time` are returned, if `disconnected`, only those with a non-null `close_time`. If filter is omitted, both are returned.
Future PRs:
- Populate `count` on `ConnectionLogResponse` using a seperate query (to preemptively mitigate the issue described in #17689)
- Implement a table in the Web UI for viewing connection logs.
- Write a query to delete old events from the audit log, call it from dbpurge.
- Write documentation for the endpoint / feature (including these filters)
This commit is contained in:
Generated
+179
@@ -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": [
|
||||
|
||||
Generated
+175
@@ -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"],
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user