mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user