feat: add last used to Workspaces page (#3816)

This commit is contained in:
Ammar Bandukwala
2022-09-01 19:08:51 -05:00
committed by GitHub
parent 80e9f24ac7
commit 04b03792cb
20 changed files with 156 additions and 11 deletions

View File

@ -105,7 +105,7 @@ func TestAgent(t *testing.T) {
var ok bool var ok bool
s, ok = (<-stats) s, ok = (<-stats)
return ok && s.NumConns > 0 && s.RxBytes > 0 && s.TxBytes > 0 return ok && s.NumConns > 0 && s.RxBytes > 0 && s.TxBytes > 0
}, testutil.WaitShort, testutil.IntervalFast, }, testutil.WaitLong, testutil.IntervalFast,
"never saw stats: %+v", s, "never saw stats: %+v", s,
) )
}) })

View File

@ -2208,6 +2208,22 @@ func (q *fakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW
return sql.ErrNoRows return sql.ErrNoRows
} }
func (q *fakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.UpdateWorkspaceLastUsedAtParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
for index, workspace := range q.workspaces {
if workspace.ID != arg.ID {
continue
}
workspace.LastUsedAt = arg.LastUsedAt
q.workspaces[index] = workspace
return nil
}
return sql.ErrNoRows
}
func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.UpdateWorkspaceBuildByIDParams) error { func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.UpdateWorkspaceBuildByIDParams) error {
q.mutex.Lock() q.mutex.Lock()
defer q.mutex.Unlock() defer q.mutex.Unlock()

View File

@ -377,7 +377,8 @@ CREATE TABLE workspaces (
deleted boolean DEFAULT false NOT NULL, deleted boolean DEFAULT false NOT NULL,
name character varying(64) NOT NULL, name character varying(64) NOT NULL,
autostart_schedule text, autostart_schedule text,
ttl bigint ttl bigint,
last_used_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL
); );
ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass); ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass);

View File

@ -0,0 +1,2 @@
ALTER TABLE workspaces
DROP COLUMN last_used_at;

View File

@ -0,0 +1,2 @@
ALTER TABLE workspaces
ADD COLUMN last_used_at timestamp NOT NULL DEFAULT '0001-01-01 00:00:00+00:00';

View File

@ -525,6 +525,7 @@ type Workspace struct {
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"`
Ttl sql.NullInt64 `db:"ttl" json:"ttl"` Ttl sql.NullInt64 `db:"ttl" json:"ttl"`
LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"`
} }
type WorkspaceAgent struct { type WorkspaceAgent struct {

View File

@ -150,6 +150,7 @@ type querier interface {
UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error
UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
} }

View File

@ -4562,7 +4562,7 @@ func (q *sqlQuerier) InsertWorkspaceResourceMetadata(ctx context.Context, arg In
const getWorkspaceByID = `-- name: GetWorkspaceByID :one const getWorkspaceByID = `-- name: GetWorkspaceByID :one
SELECT SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
FROM FROM
workspaces workspaces
WHERE WHERE
@ -4585,13 +4585,14 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp
&i.Name, &i.Name,
&i.AutostartSchedule, &i.AutostartSchedule,
&i.Ttl, &i.Ttl,
&i.LastUsedAt,
) )
return i, err return i, err
} }
const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one
SELECT SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
FROM FROM
workspaces workspaces
WHERE WHERE
@ -4621,6 +4622,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo
&i.Name, &i.Name,
&i.AutostartSchedule, &i.AutostartSchedule,
&i.Ttl, &i.Ttl,
&i.LastUsedAt,
) )
return i, err return i, err
} }
@ -4669,7 +4671,7 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i
const getWorkspaces = `-- name: GetWorkspaces :many const getWorkspaces = `-- name: GetWorkspaces :many
SELECT SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
FROM FROM
workspaces workspaces
WHERE WHERE
@ -4745,6 +4747,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
&i.Name, &i.Name,
&i.AutostartSchedule, &i.AutostartSchedule,
&i.Ttl, &i.Ttl,
&i.LastUsedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -4761,7 +4764,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
const getWorkspacesAutostart = `-- name: GetWorkspacesAutostart :many const getWorkspacesAutostart = `-- name: GetWorkspacesAutostart :many
SELECT SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
FROM FROM
workspaces workspaces
WHERE WHERE
@ -4794,6 +4797,7 @@ func (q *sqlQuerier) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, e
&i.Name, &i.Name,
&i.AutostartSchedule, &i.AutostartSchedule,
&i.Ttl, &i.Ttl,
&i.LastUsedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -4822,7 +4826,7 @@ INSERT INTO
ttl ttl
) )
VALUES VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
` `
type InsertWorkspaceParams struct { type InsertWorkspaceParams struct {
@ -4861,6 +4865,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar
&i.Name, &i.Name,
&i.AutostartSchedule, &i.AutostartSchedule,
&i.Ttl, &i.Ttl,
&i.LastUsedAt,
) )
return i, err return i, err
} }
@ -4873,7 +4878,7 @@ SET
WHERE WHERE
id = $1 id = $1
AND deleted = false AND deleted = false
RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
` `
type UpdateWorkspaceParams struct { type UpdateWorkspaceParams struct {
@ -4895,6 +4900,7 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar
&i.Name, &i.Name,
&i.AutostartSchedule, &i.AutostartSchedule,
&i.Ttl, &i.Ttl,
&i.LastUsedAt,
) )
return i, err return i, err
} }
@ -4937,6 +4943,25 @@ func (q *sqlQuerier) UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateW
return err return err
} }
const updateWorkspaceLastUsedAt = `-- name: UpdateWorkspaceLastUsedAt :exec
UPDATE
workspaces
SET
last_used_at = $2
WHERE
id = $1
`
type UpdateWorkspaceLastUsedAtParams struct {
ID uuid.UUID `db:"id" json:"id"`
LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"`
}
func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceLastUsedAt, arg.ID, arg.LastUsedAt)
return err
}
const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec
UPDATE UPDATE
workspaces workspaces

View File

@ -137,3 +137,11 @@ SET
ttl = $2 ttl = $2
WHERE WHERE
id = $1; id = $1;
-- name: UpdateWorkspaceLastUsedAt :exec
UPDATE
workspaces
SET
last_used_at = $2
WHERE
id = $1;

View File

@ -608,6 +608,10 @@ func TestTemplateDAUs(t *testing.T) {
Entries: []codersdk.DAUEntry{}, Entries: []codersdk.DAUEntry{},
}, daus, "no DAUs when stats are empty") }, daus, "no DAUs when stats are empty")
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{})
require.NoError(t, err)
assert.Zero(t, workspaces[0].LastUsedAt)
conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, opts) conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, opts)
require.NoError(t, err) require.NoError(t, err)
defer func() { defer func() {
@ -641,4 +645,10 @@ func TestTemplateDAUs(t *testing.T) {
testutil.WaitShort, testutil.IntervalFast, testutil.WaitShort, testutil.IntervalFast,
"got %+v != %+v", daus, want, "got %+v != %+v", daus, want,
) )
workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{})
require.NoError(t, err)
assert.WithinDuration(t,
time.Now(), workspaces[0].LastUsedAt, time.Minute,
)
} }

View File

@ -830,18 +830,20 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques
// We will see duplicate reports when on idle connections // We will see duplicate reports when on idle connections
// (e.g. web terminal left open) or when there are no connections at // (e.g. web terminal left open) or when there are no connections at
// all. // all.
var insert = !reflect.DeepEqual(lastReport, rep) // We also don't want to update the workspace last used at on duplicate
// reports.
var updateDB = !reflect.DeepEqual(lastReport, rep)
api.Logger.Debug(ctx, "read stats report", api.Logger.Debug(ctx, "read stats report",
slog.F("interval", api.AgentStatsRefreshInterval), slog.F("interval", api.AgentStatsRefreshInterval),
slog.F("agent", workspaceAgent.ID), slog.F("agent", workspaceAgent.ID),
slog.F("resource", resource.ID), slog.F("resource", resource.ID),
slog.F("workspace", workspace.ID), slog.F("workspace", workspace.ID),
slog.F("insert", insert), slog.F("update_db", updateDB),
slog.F("payload", rep), slog.F("payload", rep),
) )
if insert { if updateDB {
lastReport = rep lastReport = rep
_, err = api.Database.InsertAgentStat(ctx, database.InsertAgentStatParams{ _, err = api.Database.InsertAgentStat(ctx, database.InsertAgentStatParams{
@ -860,6 +862,18 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques
}) })
return return
} }
err = api.Database.UpdateWorkspaceLastUsedAt(ctx, database.UpdateWorkspaceLastUsedAtParams{
ID: build.WorkspaceID,
LastUsedAt: time.Now(),
})
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to update workspace last used at.",
Detail: err.Error(),
})
return
}
} }
select { select {

View File

@ -941,6 +941,7 @@ func convertWorkspace(
Name: workspace.Name, Name: workspace.Name,
AutostartSchedule: autostartSchedule, AutostartSchedule: autostartSchedule,
TTLMillis: ttlMillis, TTLMillis: ttlMillis,
LastUsedAt: workspace.LastUsedAt,
} }
} }

View File

@ -30,6 +30,7 @@ type Workspace struct {
Name string `json:"name"` Name string `json:"name"`
AutostartSchedule *string `json:"autostart_schedule,omitempty"` AutostartSchedule *string `json:"autostart_schedule,omitempty"`
TTLMillis *int64 `json:"ttl_ms,omitempty"` TTLMillis *int64 `json:"ttl_ms,omitempty"`
LastUsedAt time.Time `json:"last_used_at"`
} }
// CreateWorkspaceBuildRequest provides options to update the latest workspace build. // CreateWorkspaceBuildRequest provides options to update the latest workspace build.

View File

@ -95,6 +95,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"name": ActionTrack, "name": ActionTrack,
"autostart_schedule": ActionTrack, "autostart_schedule": ActionTrack,
"ttl": ActionTrack, "ttl": ActionTrack,
"last_used_at": ActionIgnore,
}, },
}) })

View File

@ -514,6 +514,7 @@ export interface Workspace {
readonly name: string readonly name: string
readonly autostart_schedule?: string readonly autostart_schedule?: string
readonly ttl_ms?: number readonly ttl_ms?: number
readonly last_used_at: string
} }
// From codersdk/workspaceresources.go // From codersdk/workspaceresources.go

View File

@ -0,0 +1,39 @@
import { Theme, useTheme } from "@material-ui/core/styles"
import { FC } from "react"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
dayjs.extend(relativeTime)
interface WorkspaceLastUsedProps {
lastUsedAt: string
}
export const WorkspaceLastUsed: FC<WorkspaceLastUsedProps> = ({ lastUsedAt }) => {
const theme: Theme = useTheme()
const t = dayjs(lastUsedAt)
const now = dayjs()
let color = theme.palette.text.secondary
let message = t.fromNow()
if (t.isAfter(now.subtract(1, "hour"))) {
color = theme.palette.success.main
// Since the agent reports on a 10m interval,
// the last_used_at can be inaccurate when recent.
message = "In the last hour"
} else if (t.isAfter(now.subtract(1, "day"))) {
color = theme.palette.primary.main
} else if (t.isAfter(now.subtract(1, "month"))) {
color = theme.palette.text.secondary
} else if (t.isAfter(now.subtract(100, "year"))) {
color = theme.palette.warning.light
} else {
color = theme.palette.error.light
message = "Never"
}
return <span style={{ color: color }}>{message}</span>
}

View File

@ -15,6 +15,7 @@ import {
} from "../TableCellData/TableCellData" } from "../TableCellData/TableCellData"
import { TableCellLink } from "../TableCellLink/TableCellLink" import { TableCellLink } from "../TableCellLink/TableCellLink"
import { OutdatedHelpTooltip } from "../Tooltips" import { OutdatedHelpTooltip } from "../Tooltips"
import { WorkspaceLastUsed } from "./WorkspaceLastUsed"
const Language = { const Language = {
upToDateLabel: "Up to date", upToDateLabel: "Up to date",
@ -64,6 +65,12 @@ export const WorkspacesRow: FC<
} }
/> />
</TableCellLink> </TableCellLink>
<TableCellLink to={workspacePageLink}>
<TableCellData>
<WorkspaceLastUsed lastUsedAt={workspace.last_used_at} />
</TableCellData>
</TableCellLink>
<TableCellLink to={workspacePageLink}> <TableCellLink to={workspacePageLink}>
{workspace.outdated ? ( {workspace.outdated ? (
<span className={styles.outdatedLabel}> <span className={styles.outdatedLabel}>

View File

@ -11,6 +11,7 @@ import { WorkspacesTableBody } from "./WorkspacesTableBody"
const Language = { const Language = {
name: "Name", name: "Name",
template: "Template", template: "Template",
lastUsed: "Last Used",
version: "Version", version: "Version",
status: "Status", status: "Status",
lastBuiltBy: "Last Built By", lastBuiltBy: "Last Built By",
@ -34,6 +35,7 @@ export const WorkspacesTable: FC<React.PropsWithChildren<WorkspacesTableProps>>
<TableRow> <TableRow>
<TableCell width="25%">{Language.name}</TableCell> <TableCell width="25%">{Language.name}</TableCell>
<TableCell width="35%">{Language.template}</TableCell> <TableCell width="35%">{Language.template}</TableCell>
<TableCell width="20%">{Language.lastUsed}</TableCell>
<TableCell width="20%">{Language.version}</TableCell> <TableCell width="20%">{Language.version}</TableCell>
<TableCell width="20%">{Language.status}</TableCell> <TableCell width="20%">{Language.status}</TableCell>
<TableCell width="1%"></TableCell> <TableCell width="1%"></TableCell>

View File

@ -1,4 +1,5 @@
import { ComponentMeta, Story } from "@storybook/react" import { ComponentMeta, Story } from "@storybook/react"
import dayjs from "dayjs"
import { spawn } from "xstate" import { spawn } from "xstate"
import { ProvisionerJobStatus, WorkspaceTransition } from "../../api/typesGenerated" import { ProvisionerJobStatus, WorkspaceTransition } from "../../api/typesGenerated"
import { MockWorkspace } from "../../testHelpers/entities" import { MockWorkspace } from "../../testHelpers/entities"
@ -13,6 +14,7 @@ const createWorkspaceItemRef = (
status: ProvisionerJobStatus, status: ProvisionerJobStatus,
transition: WorkspaceTransition = "start", transition: WorkspaceTransition = "start",
outdated = false, outdated = false,
lastUsedAt = "0001-01-01",
): WorkspaceItemMachineRef => { ): WorkspaceItemMachineRef => {
return spawn( return spawn(
workspaceItemMachine.withContext({ workspaceItemMachine.withContext({
@ -27,6 +29,7 @@ const createWorkspaceItemRef = (
status: status, status: status,
}, },
}, },
last_used_at: lastUsedAt,
}, },
}), }),
) )
@ -48,6 +51,15 @@ const additionalWorkspaces: Record<string, WorkspaceItemMachineRef> = {
succeededAndStop: createWorkspaceItemRef("succeeded", "stop"), succeededAndStop: createWorkspaceItemRef("succeeded", "stop"),
runningAndDelete: createWorkspaceItemRef("running", "delete"), runningAndDelete: createWorkspaceItemRef("running", "delete"),
outdated: createWorkspaceItemRef("running", "delete", true), outdated: createWorkspaceItemRef("running", "delete", true),
active: createWorkspaceItemRef("running", undefined, true, dayjs().toString()),
today: createWorkspaceItemRef("running", undefined, true, dayjs().subtract(3, "hour").toString()),
old: createWorkspaceItemRef("running", undefined, true, dayjs().subtract(1, "week").toString()),
veryOld: createWorkspaceItemRef(
"running",
undefined,
true,
dayjs().subtract(1, "month").subtract(4, "day").toString(),
),
} }
export default { export default {

View File

@ -236,6 +236,7 @@ export const MockWorkspace: TypesGen.Workspace = {
autostart_schedule: MockWorkspaceAutostartEnabled.schedule, autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours as milliseconds ttl_ms: 2 * 60 * 60 * 1000, // 2 hours as milliseconds
latest_build: MockWorkspaceBuild, latest_build: MockWorkspaceBuild,
last_used_at: "",
} }
export const MockStoppedWorkspace: TypesGen.Workspace = { export const MockStoppedWorkspace: TypesGen.Workspace = {