Skip to content

Add Users Last Seen At #4192

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Sep 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions coderd/database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -1956,6 +1956,22 @@ func (q *fakeQuerier) UpdateUserStatus(_ context.Context, arg database.UpdateUse
return database.User{}, sql.ErrNoRows
}

func (q *fakeQuerier) UpdateUserLastSeenAt(_ context.Context, arg database.UpdateUserLastSeenAtParams) (database.User, error) {
q.mutex.Lock()
defer q.mutex.Unlock()

for index, user := range q.users {
if user.ID != arg.ID {
continue
}
user.LastSeenAt = arg.LastSeenAt
user.UpdatedAt = arg.UpdatedAt
q.users[index] = user
return user, nil
}
return database.User{}, sql.ErrNoRows
}

func (q *fakeQuerier) UpdateUserHashedPassword(_ context.Context, arg database.UpdateUserHashedPasswordParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
Expand Down
3 changes: 2 additions & 1 deletion coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coderd/database/migrations/000053_last_seen_at.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE ONLY users
DROP COLUMN last_seen_at;
2 changes: 2 additions & 0 deletions coderd/database/migrations/000053_last_seen_at.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE ONLY users
ADD COLUMN last_seen_at timestamp NOT NULL DEFAULT '0001-01-01 00:00:00+00:00';
1 change: 1 addition & 0 deletions coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 52 additions & 8 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions coderd/database/queries/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ SET
WHERE
id = $1 RETURNING *;

-- name: UpdateUserLastSeenAt :one
UPDATE
users
SET
last_seen_at = $2,
updated_at = $3
WHERE
id = $1 RETURNING *;


-- name: GetAuthorizationUserRoles :one
-- This function returns roles for authorization purposes. Implied member roles
Expand Down
16 changes: 16 additions & 0 deletions coderd/httpmw/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,22 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
return
}
}

// We only want to update this occasionally to reduce DB write
// load. We update alongside the UserLink and APIKey since it's
// easier on the DB to colocate writes.
_, err = cfg.DB.UpdateUserLastSeenAt(ctx, database.UpdateUserLastSeenAtParams{
ID: key.UserID,
LastSeenAt: database.Now(),
UpdatedAt: database.Now(),
})
if err != nil {
write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("update user last_seen_at: %s", err.Error()),
})
return
}
}

// If the key is valid, we also fetch the user roles and status.
Expand Down
1 change: 1 addition & 0 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -1210,6 +1210,7 @@ func convertUser(user database.User, organizationIDs []uuid.UUID) codersdk.User
ID: user.ID,
Email: user.Email,
CreatedAt: user.CreatedAt,
LastSeenAt: user.LastSeenAt,
Username: user.Username,
Status: codersdk.UserStatus(user.Status),
OrganizationIDs: organizationIDs,
Expand Down
29 changes: 29 additions & 0 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,35 @@ func TestFirstUser(t *testing.T) {
_ = coderdtest.CreateFirstUser(t, client)
})

t.Run("LastSeenAt", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

client := coderdtest.New(t, nil)
firstUserResp := coderdtest.CreateFirstUser(t, client)

firstUser, err := client.User(ctx, firstUserResp.UserID.String())
require.NoError(t, err)

_ = coderdtest.CreateAnotherUser(t, client, firstUserResp.OrganizationID)

allUsers, err := client.Users(ctx, codersdk.UsersRequest{})
require.NoError(t, err)

require.Len(t, allUsers, 2)

// We sent the "GET Users" request with the first user, but the second user
// should be Never since they haven't performed a request.
for _, user := range allUsers {
if user.ID == firstUser.ID {
require.WithinDuration(t, firstUser.LastSeenAt, database.Now(), testutil.WaitShort)
} else {
require.Zero(t, user.LastSeenAt)
}
}
})

t.Run("AutoImportsTemplates", func(t *testing.T) {
t.Parallel()

Expand Down
10 changes: 6 additions & 4 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ type UsersRequest struct {

// User represents a user in Coder.
type User struct {
ID uuid.UUID `json:"id" validate:"required" table:"id"`
Username string `json:"username" validate:"required" table:"username"`
Email string `json:"email" validate:"required" table:"email"`
CreatedAt time.Time `json:"created_at" validate:"required" table:"created at"`
ID uuid.UUID `json:"id" validate:"required" table:"id"`
Username string `json:"username" validate:"required" table:"username"`
Email string `json:"email" validate:"required" table:"email"`
CreatedAt time.Time `json:"created_at" validate:"required" table:"created at"`
LastSeenAt time.Time `json:"last_seen_at"`

Status UserStatus `json:"status" table:"status"`
OrganizationIDs []uuid.UUID `json:"organization_ids"`
Roles []Role `json:"roles"`
Expand Down
1 change: 1 addition & 0 deletions enterprise/audit/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"rbac_roles": ActionTrack,
"login_type": ActionIgnore,
"avatar_url": ActionIgnore,
"last_seen_at": ActionIgnore,
"deleted": ActionTrack,
},
&database.Workspace{}: {
Expand Down
1 change: 1 addition & 0 deletions site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ export interface User {
readonly username: string
readonly email: string
readonly created_at: string
readonly last_seen_at: string
readonly status: UserStatus
readonly organization_ids: string[]
readonly roles: Role[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { colors } from "theme/colors"

dayjs.extend(relativeTime)

interface WorkspaceLastUsedProps {
interface LastUsedProps {
lastUsedAt: string
}

export const WorkspaceLastUsed: FC<WorkspaceLastUsedProps> = ({ lastUsedAt }) => {
export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
const theme: Theme = useTheme()
const styles = useStyles()

Expand Down
2 changes: 2 additions & 0 deletions site/src/components/UsersTable/UsersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const Language = {
usernameLabel: "User",
rolesLabel: "Roles",
statusLabel: "Status",
lastSeenLabel: "Last Seen",
}

export interface UsersTableProps {
Expand Down Expand Up @@ -50,6 +51,7 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
<TableRow>
<TableCell width="50%">{Language.usernameLabel}</TableCell>
<TableCell width="25%">{Language.statusLabel}</TableCell>
<TableCell width="50%">{Language.lastSeenLabel}</TableCell>
<TableCell width="25%">
<Stack direction="row" spacing={1} alignItems="center">
<span>{Language.rolesLabel}</span>
Expand Down
4 changes: 4 additions & 0 deletions site/src/components/UsersTable/UsersTableBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Box from "@material-ui/core/Box"
import { makeStyles } from "@material-ui/core/styles"
import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow"
import { LastUsed } from "components/LastUsed/LastUsed"
import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated"
import { combineClasses } from "../../util/combineClasses"
Expand Down Expand Up @@ -101,6 +102,9 @@ export const UsersTableBody: FC<React.PropsWithChildren<UsersTableBodyProps>> =
>
{user.status}
</TableCell>
<TableCell>
<LastUsed lastUsedAt={user.last_seen_at} />
</TableCell>
<TableCell>
{canEditUsers ? (
<RoleSelect
Expand Down
Loading