feat: add version to footer (#882)

* Add endpoint for getting build info

* Add build info XService

* Add version with link to page footer

Partially addresses #376.

* Lift buildinfo package
This commit is contained in:
Asher
2022-04-07 12:18:58 -05:00
committed by GitHub
parent 2e5859f226
commit 18595791c0
14 changed files with 204 additions and 17 deletions
@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/mod/semver"
"github.com/coder/coder/cli/buildinfo"
"github.com/coder/coder/buildinfo"
)
func TestBuildInfo(t *testing.T) {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"github.com/coder/coder/cli/buildinfo"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/codersdk"
+12
View File
@@ -7,16 +7,19 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"google.golang.org/api/idtoken"
chitrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-chi/chi.v5"
"cdr.dev/slog"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd/awsidentity"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/site"
)
@@ -59,6 +62,15 @@ func New(options *Options) (http.Handler, func()) {
Message: "👋",
})
})
r.Route("/buildinfo", func(r chi.Router) {
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
render.Status(r, http.StatusOK)
render.JSON(rw, r, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
Version: buildinfo.Version(),
})
})
})
r.Route("/files", func(r chi.Router) {
r.Use(
httpmw.ExtractAPIKey(options.Database, nil),
+15
View File
@@ -1,11 +1,26 @@
package coderd_test
import (
"context"
"testing"
"go.uber.org/goleak"
"github.com/stretchr/testify/require"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd/coderdtest"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestBuildInfo(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
buildInfo, err := client.BuildInfo(context.Background())
require.NoError(t, err)
require.Equal(t, buildinfo.ExternalURL(), buildInfo.ExternalURL, "external URL")
require.Equal(t, buildinfo.Version(), buildInfo.Version, "version")
}
+33
View File
@@ -0,0 +1,33 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
)
// BuildInfoResponse contains build information for this instance of Coder.
type BuildInfoResponse struct {
// ExternalURL is a URL referencing the current Coder version. For production
// builds, this will link directly to a release. For development builds, this
// will link to a commit.
ExternalURL string `json:"external_url"`
// Version returns the semantic version of the build.
Version string `json:"version"`
}
// BuildInfo returns build information for this instance of Coder.
func (c *Client) BuildInfo(ctx context.Context) (BuildInfoResponse, error) {
res, err := c.request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)
if err != nil {
return BuildInfoResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return BuildInfoResponse{}, readBodyAsError(res)
}
var buildInfo BuildInfoResponse
return buildInfo, json.NewDecoder(res.Body).Decode(&buildInfo)
}
+5
View File
@@ -68,3 +68,8 @@ export const getApiKey = async (): Promise<Types.APIKeyResponse> => {
const response = await axios.post<Types.APIKeyResponse>("/api/v2/users/me/keys")
return response.data
}
export const getBuildInfo = async (): Promise<Types.BuildInfoResponse> => {
const response = await axios.get("/api/v2/buildinfo")
return response.data
}
+8
View File
@@ -1,3 +1,11 @@
/**
* `BuildInfoResponse` must be kept in sync with the go struct in buildinfo.go.
*/
export interface BuildInfoResponse {
external_url: string
version: string
}
export interface LoginResponse {
session_token: string
}
+3 -2
View File
@@ -1,7 +1,7 @@
import { screen } from "@testing-library/react"
import React from "react"
import { render } from "../../test_helpers"
import { Footer } from "./Footer"
import { MockBuildInfo, render } from "../../test_helpers"
import { Footer, Language } from "./Footer"
describe("Footer", () => {
it("renders content", async () => {
@@ -10,5 +10,6 @@ describe("Footer", () => {
// Then
await screen.findByText("Copyright", { exact: false })
await screen.findByText(Language.buildInfoText(MockBuildInfo))
})
})
+21 -9
View File
@@ -1,9 +1,21 @@
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
import { useActor } from "@xstate/react"
import React, { useContext } from "react"
import { BuildInfoResponse } from "../../api/types"
import { XServiceContext } from "../../xServices/StateContext"
export const Language = {
buildInfoText: (buildInfo: BuildInfoResponse): string => {
return `Coder ${buildInfo.version}`
},
}
export const Footer: React.FC = ({ children }) => {
const styles = useFooterStyles()
const xServices = useContext(XServiceContext)
const [buildInfoState] = useActor(xServices.buildInfoXService)
return (
<div className={styles.root}>
@@ -13,11 +25,13 @@ export const Footer: React.FC = ({ children }) => {
{`Copyright \u00a9 ${new Date().getFullYear()} Coder Technologies, Inc. All rights reserved.`}
</Typography>
</div>
<div className={styles.version}>
<Typography color="textSecondary" variant="caption">
v2 0.0.0-prototype
</Typography>
</div>
{buildInfoState.context.buildInfo && (
<div className={styles.buildInfo}>
<Link variant="caption" href={buildInfoState.context.buildInfo.external_url}>
{Language.buildInfoText(buildInfoState.context.buildInfo)}
</Link>
</div>
)}
</div>
)
}
@@ -29,11 +43,9 @@ const useFooterStyles = makeStyles((theme) => ({
flex: "0",
},
copyRight: {
backgroundColor: theme.palette.background.default,
margin: theme.spacing(0.25),
},
version: {
backgroundColor: theme.palette.background.default,
buildInfo: {
margin: theme.spacing(0.25),
},
}))
+14 -1
View File
@@ -1,9 +1,22 @@
import { Organization, Provisioner, Template, UserAgent, UserResponse, Workspace } from "../api/types"
import {
BuildInfoResponse,
Organization,
Provisioner,
Template,
UserAgent,
UserResponse,
Workspace,
} from "../api/types"
export const MockSessionToken = { session_token: "my-session-token" }
export const MockAPIKey = { key: "my-api-key" }
export const MockBuildInfo: BuildInfoResponse = {
external_url: "file:///mock-url",
version: "v99.999.9999+c9cdf14",
}
export const MockUser: UserResponse = {
id: "test-user",
username: "TestUser",
+5
View File
@@ -2,6 +2,11 @@ import { rest } from "msw"
import * as M from "./entities"
export const handlers = [
// build info
rest.get("/api/v2/buildinfo", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockBuildInfo))
}),
// organizations
rest.get("/api/v2/organizations/:organizationId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockOrganization))
+12 -3
View File
@@ -1,9 +1,11 @@
import { useInterpret } from "@xstate/react"
import React, { createContext } from "react"
import { ActorRefFrom } from "xstate"
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
import { userMachine } from "./user/userXService"
interface XServiceContextType {
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
userXService: ActorRefFrom<typeof userMachine>
}
@@ -18,7 +20,14 @@ interface XServiceContextType {
export const XServiceContext = createContext({} as XServiceContextType)
export const XServiceProvider: React.FC = ({ children }) => {
const userXService = useInterpret(userMachine)
return <XServiceContext.Provider value={{ userXService }}>{children}</XServiceContext.Provider>
return (
<XServiceContext.Provider
value={{
buildInfoXService: useInterpret(buildInfoMachine),
userXService: useInterpret(userMachine),
}}
>
{children}
</XServiceContext.Provider>
)
}
@@ -0,0 +1,74 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api"
import * as Types from "../../api/types"
export interface BuildInfoContext {
getBuildInfoError?: Error | unknown
buildInfo?: Types.BuildInfoResponse
}
export const buildInfoMachine = createMachine(
{
tsTypes: {} as import("./buildInfoXService.typegen").Typegen0,
schema: {
context: {} as BuildInfoContext,
services: {} as {
getBuildInfo: {
data: Types.BuildInfoResponse
}
},
},
context: {
buildInfo: undefined,
},
id: "buildInfoState",
initial: "gettingBuildInfo",
states: {
gettingBuildInfo: {
invoke: {
src: "getBuildInfo",
id: "getBuildInfo",
onDone: [
{
actions: ["assignBuildInfo", "clearGetBuildInfoError"],
target: "#buildInfoState.success",
},
],
onError: [
{
actions: ["assignGetBuildInfoError", "clearBuildInfo"],
target: "#buildInfoState.failure",
},
],
},
},
success: {
type: "final",
},
failure: {
type: "final",
},
},
},
{
services: {
getBuildInfo: API.getBuildInfo,
},
actions: {
assignBuildInfo: assign({
buildInfo: (_, event) => event.data,
}),
clearBuildInfo: assign((context: BuildInfoContext) => ({
...context,
buildInfo: undefined,
})),
assignGetBuildInfoError: assign({
getBuildInfoError: (_, event) => event.data,
}),
clearGetBuildInfoError: assign((context: BuildInfoContext) => ({
...context,
getBuildInfoError: undefined,
})),
},
},
)