mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: show agent version in UI and CLI (#3709)
This commit adds the ability for agents to set their version upon start. This is then reported in the UI and CLI.
This commit is contained in:
+11
-1
@@ -20,6 +20,7 @@ import (
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/agent/reaper"
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/retry"
|
||||
@@ -73,7 +74,12 @@ func workspaceAgent() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info(cmd.Context(), "starting agent", slog.F("url", coderURL), slog.F("auth", auth))
|
||||
version := buildinfo.Version()
|
||||
logger.Info(cmd.Context(), "starting agent",
|
||||
slog.F("url", coderURL),
|
||||
slog.F("auth", auth),
|
||||
slog.F("version", version),
|
||||
)
|
||||
client := codersdk.New(coderURL)
|
||||
|
||||
if pprofEnabled {
|
||||
@@ -172,6 +178,10 @@ func workspaceAgent() *cobra.Command {
|
||||
return xerrors.Errorf("add executable to $PATH: %w", err)
|
||||
}
|
||||
|
||||
if err := client.PostWorkspaceAgentVersion(cmd.Context(), version); err != nil {
|
||||
logger.Error(cmd.Context(), "post agent version: %w", slog.Error(err), slog.F("version", version))
|
||||
}
|
||||
|
||||
closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{
|
||||
Logger: logger,
|
||||
EnvironmentVariables: map[string]string{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
@@ -59,6 +60,9 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
|
||||
assert.NotEmpty(t, resources[0].Agents[0].Version)
|
||||
}
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
@@ -114,6 +118,9 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
|
||||
assert.NotEmpty(t, resources[0].Agents[0].Version)
|
||||
}
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
@@ -169,6 +176,9 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
|
||||
assert.NotEmpty(t, resources[0].Agents[0].Version)
|
||||
}
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
|
||||
+38
-13
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
|
||||
@@ -18,6 +19,7 @@ type WorkspaceResourcesOptions struct {
|
||||
HideAgentState bool
|
||||
HideAccess bool
|
||||
Title string
|
||||
ServerVersion string
|
||||
}
|
||||
|
||||
// WorkspaceResources displays the connection status and tree-view of provided resources.
|
||||
@@ -48,6 +50,7 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
row := table.Row{"Resource"}
|
||||
if !options.HideAgentState {
|
||||
row = append(row, "Status")
|
||||
row = append(row, "Version")
|
||||
}
|
||||
if !options.HideAccess {
|
||||
row = append(row, "Access")
|
||||
@@ -91,21 +94,12 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
}
|
||||
if !options.HideAgentState {
|
||||
var agentStatus string
|
||||
var agentVersion string
|
||||
if !options.HideAgentState {
|
||||
switch agent.Status {
|
||||
case codersdk.WorkspaceAgentConnecting:
|
||||
since := database.Now().Sub(agent.CreatedAt)
|
||||
agentStatus = Styles.Warn.Render("⦾ connecting") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentDisconnected:
|
||||
since := database.Now().Sub(*agent.DisconnectedAt)
|
||||
agentStatus = Styles.Error.Render("⦾ disconnected") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentConnected:
|
||||
agentStatus = Styles.Keyword.Render("⦿ connected")
|
||||
}
|
||||
agentStatus = renderAgentStatus(agent)
|
||||
agentVersion = renderAgentVersion(agent.Version, options.ServerVersion)
|
||||
}
|
||||
row = append(row, agentStatus)
|
||||
row = append(row, agentStatus, agentVersion)
|
||||
}
|
||||
if !options.HideAccess {
|
||||
sshCommand := "coder ssh " + options.WorkspaceName
|
||||
@@ -122,3 +116,34 @@ func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource
|
||||
_, err := fmt.Fprintln(writer, tableWriter.Render())
|
||||
return err
|
||||
}
|
||||
|
||||
func renderAgentStatus(agent codersdk.WorkspaceAgent) string {
|
||||
switch agent.Status {
|
||||
case codersdk.WorkspaceAgentConnecting:
|
||||
since := database.Now().Sub(agent.CreatedAt)
|
||||
return Styles.Warn.Render("⦾ connecting") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentDisconnected:
|
||||
since := database.Now().Sub(*agent.DisconnectedAt)
|
||||
return Styles.Error.Render("⦾ disconnected") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentConnected:
|
||||
return Styles.Keyword.Render("⦿ connected")
|
||||
default:
|
||||
return Styles.Warn.Render("○ unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func renderAgentVersion(agentVersion, serverVersion string) string {
|
||||
if agentVersion == "" {
|
||||
agentVersion = "(unknown)"
|
||||
}
|
||||
if !semver.IsValid(serverVersion) || !semver.IsValid(agentVersion) {
|
||||
return Styles.Placeholder.Render(agentVersion)
|
||||
}
|
||||
outdated := semver.Compare(agentVersion, serverVersion) < 0
|
||||
if outdated {
|
||||
return Styles.Warn.Render(agentVersion + " (outdated)")
|
||||
}
|
||||
return Styles.Keyword.Render(agentVersion)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRenderAgentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
agentVersion string
|
||||
serverVersion string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
agentVersion: "v1.2.3",
|
||||
serverVersion: "v1.2.3",
|
||||
expected: "v1.2.3",
|
||||
},
|
||||
{
|
||||
name: "Outdated",
|
||||
agentVersion: "v1.2.3",
|
||||
serverVersion: "v1.2.4",
|
||||
expected: "v1.2.3 (outdated)",
|
||||
},
|
||||
{
|
||||
name: "AgentUnknown",
|
||||
agentVersion: "",
|
||||
serverVersion: "v1.2.4",
|
||||
expected: "(unknown)",
|
||||
},
|
||||
{
|
||||
name: "ServerUnknown",
|
||||
agentVersion: "v1.2.3",
|
||||
serverVersion: "",
|
||||
expected: "v1.2.3",
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
actual := renderAgentVersion(testCase.agentVersion, testCase.serverVersion)
|
||||
assert.Equal(t, testCase.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ func show() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buildInfo, err := client.BuildInfo(cmd.Context())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get server version: %w", err)
|
||||
}
|
||||
workspace, err := namedWorkspace(cmd, client, args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace: %w", err)
|
||||
@@ -28,6 +32,7 @@ func show() *cobra.Command {
|
||||
}
|
||||
return cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: workspace.Name,
|
||||
ServerVersion: buildInfo.Version,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@ func New(options *Options) *API {
|
||||
r.Route("/me", func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractWorkspaceAgent(options.Database))
|
||||
r.Get("/metadata", api.workspaceAgentMetadata)
|
||||
r.Post("/version", api.postWorkspaceAgentVersion)
|
||||
r.Get("/listen", api.workspaceAgentListen)
|
||||
r.Get("/gitsshkey", api.agentGitSSHKey)
|
||||
r.Get("/turn", api.workspaceAgentTurn)
|
||||
|
||||
@@ -194,6 +194,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
|
||||
"GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/derp": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/me/version": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/wireguardlisten": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/me/keys": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true},
|
||||
|
||||
@@ -2047,6 +2047,22 @@ func (q *fakeQuerier) UpdateWorkspaceAgentKeysByID(_ context.Context, arg databa
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) UpdateWorkspaceAgentVersionByID(_ context.Context, arg database.UpdateWorkspaceAgentVersionByIDParams) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for index, agent := range q.provisionerJobAgents {
|
||||
if agent.ID != arg.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
agent.Version = arg.Version
|
||||
q.provisionerJobAgents[index] = agent
|
||||
return nil
|
||||
}
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.UpdateProvisionerJobByIDParams) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
Generated
+4
-1
@@ -308,9 +308,12 @@ CREATE TABLE workspace_agents (
|
||||
directory character varying(4096) DEFAULT ''::character varying NOT NULL,
|
||||
wireguard_node_ipv6 inet DEFAULT '::'::inet NOT NULL,
|
||||
wireguard_node_public_key character varying(128) DEFAULT 'nodekey:0000000000000000000000000000000000000000000000000000000000000000'::character varying NOT NULL,
|
||||
wireguard_disco_public_key character varying(128) DEFAULT 'discokey:0000000000000000000000000000000000000000000000000000000000000000'::character varying NOT NULL
|
||||
wireguard_disco_public_key character varying(128) DEFAULT 'discokey:0000000000000000000000000000000000000000000000000000000000000000'::character varying NOT NULL,
|
||||
version text DEFAULT ''::text NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.';
|
||||
|
||||
CREATE TABLE workspace_apps (
|
||||
id uuid NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE ONLY workspace_agents DROP COLUMN version;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE ONLY workspace_agents ADD COLUMN version text DEFAULT ''::text NOT NULL;
|
||||
COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.';
|
||||
@@ -536,6 +536,8 @@ type WorkspaceAgent struct {
|
||||
WireguardNodeIPv6 pqtype.Inet `db:"wireguard_node_ipv6" json:"wireguard_node_ipv6"`
|
||||
WireguardNodePublicKey dbtypes.NodePublic `db:"wireguard_node_public_key" json:"wireguard_node_public_key"`
|
||||
WireguardDiscoPublicKey dbtypes.DiscoPublic `db:"wireguard_disco_public_key" json:"wireguard_disco_public_key"`
|
||||
// Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.
|
||||
Version string `db:"version" json:"version"`
|
||||
}
|
||||
|
||||
type WorkspaceApp struct {
|
||||
|
||||
@@ -144,6 +144,7 @@ type querier interface {
|
||||
UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error)
|
||||
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
|
||||
UpdateWorkspaceAgentKeysByID(ctx context.Context, arg UpdateWorkspaceAgentKeysByIDParams) error
|
||||
UpdateWorkspaceAgentVersionByID(ctx context.Context, arg UpdateWorkspaceAgentVersionByIDParams) error
|
||||
UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error
|
||||
UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error
|
||||
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
|
||||
|
||||
@@ -3158,7 +3158,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP
|
||||
|
||||
const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one
|
||||
SELECT
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version
|
||||
FROM
|
||||
workspace_agents
|
||||
WHERE
|
||||
@@ -3191,13 +3191,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken
|
||||
&i.WireguardNodeIPv6,
|
||||
&i.WireguardNodePublicKey,
|
||||
&i.WireguardDiscoPublicKey,
|
||||
&i.Version,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version
|
||||
FROM
|
||||
workspace_agents
|
||||
WHERE
|
||||
@@ -3228,13 +3229,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (W
|
||||
&i.WireguardNodeIPv6,
|
||||
&i.WireguardNodePublicKey,
|
||||
&i.WireguardDiscoPublicKey,
|
||||
&i.Version,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version
|
||||
FROM
|
||||
workspace_agents
|
||||
WHERE
|
||||
@@ -3267,13 +3269,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst
|
||||
&i.WireguardNodeIPv6,
|
||||
&i.WireguardNodePublicKey,
|
||||
&i.WireguardDiscoPublicKey,
|
||||
&i.Version,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many
|
||||
SELECT
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key
|
||||
id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version
|
||||
FROM
|
||||
workspace_agents
|
||||
WHERE
|
||||
@@ -3310,6 +3313,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []
|
||||
&i.WireguardNodeIPv6,
|
||||
&i.WireguardNodePublicKey,
|
||||
&i.WireguardDiscoPublicKey,
|
||||
&i.Version,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -3325,7 +3329,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []
|
||||
}
|
||||
|
||||
const getWorkspaceAgentsCreatedAfter = `-- name: GetWorkspaceAgentsCreatedAfter :many
|
||||
SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key FROM workspace_agents WHERE created_at > $1
|
||||
SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version FROM workspace_agents WHERE created_at > $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) {
|
||||
@@ -3358,6 +3362,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created
|
||||
&i.WireguardNodeIPv6,
|
||||
&i.WireguardNodePublicKey,
|
||||
&i.WireguardDiscoPublicKey,
|
||||
&i.Version,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -3394,7 +3399,7 @@ INSERT INTO
|
||||
wireguard_disco_public_key
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, wireguard_node_ipv6, wireguard_node_public_key, wireguard_disco_public_key, version
|
||||
`
|
||||
|
||||
type InsertWorkspaceAgentParams struct {
|
||||
@@ -3459,6 +3464,7 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa
|
||||
&i.WireguardNodeIPv6,
|
||||
&i.WireguardNodePublicKey,
|
||||
&i.WireguardDiscoPublicKey,
|
||||
&i.Version,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -3522,6 +3528,25 @@ func (q *sqlQuerier) UpdateWorkspaceAgentKeysByID(ctx context.Context, arg Updat
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceAgentVersionByID = `-- name: UpdateWorkspaceAgentVersionByID :exec
|
||||
UPDATE
|
||||
workspace_agents
|
||||
SET
|
||||
version = $2
|
||||
WHERE
|
||||
id = $1
|
||||
`
|
||||
|
||||
type UpdateWorkspaceAgentVersionByIDParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Version string `db:"version" json:"version"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateWorkspaceAgentVersionByID(ctx context.Context, arg UpdateWorkspaceAgentVersionByIDParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateWorkspaceAgentVersionByID, arg.ID, arg.Version)
|
||||
return err
|
||||
}
|
||||
|
||||
const getWorkspaceAppByAgentIDAndName = `-- name: GetWorkspaceAppByAgentIDAndName :one
|
||||
SELECT id, created_at, agent_id, name, icon, command, url, relative_path FROM workspace_apps WHERE agent_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
@@ -81,3 +81,11 @@ SET
|
||||
updated_at = $4
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: UpdateWorkspaceAgentVersionByID :exec
|
||||
UPDATE
|
||||
workspace_agents
|
||||
SET
|
||||
version = $2
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/yamux"
|
||||
"github.com/tabbed/pqtype"
|
||||
"golang.org/x/mod/semver"
|
||||
"golang.org/x/xerrors"
|
||||
"inet.af/netaddr"
|
||||
"nhooyr.io/websocket"
|
||||
@@ -152,6 +153,46 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *API) postWorkspaceAgentVersion(rw http.ResponseWriter, r *http.Request) {
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
apiAgent, err := convertWorkspaceAgent(workspaceAgent, nil, api.AgentInactiveDisconnectTimeout)
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error reading workspace agent.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.PostWorkspaceAgentVersionRequest
|
||||
if !httpapi.Read(rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
api.Logger.Info(r.Context(), "post workspace agent version", slog.F("agent_id", apiAgent.ID), slog.F("agent_version", req.Version))
|
||||
|
||||
if !semver.IsValid(req.Version) {
|
||||
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid workspace agent version provided.",
|
||||
Detail: fmt.Sprintf("invalid semver version: %q", req.Version),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.Database.UpdateWorkspaceAgentVersionByID(r.Context(), database.UpdateWorkspaceAgentVersionByIDParams{
|
||||
ID: apiAgent.ID,
|
||||
Version: req.Version,
|
||||
}); err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Error setting agent version",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(rw, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (api *API) workspaceAgentListen(rw http.ResponseWriter, r *http.Request) {
|
||||
api.websocketWaitMutex.Lock()
|
||||
api.websocketWaitGroup.Add(1)
|
||||
@@ -707,6 +748,7 @@ func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, apps []codersdk.Work
|
||||
IPv6: inetToNetaddr(dbAgent.WireguardNodeIPv6),
|
||||
WireguardPublicKey: key.NodePublic(dbAgent.WireguardNodePublicKey),
|
||||
DiscoPublicKey: key.DiscoPublic(dbAgent.WireguardDiscoPublicKey),
|
||||
Version: dbAgent.Version,
|
||||
}
|
||||
|
||||
if dbAgent.FirstConnectedAt.Valid {
|
||||
|
||||
@@ -48,6 +48,10 @@ type WorkspaceAgentAuthenticateResponse struct {
|
||||
SessionToken string `json:"session_token"`
|
||||
}
|
||||
|
||||
type PostWorkspaceAgentVersionRequest struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AuthWorkspaceGoogleInstanceIdentity uses the Google Compute Engine Metadata API to
|
||||
// fetch a signed JWT, and exchange it for a session token for a workspace agent.
|
||||
//
|
||||
@@ -240,6 +244,8 @@ func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) (
|
||||
if err != nil {
|
||||
return agent.Metadata{}, nil, xerrors.Errorf("listen peerbroker: %w", err)
|
||||
}
|
||||
|
||||
// Fetch updated agent metadata
|
||||
res, err = c.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/me/metadata", nil)
|
||||
if err != nil {
|
||||
return agent.Metadata{}, nil, err
|
||||
@@ -429,6 +435,19 @@ func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAge
|
||||
return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent)
|
||||
}
|
||||
|
||||
func (c *Client) PostWorkspaceAgentVersion(ctx context.Context, version string) error {
|
||||
// Phone home and tell the mothership what version we're on.
|
||||
versionReq := PostWorkspaceAgentVersionRequest{Version: version}
|
||||
res, err := c.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/version", versionReq)
|
||||
if err != nil {
|
||||
return readBodyAsError(res)
|
||||
}
|
||||
// Discord the response
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
||||
// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON.
|
||||
// Responses are PTY output that can be rendered.
|
||||
|
||||
@@ -57,6 +57,7 @@ type WorkspaceAgent struct {
|
||||
WireguardPublicKey key.NodePublic `json:"wireguard_public_key"`
|
||||
DiscoPublicKey key.DiscoPublic `json:"disco_public_key"`
|
||||
IPv6 netaddr.IPPrefix `json:"ipv6"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type WorkspaceAgentResourceMetadata struct {
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"@types/react": "18.0.15",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react-helmet": "6.1.5",
|
||||
"@types/semver": "^7.3.12",
|
||||
"@types/superagent": "4.1.15",
|
||||
"@types/uuid": "8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.31.0",
|
||||
@@ -112,6 +113,7 @@
|
||||
"prettier": "2.7.1",
|
||||
"prettier-plugin-organize-imports": "3.0.0",
|
||||
"react-hot-loader": "4.13.0",
|
||||
"semver": "^7.3.7",
|
||||
"sql-formatter": "8.2.0",
|
||||
"style-loader": "3.3.1",
|
||||
"ts-jest": "27.1.4",
|
||||
|
||||
@@ -249,6 +249,11 @@ export interface ParameterSchema {
|
||||
readonly validation_contains?: string[]
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
export interface PostWorkspaceAgentVersionRequest {
|
||||
readonly version: string
|
||||
}
|
||||
|
||||
// From codersdk/provisionerdaemons.go
|
||||
export interface ProvisionerDaemon {
|
||||
readonly id: string
|
||||
@@ -479,6 +484,7 @@ export interface WorkspaceAgent {
|
||||
// Named type "inet.af/netaddr.IPPrefix" unknown, using "any"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
readonly ipv6: any
|
||||
readonly version: string
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
|
||||
@@ -9,14 +9,15 @@ import useTheme from "@material-ui/styles/useTheme"
|
||||
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
|
||||
import { TableCellDataPrimary } from "components/TableCellData/TableCellData"
|
||||
import { FC } from "react"
|
||||
import { getDisplayAgentStatus } from "util/workspace"
|
||||
import { Workspace, WorkspaceResource } from "../../api/typesGenerated"
|
||||
import { getDisplayAgentStatus, getDisplayVersionStatus } from "util/workspace"
|
||||
import { BuildInfoResponse, Workspace, WorkspaceResource } from "../../api/typesGenerated"
|
||||
import { AppLink } from "../AppLink/AppLink"
|
||||
import { SSHButton } from "../SSHButton/SSHButton"
|
||||
import { Stack } from "../Stack/Stack"
|
||||
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
|
||||
import { TerminalLink } from "../TerminalLink/TerminalLink"
|
||||
import { AgentHelpTooltip } from "../Tooltips/AgentHelpTooltip"
|
||||
import { AgentOutdatedTooltip } from "../Tooltips/AgentOutdatedTooltip"
|
||||
import { ResourcesHelpTooltip } from "../Tooltips/ResourcesHelpTooltip"
|
||||
import { ResourceAvatarData } from "./ResourceAvatarData"
|
||||
|
||||
@@ -26,6 +27,7 @@ const Language = {
|
||||
agentsLabel: "Agents",
|
||||
agentLabel: "Agent",
|
||||
statusLabel: "status: ",
|
||||
versionLabel: "version: ",
|
||||
osLabel: "os: ",
|
||||
}
|
||||
|
||||
@@ -34,6 +36,7 @@ interface ResourcesProps {
|
||||
getResourcesError?: Error | unknown
|
||||
workspace: Workspace
|
||||
canUpdateWorkspace: boolean
|
||||
buildInfo?: BuildInfoResponse | undefined
|
||||
}
|
||||
|
||||
export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
@@ -41,9 +44,11 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
getResourcesError,
|
||||
workspace,
|
||||
canUpdateWorkspace,
|
||||
buildInfo,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const theme: Theme = useTheme()
|
||||
const serverVersion = buildInfo?.version || ""
|
||||
|
||||
return (
|
||||
<div aria-label={Language.resources} className={styles.wrapper}>
|
||||
@@ -90,6 +95,10 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const { displayVersion, outdated } = getDisplayVersionStatus(
|
||||
agent.version,
|
||||
serverVersion,
|
||||
)
|
||||
const agentStatus = getDisplayAgentStatus(theme, agent)
|
||||
return (
|
||||
<TableRow key={`${resource.id}-${agent.id}`}>
|
||||
@@ -114,6 +123,11 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
<strong>{Language.osLabel}</strong>
|
||||
<span className={styles.operatingSystem}>{agent.operating_system}</span>
|
||||
</div>
|
||||
<div className={styles.dataRow}>
|
||||
<strong>{Language.versionLabel}</strong>
|
||||
<span className={styles.agentVersion}>{displayVersion}</span>
|
||||
<AgentOutdatedTooltip outdated={outdated} />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -188,6 +202,10 @@ const useStyles = makeStyles((theme) => ({
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
|
||||
agentVersion: {
|
||||
display: "block",
|
||||
},
|
||||
|
||||
accessLinks: {
|
||||
display: "flex",
|
||||
gap: theme.spacing(0.5),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FC } from "react"
|
||||
import { HelpTooltip, HelpTooltipText, HelpTooltipTitle } from "./HelpTooltip"
|
||||
|
||||
export const Language = {
|
||||
label: "Agent Outdated",
|
||||
text: "This agent is an older version than the Coder server. This can happen after you update Coder with running workspaces. To fix this, you can stop and start the workspace.",
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
outdated: boolean
|
||||
}
|
||||
|
||||
export const AgentOutdatedTooltip: FC<React.PropsWithChildren<TooltipProps>> = ({ outdated }) => {
|
||||
if (!outdated) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<HelpTooltip size="small">
|
||||
<HelpTooltipTitle>{Language.label}</HelpTooltipTitle>
|
||||
<HelpTooltipText>{Language.text}</HelpTooltipText>
|
||||
</HelpTooltip>
|
||||
)
|
||||
}
|
||||
@@ -44,6 +44,7 @@ Started.args = {
|
||||
builds: [Mocks.MockWorkspaceBuild],
|
||||
canUpdateWorkspace: true,
|
||||
workspaceErrors: {},
|
||||
buildInfo: Mocks.MockBuildInfo,
|
||||
}
|
||||
|
||||
export const WithoutUpdateAccess = Template.bind({})
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface WorkspaceProps {
|
||||
builds?: TypesGen.WorkspaceBuild[]
|
||||
canUpdateWorkspace: boolean
|
||||
workspaceErrors: Partial<Record<WorkspaceErrors, Error | unknown>>
|
||||
buildInfo?: TypesGen.BuildInfoResponse
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,6 +63,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
builds,
|
||||
canUpdateWorkspace,
|
||||
workspaceErrors,
|
||||
buildInfo,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const navigate = useNavigate()
|
||||
@@ -128,6 +130,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
getResourcesError={workspaceErrors[WorkspaceErrors.GET_RESOURCES_ERROR]}
|
||||
workspace={workspace}
|
||||
canUpdateWorkspace={canUpdateWorkspace}
|
||||
buildInfo={buildInfo}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("WorkspacePage", () => {
|
||||
const agent1Status = await screen.findAllByText(
|
||||
DisplayAgentStatusLanguage[MockWorkspaceAgent.status],
|
||||
)
|
||||
expect(agent1Status.length).toEqual(2)
|
||||
expect(agent1Status.length).toEqual(4)
|
||||
const agent2Status = await screen.findAllByText(
|
||||
DisplayAgentStatusLanguage[MockWorkspaceAgentDisconnected.status],
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { useMachine, useSelector } from "@xstate/react"
|
||||
import { useActor, useMachine, useSelector } from "@xstate/react"
|
||||
import dayjs from "dayjs"
|
||||
import minMax from "dayjs/plugin/minMax"
|
||||
import { FC, useContext, useEffect } from "react"
|
||||
@@ -51,6 +51,7 @@ export const WorkspacePage: FC = () => {
|
||||
const canUpdateWorkspace = !!permissions?.updateWorkspace
|
||||
|
||||
const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine)
|
||||
const [buildInfoState] = useActor(xServices.buildInfoXService)
|
||||
|
||||
const styles = useStyles()
|
||||
|
||||
@@ -133,6 +134,7 @@ export const WorkspacePage: FC = () => {
|
||||
[WorkspaceErrors.BUILD_ERROR]: buildError,
|
||||
[WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
|
||||
}}
|
||||
buildInfo={buildInfoState.context.buildInfo}
|
||||
/>
|
||||
<DeleteWorkspaceDialog
|
||||
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
|
||||
|
||||
@@ -305,6 +305,7 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = {
|
||||
wireguard_public_key: "",
|
||||
disco_public_key: "",
|
||||
ipv6: "",
|
||||
version: MockBuildInfo.version,
|
||||
}
|
||||
|
||||
export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = {
|
||||
@@ -312,10 +313,19 @@ export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = {
|
||||
id: "test-workspace-agent-2",
|
||||
name: "another-workspace-agent",
|
||||
status: "disconnected",
|
||||
version: "",
|
||||
}
|
||||
|
||||
export const MockWorkspaceAgentOutdated: TypesGen.WorkspaceAgent = {
|
||||
...MockWorkspaceAgent,
|
||||
id: "test-workspace-agent-3",
|
||||
name: "an-outdated-workspace-agent",
|
||||
version: "v99.999.9998+abcdef",
|
||||
operating_system: "Windows",
|
||||
}
|
||||
|
||||
export const MockWorkspaceResource: TypesGen.WorkspaceResource = {
|
||||
agents: [MockWorkspaceAgent, MockWorkspaceAgentDisconnected],
|
||||
agents: [MockWorkspaceAgent, MockWorkspaceAgentDisconnected, MockWorkspaceAgentOutdated],
|
||||
created_at: "",
|
||||
id: "test-workspace-resource",
|
||||
job_id: "",
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as TypesGen from "../api/typesGenerated"
|
||||
import * as Mocks from "../testHelpers/entities"
|
||||
import {
|
||||
defaultWorkspaceExtension,
|
||||
getDisplayVersionStatus,
|
||||
getDisplayWorkspaceBuildInitiatedBy,
|
||||
isWorkspaceDeleted,
|
||||
isWorkspaceOn,
|
||||
@@ -128,4 +129,23 @@ describe("util > workspace", () => {
|
||||
expect(getDisplayWorkspaceBuildInitiatedBy(build)).toEqual(initiatedBy)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getDisplayVersionStatus", () => {
|
||||
it.each<[string, string, string, boolean]>([
|
||||
["", "", "(unknown)", false],
|
||||
["", "v1.2.3", "(unknown)", false],
|
||||
["v1.2.3", "", "v1.2.3", false],
|
||||
["v1.2.3", "v1.2.3", "v1.2.3", false],
|
||||
["v1.2.3", "v1.2.4", "v1.2.3 (outdated)", true],
|
||||
["v1.2.4", "v1.2.3", "v1.2.4", false],
|
||||
["foo", "bar", "foo", false],
|
||||
])(
|
||||
`getDisplayVersionStatus(theme, %p, %p) returns (%p, %p)`,
|
||||
(agentVersion, serverVersion, expectedVersion, expectedOutdated) => {
|
||||
const { displayVersion, outdated } = getDisplayVersionStatus(agentVersion, serverVersion)
|
||||
expect(displayVersion).toEqual(expectedVersion)
|
||||
expect(expectedOutdated).toEqual(outdated)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
import minMax from "dayjs/plugin/minMax"
|
||||
import utc from "dayjs/plugin/utc"
|
||||
import semver from "semver"
|
||||
import { WorkspaceBuildTransition } from "../api/types"
|
||||
import * as TypesGen from "../api/typesGenerated"
|
||||
|
||||
@@ -95,6 +96,11 @@ export const DisplayWorkspaceBuildStatusLanguage = {
|
||||
failed: "Failed",
|
||||
}
|
||||
|
||||
export const DisplayAgentVersionLanguage = {
|
||||
unknown: "unknown",
|
||||
outdated: "outdated",
|
||||
}
|
||||
|
||||
export const getDisplayWorkspaceBuildStatus = (
|
||||
theme: Theme,
|
||||
build: TypesGen.WorkspaceBuild,
|
||||
@@ -211,6 +217,28 @@ export const getDisplayAgentStatus = (
|
||||
}
|
||||
}
|
||||
|
||||
export const getDisplayVersionStatus = (
|
||||
agentVersion: string,
|
||||
serverVersion: string,
|
||||
): { displayVersion: string; outdated: boolean } => {
|
||||
if (!semver.valid(serverVersion) || !semver.valid(agentVersion)) {
|
||||
return {
|
||||
displayVersion: `${agentVersion}` || `(${DisplayAgentVersionLanguage.unknown})`,
|
||||
outdated: false,
|
||||
}
|
||||
} else if (semver.lt(agentVersion, serverVersion)) {
|
||||
return {
|
||||
displayVersion: `${agentVersion} (${DisplayAgentVersionLanguage.outdated})`,
|
||||
outdated: true,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
displayVersion: agentVersion,
|
||||
outdated: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isWorkspaceOn = (workspace: TypesGen.Workspace): boolean => {
|
||||
const transition = workspace.latest_build.transition
|
||||
const status = workspace.latest_build.job.status
|
||||
|
||||
@@ -3330,6 +3330,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
|
||||
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
|
||||
|
||||
"@types/semver@^7.3.12":
|
||||
version "7.3.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.12.tgz#920447fdd78d76b19de0438b7f60df3c4a80bf1c"
|
||||
integrity sha512-WwA1MW0++RfXmCr12xeYOOC5baSC9mSb0ZqCquFzKhcoF4TvHu5MKOuXsncgZcpVFhB1pXd5hZmM0ryAoCp12A==
|
||||
|
||||
"@types/serve-index@^1.9.1":
|
||||
version "1.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278"
|
||||
|
||||
Reference in New Issue
Block a user