Add Users Last Seen At (#4192)

This commit is contained in:
Ammar Bandukwala 2022-09-26 10:31:03 -05:00 committed by GitHub
parent b8ec5c786d
commit ee4b934601
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 154 additions and 17 deletions

View File

@ -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()

View File

@ -317,7 +317,8 @@ CREATE TABLE users (
rbac_roles text[] DEFAULT '{}'::text[] NOT NULL,
login_type login_type DEFAULT 'password'::public.login_type NOT NULL,
avatar_url text,
deleted boolean DEFAULT false NOT NULL
deleted boolean DEFAULT false NOT NULL,
last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL
);
CREATE TABLE workspace_agents (

View File

@ -0,0 +1,2 @@
ALTER TABLE ONLY users
DROP COLUMN last_seen_at;

View File

@ -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';

View File

@ -549,6 +549,7 @@ type User struct {
LoginType LoginType `db:"login_type" json:"login_type"`
AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"`
Deleted bool `db:"deleted" json:"deleted"`
LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"`
}
type UserLink struct {

View File

@ -141,6 +141,7 @@ type querier interface {
UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error
UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error
UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error
UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error)
UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error)
UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error)

View File

@ -3067,7 +3067,7 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.
const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
FROM
users
WHERE
@ -3098,13 +3098,14 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
FROM
users
WHERE
@ -3128,6 +3129,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
)
return i, err
}
@ -3148,7 +3150,7 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) {
const getUsers = `-- name: GetUsers :many
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
FROM
users
WHERE
@ -3246,6 +3248,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User,
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
); err != nil {
return nil, err
}
@ -3261,7 +3264,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User,
}
const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted FROM users WHERE id = ANY($1 :: uuid [ ]) AND deleted = $2
SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at FROM users WHERE id = ANY($1 :: uuid [ ]) AND deleted = $2
`
type GetUsersByIDsParams struct {
@ -3290,6 +3293,7 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, arg GetUsersByIDsParams)
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
); err != nil {
return nil, err
}
@ -3317,7 +3321,7 @@ INSERT INTO
login_type
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
`
type InsertUserParams struct {
@ -3355,6 +3359,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
)
return i, err
}
@ -3397,6 +3402,42 @@ func (q *sqlQuerier) UpdateUserHashedPassword(ctx context.Context, arg UpdateUse
return err
}
const updateUserLastSeenAt = `-- name: UpdateUserLastSeenAt :one
UPDATE
users
SET
last_seen_at = $2,
updated_at = $3
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
`
type UpdateUserLastSeenAtParams struct {
ID uuid.UUID `db:"id" json:"id"`
LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) {
row := q.db.QueryRowContext(ctx, updateUserLastSeenAt, arg.ID, arg.LastSeenAt, arg.UpdatedAt)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.HashedPassword,
&i.CreatedAt,
&i.UpdatedAt,
&i.Status,
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
)
return i, err
}
const updateUserProfile = `-- name: UpdateUserProfile :one
UPDATE
users
@ -3406,7 +3447,7 @@ SET
avatar_url = $4,
updated_at = $5
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
`
type UpdateUserProfileParams struct {
@ -3438,6 +3479,7 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
)
return i, err
}
@ -3450,7 +3492,7 @@ SET
rbac_roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[]))
WHERE
id = $2
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
`
type UpdateUserRolesParams struct {
@ -3473,6 +3515,7 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
)
return i, err
}
@ -3484,7 +3527,7 @@ SET
status = $2,
updated_at = $3
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at
`
type UpdateUserStatusParams struct {
@ -3508,6 +3551,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
&i.LastSeenAt,
)
return i, err
}

View File

@ -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

View File

@ -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.

View File

@ -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,

View File

@ -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()

View File

@ -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"`

View File

@ -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{}: {

View File

@ -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[]

View File

@ -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()

View File

@ -14,6 +14,7 @@ export const Language = {
usernameLabel: "User",
rolesLabel: "Roles",
statusLabel: "Status",
lastSeenLabel: "Last Seen",
}
export interface UsersTableProps {
@ -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>

View File

@ -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"
@ -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

View File

@ -8,10 +8,10 @@ import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceS
import { FC } from "react"
import { useNavigate } from "react-router-dom"
import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService"
import { LastUsed } from "../LastUsed/LastUsed"
import { TableCellData, TableCellDataPrimary } from "../TableCellData/TableCellData"
import { TableCellLink } from "../TableCellLink/TableCellLink"
import { OutdatedHelpTooltip } from "../Tooltips"
import { WorkspaceLastUsed } from "./WorkspaceLastUsed"
const Language = {
upToDateLabel: "Up to date",
@ -61,7 +61,7 @@ export const WorkspacesRow: FC<
</TableCellLink>
<TableCellLink to={workspacePageLink}>
<TableCellData>
<WorkspaceLastUsed lastUsedAt={workspace.last_used_at} />
<LastUsed lastUsedAt={workspace.last_used_at} />
</TableCellData>
</TableCellLink>

View File

@ -38,6 +38,7 @@ describe("AccountPage", () => {
organization_ids: ["123"],
roles: [],
avatar_url: "",
last_seen_at: new Date().toString(),
...data,
}),
)

View File

@ -71,6 +71,7 @@ export const MockUser: TypesGen.User = {
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
roles: [MockOwnerRole],
avatar_url: "https://github.com/coder.png",
last_seen_at: "",
}
export const MockUserAdmin: TypesGen.User = {
@ -82,6 +83,7 @@ export const MockUserAdmin: TypesGen.User = {
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
roles: [MockUserAdminRole],
avatar_url: "",
last_seen_at: "",
}
export const MockUser2: TypesGen.User = {
@ -93,6 +95,7 @@ export const MockUser2: TypesGen.User = {
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
roles: [],
avatar_url: "",
last_seen_at: "2022-09-14T19:12:21Z",
}
export const SuspendedMockUser: TypesGen.User = {
@ -104,6 +107,7 @@ export const SuspendedMockUser: TypesGen.User = {
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
roles: [],
avatar_url: "",
last_seen_at: "",
}
export const MockOrganization: TypesGen.Organization = {