Skip to content

Commit ee4b934

Browse files
authored
Add Users Last Seen At (#4192)
1 parent b8ec5c7 commit ee4b934

File tree

20 files changed

+154
-17
lines changed

20 files changed

+154
-17
lines changed

coderd/database/databasefake/databasefake.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1956,6 +1956,22 @@ func (q *fakeQuerier) UpdateUserStatus(_ context.Context, arg database.UpdateUse
19561956
return database.User{}, sql.ErrNoRows
19571957
}
19581958

1959+
func (q *fakeQuerier) UpdateUserLastSeenAt(_ context.Context, arg database.UpdateUserLastSeenAtParams) (database.User, error) {
1960+
q.mutex.Lock()
1961+
defer q.mutex.Unlock()
1962+
1963+
for index, user := range q.users {
1964+
if user.ID != arg.ID {
1965+
continue
1966+
}
1967+
user.LastSeenAt = arg.LastSeenAt
1968+
user.UpdatedAt = arg.UpdatedAt
1969+
q.users[index] = user
1970+
return user, nil
1971+
}
1972+
return database.User{}, sql.ErrNoRows
1973+
}
1974+
19591975
func (q *fakeQuerier) UpdateUserHashedPassword(_ context.Context, arg database.UpdateUserHashedPasswordParams) error {
19601976
q.mutex.Lock()
19611977
defer q.mutex.Unlock()

coderd/database/dump.sql

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE ONLY users
2+
DROP COLUMN last_seen_at;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE ONLY users
2+
ADD COLUMN last_seen_at timestamp NOT NULL DEFAULT '0001-01-01 00:00:00+00:00';

coderd/database/models.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/querier.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 52 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/users.sql

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,15 @@ SET
157157
WHERE
158158
id = $1 RETURNING *;
159159

160+
-- name: UpdateUserLastSeenAt :one
161+
UPDATE
162+
users
163+
SET
164+
last_seen_at = $2,
165+
updated_at = $3
166+
WHERE
167+
id = $1 RETURNING *;
168+
160169

161170
-- name: GetAuthorizationUserRoles :one
162171
-- This function returns roles for authorization purposes. Implied member roles

coderd/httpmw/apikey.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,22 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
317317
return
318318
}
319319
}
320+
321+
// We only want to update this occasionally to reduce DB write
322+
// load. We update alongside the UserLink and APIKey since it's
323+
// easier on the DB to colocate writes.
324+
_, err = cfg.DB.UpdateUserLastSeenAt(ctx, database.UpdateUserLastSeenAtParams{
325+
ID: key.UserID,
326+
LastSeenAt: database.Now(),
327+
UpdatedAt: database.Now(),
328+
})
329+
if err != nil {
330+
write(http.StatusInternalServerError, codersdk.Response{
331+
Message: internalErrorMessage,
332+
Detail: fmt.Sprintf("update user last_seen_at: %s", err.Error()),
333+
})
334+
return
335+
}
320336
}
321337

322338
// If the key is valid, we also fetch the user roles and status.

coderd/users.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,7 @@ func convertUser(user database.User, organizationIDs []uuid.UUID) codersdk.User
12101210
ID: user.ID,
12111211
Email: user.Email,
12121212
CreatedAt: user.CreatedAt,
1213+
LastSeenAt: user.LastSeenAt,
12131214
Username: user.Username,
12141215
Status: codersdk.UserStatus(user.Status),
12151216
OrganizationIDs: organizationIDs,

coderd/users_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,35 @@ func TestFirstUser(t *testing.T) {
6565
_ = coderdtest.CreateFirstUser(t, client)
6666
})
6767

68+
t.Run("LastSeenAt", func(t *testing.T) {
69+
t.Parallel()
70+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
71+
defer cancel()
72+
73+
client := coderdtest.New(t, nil)
74+
firstUserResp := coderdtest.CreateFirstUser(t, client)
75+
76+
firstUser, err := client.User(ctx, firstUserResp.UserID.String())
77+
require.NoError(t, err)
78+
79+
_ = coderdtest.CreateAnotherUser(t, client, firstUserResp.OrganizationID)
80+
81+
allUsers, err := client.Users(ctx, codersdk.UsersRequest{})
82+
require.NoError(t, err)
83+
84+
require.Len(t, allUsers, 2)
85+
86+
// We sent the "GET Users" request with the first user, but the second user
87+
// should be Never since they haven't performed a request.
88+
for _, user := range allUsers {
89+
if user.ID == firstUser.ID {
90+
require.WithinDuration(t, firstUser.LastSeenAt, database.Now(), testutil.WaitShort)
91+
} else {
92+
require.Zero(t, user.LastSeenAt)
93+
}
94+
}
95+
})
96+
6897
t.Run("AutoImportsTemplates", func(t *testing.T) {
6998
t.Parallel()
7099

codersdk/users.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@ type UsersRequest struct {
4343

4444
// User represents a user in Coder.
4545
type User struct {
46-
ID uuid.UUID `json:"id" validate:"required" table:"id"`
47-
Username string `json:"username" validate:"required" table:"username"`
48-
Email string `json:"email" validate:"required" table:"email"`
49-
CreatedAt time.Time `json:"created_at" validate:"required" table:"created at"`
46+
ID uuid.UUID `json:"id" validate:"required" table:"id"`
47+
Username string `json:"username" validate:"required" table:"username"`
48+
Email string `json:"email" validate:"required" table:"email"`
49+
CreatedAt time.Time `json:"created_at" validate:"required" table:"created at"`
50+
LastSeenAt time.Time `json:"last_seen_at"`
51+
5052
Status UserStatus `json:"status" table:"status"`
5153
OrganizationIDs []uuid.UUID `json:"organization_ids"`
5254
Roles []Role `json:"roles"`

enterprise/audit/table.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
8484
"rbac_roles": ActionTrack,
8585
"login_type": ActionIgnore,
8686
"avatar_url": ActionIgnore,
87+
"last_seen_at": ActionIgnore,
8788
"deleted": ActionTrack,
8889
},
8990
&database.Workspace{}: {

site/src/api/typesGenerated.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ export interface User {
510510
readonly username: string
511511
readonly email: string
512512
readonly created_at: string
513+
readonly last_seen_at: string
513514
readonly status: UserStatus
514515
readonly organization_ids: string[]
515516
readonly roles: Role[]

site/src/components/WorkspacesTable/WorkspaceLastUsed.tsx renamed to site/src/components/LastUsed/LastUsed.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ import { colors } from "theme/colors"
88

99
dayjs.extend(relativeTime)
1010

11-
interface WorkspaceLastUsedProps {
11+
interface LastUsedProps {
1212
lastUsedAt: string
1313
}
1414

15-
export const WorkspaceLastUsed: FC<WorkspaceLastUsedProps> = ({ lastUsedAt }) => {
15+
export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
1616
const theme: Theme = useTheme()
1717
const styles = useStyles()
1818

site/src/components/UsersTable/UsersTable.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const Language = {
1414
usernameLabel: "User",
1515
rolesLabel: "Roles",
1616
statusLabel: "Status",
17+
lastSeenLabel: "Last Seen",
1718
}
1819

1920
export interface UsersTableProps {
@@ -50,6 +51,7 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
5051
<TableRow>
5152
<TableCell width="50%">{Language.usernameLabel}</TableCell>
5253
<TableCell width="25%">{Language.statusLabel}</TableCell>
54+
<TableCell width="50%">{Language.lastSeenLabel}</TableCell>
5355
<TableCell width="25%">
5456
<Stack direction="row" spacing={1} alignItems="center">
5557
<span>{Language.rolesLabel}</span>

site/src/components/UsersTable/UsersTableBody.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Box from "@material-ui/core/Box"
22
import { makeStyles } from "@material-ui/core/styles"
33
import TableCell from "@material-ui/core/TableCell"
44
import TableRow from "@material-ui/core/TableRow"
5+
import { LastUsed } from "components/LastUsed/LastUsed"
56
import { FC } from "react"
67
import * as TypesGen from "../../api/typesGenerated"
78
import { combineClasses } from "../../util/combineClasses"
@@ -101,6 +102,9 @@ export const UsersTableBody: FC<React.PropsWithChildren<UsersTableBodyProps>> =
101102
>
102103
{user.status}
103104
</TableCell>
105+
<TableCell>
106+
<LastUsed lastUsedAt={user.last_seen_at} />
107+
</TableCell>
104108
<TableCell>
105109
{canEditUsers ? (
106110
<RoleSelect

0 commit comments

Comments
 (0)