From 04b03792cbf8f31551b59e9c1947a8d85d660133 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Thu, 1 Sep 2022 19:08:51 -0500 Subject: [PATCH] feat: add last used to Workspaces page (#3816) --- agent/agent_test.go | 2 +- coderd/database/databasefake/databasefake.go | 16 ++++++++ coderd/database/dump.sql | 3 +- .../000043_workspace_last_used.down.sql | 2 + .../000043_workspace_last_used.up.sql | 2 + coderd/database/models.go | 1 + coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 37 +++++++++++++++--- coderd/database/queries/workspaces.sql | 8 ++++ coderd/templates_test.go | 10 +++++ coderd/workspaceagents.go | 20 ++++++++-- coderd/workspaces.go | 1 + codersdk/workspaces.go | 1 + enterprise/audit/table.go | 1 + site/src/api/typesGenerated.ts | 1 + .../WorkspacesTable/WorkspaceLastUsed.tsx | 39 +++++++++++++++++++ .../WorkspacesTable/WorkspacesRow.tsx | 7 ++++ .../WorkspacesTable/WorkspacesTable.tsx | 2 + .../WorkspacesPageView.stories.tsx | 12 ++++++ site/src/testHelpers/entities.ts | 1 + 20 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 coderd/database/migrations/000043_workspace_last_used.down.sql create mode 100644 coderd/database/migrations/000043_workspace_last_used.up.sql create mode 100644 site/src/components/WorkspacesTable/WorkspaceLastUsed.tsx diff --git a/agent/agent_test.go b/agent/agent_test.go index a2a4fc28c4..49f57214ab 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -105,7 +105,7 @@ func TestAgent(t *testing.T) { var ok bool s, ok = (<-stats) return ok && s.NumConns > 0 && s.RxBytes > 0 && s.TxBytes > 0 - }, testutil.WaitShort, testutil.IntervalFast, + }, testutil.WaitLong, testutil.IntervalFast, "never saw stats: %+v", s, ) }) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 7fc3aea9e7..0958d2e7ff 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2208,6 +2208,22 @@ func (q *fakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW 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 { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 86a1f91cc8..6f2853e183 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -377,7 +377,8 @@ CREATE TABLE workspaces ( deleted boolean DEFAULT false NOT NULL, name character varying(64) NOT NULL, 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); diff --git a/coderd/database/migrations/000043_workspace_last_used.down.sql b/coderd/database/migrations/000043_workspace_last_used.down.sql new file mode 100644 index 0000000000..591a65a707 --- /dev/null +++ b/coderd/database/migrations/000043_workspace_last_used.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE workspaces + DROP COLUMN last_used_at; diff --git a/coderd/database/migrations/000043_workspace_last_used.up.sql b/coderd/database/migrations/000043_workspace_last_used.up.sql new file mode 100644 index 0000000000..33026756c7 --- /dev/null +++ b/coderd/database/migrations/000043_workspace_last_used.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE workspaces + ADD COLUMN last_used_at timestamp NOT NULL DEFAULT '0001-01-01 00:00:00+00:00'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 0a56fe560c..b64be8b3a9 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -525,6 +525,7 @@ type Workspace struct { Name string `db:"name" json:"name"` AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` } type WorkspaceAgent struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 12400d3924..12985422f5 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -150,6 +150,7 @@ type querier interface { UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error + UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 76f42cef55..9d63a7e494 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4562,7 +4562,7 @@ func (q *sqlQuerier) InsertWorkspaceResourceMetadata(ctx context.Context, arg In const getWorkspaceByID = `-- name: GetWorkspaceByID :one 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 workspaces WHERE @@ -4585,13 +4585,14 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp &i.Name, &i.AutostartSchedule, &i.Ttl, + &i.LastUsedAt, ) return i, err } const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one 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 workspaces WHERE @@ -4621,6 +4622,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo &i.Name, &i.AutostartSchedule, &i.Ttl, + &i.LastUsedAt, ) return i, err } @@ -4669,7 +4671,7 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i const getWorkspaces = `-- name: GetWorkspaces :many 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 workspaces WHERE @@ -4745,6 +4747,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.Name, &i.AutostartSchedule, &i.Ttl, + &i.LastUsedAt, ); err != nil { return nil, err } @@ -4761,7 +4764,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) const getWorkspacesAutostart = `-- name: GetWorkspacesAutostart :many 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 workspaces WHERE @@ -4794,6 +4797,7 @@ func (q *sqlQuerier) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, e &i.Name, &i.AutostartSchedule, &i.Ttl, + &i.LastUsedAt, ); err != nil { return nil, err } @@ -4822,7 +4826,7 @@ INSERT INTO ttl ) 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 { @@ -4861,6 +4865,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar &i.Name, &i.AutostartSchedule, &i.Ttl, + &i.LastUsedAt, ) return i, err } @@ -4873,7 +4878,7 @@ SET WHERE id = $1 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 { @@ -4895,6 +4900,7 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar &i.Name, &i.AutostartSchedule, &i.Ttl, + &i.LastUsedAt, ) return i, err } @@ -4937,6 +4943,25 @@ func (q *sqlQuerier) UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateW 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 UPDATE workspaces diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 8de76e2584..3af1ca4965 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -137,3 +137,11 @@ SET ttl = $2 WHERE id = $1; + +-- name: UpdateWorkspaceLastUsedAt :exec +UPDATE + workspaces +SET + last_used_at = $2 +WHERE + id = $1; diff --git a/coderd/templates_test.go b/coderd/templates_test.go index ae1976c647..3eea3a9387 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -608,6 +608,10 @@ func TestTemplateDAUs(t *testing.T) { Entries: []codersdk.DAUEntry{}, }, 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) require.NoError(t, err) defer func() { @@ -641,4 +645,10 @@ func TestTemplateDAUs(t *testing.T) { testutil.WaitShort, testutil.IntervalFast, "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, + ) } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 509365e8a0..bb48da0bab 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -830,18 +830,20 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques // We will see duplicate reports when on idle connections // (e.g. web terminal left open) or when there are no connections at // 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", slog.F("interval", api.AgentStatsRefreshInterval), slog.F("agent", workspaceAgent.ID), slog.F("resource", resource.ID), slog.F("workspace", workspace.ID), - slog.F("insert", insert), + slog.F("update_db", updateDB), slog.F("payload", rep), ) - if insert { + if updateDB { lastReport = rep _, err = api.Database.InsertAgentStat(ctx, database.InsertAgentStatParams{ @@ -860,6 +862,18 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques }) 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 { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index d524dc6340..b1e7e4e999 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -941,6 +941,7 @@ func convertWorkspace( Name: workspace.Name, AutostartSchedule: autostartSchedule, TTLMillis: ttlMillis, + LastUsedAt: workspace.LastUsedAt, } } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 07da2e394d..a4ab1fd54d 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -30,6 +30,7 @@ type Workspace struct { Name string `json:"name"` AutostartSchedule *string `json:"autostart_schedule,omitempty"` TTLMillis *int64 `json:"ttl_ms,omitempty"` + LastUsedAt time.Time `json:"last_used_at"` } // CreateWorkspaceBuildRequest provides options to update the latest workspace build. diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index aa63096366..8385d8282b 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -95,6 +95,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "name": ActionTrack, "autostart_schedule": ActionTrack, "ttl": ActionTrack, + "last_used_at": ActionIgnore, }, }) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5102485e11..e2998a383e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -514,6 +514,7 @@ export interface Workspace { readonly name: string readonly autostart_schedule?: string readonly ttl_ms?: number + readonly last_used_at: string } // From codersdk/workspaceresources.go diff --git a/site/src/components/WorkspacesTable/WorkspaceLastUsed.tsx b/site/src/components/WorkspacesTable/WorkspaceLastUsed.tsx new file mode 100644 index 0000000000..c64af30eac --- /dev/null +++ b/site/src/components/WorkspacesTable/WorkspaceLastUsed.tsx @@ -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 = ({ 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 {message} +} diff --git a/site/src/components/WorkspacesTable/WorkspacesRow.tsx b/site/src/components/WorkspacesTable/WorkspacesRow.tsx index d6bc5c47e5..cf6b92d1da 100644 --- a/site/src/components/WorkspacesTable/WorkspacesRow.tsx +++ b/site/src/components/WorkspacesTable/WorkspacesRow.tsx @@ -15,6 +15,7 @@ import { } from "../TableCellData/TableCellData" import { TableCellLink } from "../TableCellLink/TableCellLink" import { OutdatedHelpTooltip } from "../Tooltips" +import { WorkspaceLastUsed } from "./WorkspaceLastUsed" const Language = { upToDateLabel: "Up to date", @@ -64,6 +65,12 @@ export const WorkspacesRow: FC< } /> + + + + + + {workspace.outdated ? ( diff --git a/site/src/components/WorkspacesTable/WorkspacesTable.tsx b/site/src/components/WorkspacesTable/WorkspacesTable.tsx index 3561febff3..b2b7f175c8 100644 --- a/site/src/components/WorkspacesTable/WorkspacesTable.tsx +++ b/site/src/components/WorkspacesTable/WorkspacesTable.tsx @@ -11,6 +11,7 @@ import { WorkspacesTableBody } from "./WorkspacesTableBody" const Language = { name: "Name", template: "Template", + lastUsed: "Last Used", version: "Version", status: "Status", lastBuiltBy: "Last Built By", @@ -34,6 +35,7 @@ export const WorkspacesTable: FC> {Language.name} {Language.template} + {Language.lastUsed} {Language.version} {Language.status} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 2c0a3c5602..6624f2aa51 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -1,4 +1,5 @@ import { ComponentMeta, Story } from "@storybook/react" +import dayjs from "dayjs" import { spawn } from "xstate" import { ProvisionerJobStatus, WorkspaceTransition } from "../../api/typesGenerated" import { MockWorkspace } from "../../testHelpers/entities" @@ -13,6 +14,7 @@ const createWorkspaceItemRef = ( status: ProvisionerJobStatus, transition: WorkspaceTransition = "start", outdated = false, + lastUsedAt = "0001-01-01", ): WorkspaceItemMachineRef => { return spawn( workspaceItemMachine.withContext({ @@ -27,6 +29,7 @@ const createWorkspaceItemRef = ( status: status, }, }, + last_used_at: lastUsedAt, }, }), ) @@ -48,6 +51,15 @@ const additionalWorkspaces: Record = { succeededAndStop: createWorkspaceItemRef("succeeded", "stop"), runningAndDelete: createWorkspaceItemRef("running", "delete"), 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 { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 505461cdda..143bb0c606 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -236,6 +236,7 @@ export const MockWorkspace: TypesGen.Workspace = { autostart_schedule: MockWorkspaceAutostartEnabled.schedule, ttl_ms: 2 * 60 * 60 * 1000, // 2 hours as milliseconds latest_build: MockWorkspaceBuild, + last_used_at: "", } export const MockStoppedWorkspace: TypesGen.Workspace = {