mirror of https://github.com/coder/coder.git
feat(site): Add deployment-wide DAU chart (#5810)
This commit is contained in:
parent
e7b8318b87
commit
16d8cc4176
|
@ -648,6 +648,31 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/insights/daus": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Insights"
|
||||
],
|
||||
"summary": "Get deployment DAUs",
|
||||
"operationId": "get-deployment-daus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.DeploymentDAUsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/licenses": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -6149,6 +6174,17 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeploymentDAUsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DAUEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Entitlement": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
|
@ -558,6 +558,27 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/insights/daus": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Insights"],
|
||||
"summary": "Get deployment DAUs",
|
||||
"operationId": "get-deployment-daus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.DeploymentDAUsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/licenses": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -5486,6 +5507,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeploymentDAUsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DAUEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Entitlement": {
|
||||
"type": "string",
|
||||
"enum": ["entitled", "grace_period", "not_entitled"],
|
||||
|
|
|
@ -621,7 +621,10 @@ func New(options *Options) *API {
|
|||
r.Get("/", api.workspaceApplicationAuth)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/insights", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Get("/daus", api.deploymentDAUs)
|
||||
})
|
||||
r.Route("/debug", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
|
|
|
@ -323,6 +323,42 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) (
|
|||
return rs, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetDeploymentDAUs(_ context.Context) ([]database.GetDeploymentDAUsRow, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
seens := make(map[time.Time]map[uuid.UUID]struct{})
|
||||
|
||||
for _, as := range q.agentStats {
|
||||
date := as.CreatedAt.Truncate(time.Hour * 24)
|
||||
|
||||
dateEntry := seens[date]
|
||||
if dateEntry == nil {
|
||||
dateEntry = make(map[uuid.UUID]struct{})
|
||||
}
|
||||
dateEntry[as.UserID] = struct{}{}
|
||||
seens[date] = dateEntry
|
||||
}
|
||||
|
||||
seenKeys := maps.Keys(seens)
|
||||
sort.Slice(seenKeys, func(i, j int) bool {
|
||||
return seenKeys[i].Before(seenKeys[j])
|
||||
})
|
||||
|
||||
var rs []database.GetDeploymentDAUsRow
|
||||
for _, key := range seenKeys {
|
||||
ids := seens[key]
|
||||
for id := range ids {
|
||||
rs = append(rs, database.GetDeploymentDAUsRow{
|
||||
Date: key,
|
||||
UserID: id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.GetTemplateAverageBuildTimeRow{}, err
|
||||
|
|
|
@ -40,6 +40,7 @@ type sqlcQuerier interface {
|
|||
// are included.
|
||||
GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error)
|
||||
GetDERPMeshKey(ctx context.Context) (string, error)
|
||||
GetDeploymentDAUs(ctx context.Context) ([]GetDeploymentDAUsRow, error)
|
||||
GetDeploymentID(ctx context.Context) (string, error)
|
||||
GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error)
|
||||
GetFileByID(ctx context.Context, id uuid.UUID) (File, error)
|
||||
|
|
|
@ -25,6 +25,46 @@ func (q *sqlQuerier) DeleteOldAgentStats(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
const getDeploymentDAUs = `-- name: GetDeploymentDAUs :many
|
||||
SELECT
|
||||
(created_at at TIME ZONE 'UTC')::date as date,
|
||||
user_id
|
||||
FROM
|
||||
agent_stats
|
||||
GROUP BY
|
||||
date, user_id
|
||||
ORDER BY
|
||||
date ASC
|
||||
`
|
||||
|
||||
type GetDeploymentDAUsRow struct {
|
||||
Date time.Time `db:"date" json:"date"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetDeploymentDAUs(ctx context.Context) ([]GetDeploymentDAUsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getDeploymentDAUs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetDeploymentDAUsRow
|
||||
for rows.Next() {
|
||||
var i GetDeploymentDAUsRow
|
||||
if err := rows.Scan(&i.Date, &i.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getTemplateDAUs = `-- name: GetTemplateDAUs :many
|
||||
SELECT
|
||||
(created_at at TIME ZONE 'UTC')::date as date,
|
||||
|
|
|
@ -25,5 +25,16 @@ GROUP BY
|
|||
ORDER BY
|
||||
date ASC;
|
||||
|
||||
-- name: GetDeploymentDAUs :many
|
||||
SELECT
|
||||
(created_at at TIME ZONE 'UTC')::date as date,
|
||||
user_id
|
||||
FROM
|
||||
agent_stats
|
||||
GROUP BY
|
||||
date, user_id
|
||||
ORDER BY
|
||||
date ASC;
|
||||
|
||||
-- name: DeleteOldAgentStats :exec
|
||||
DELETE FROM agent_stats WHERE created_at < NOW() - INTERVAL '30 days';
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
package coderd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// @Summary Get deployment DAUs
|
||||
// @ID get-deployment-daus
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Insights
|
||||
// @Success 200 {object} codersdk.DeploymentDAUsResponse
|
||||
// @Router /insights/daus [get]
|
||||
func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
resp, _ := api.metricsCache.DeploymentDAUs()
|
||||
if resp == nil || resp.Entries == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, &codersdk.DeploymentDAUsResponse{
|
||||
Entries: []codersdk.DAUEntry{},
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestDeploymentInsights(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
AgentStatsRefreshInterval: time.Millisecond * 100,
|
||||
MetricsCacheRefreshInterval: time.Millisecond * 100,
|
||||
})
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
Client: agentClient,
|
||||
})
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
}()
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
daus, err := client.DeploymentDAUs(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, &codersdk.DeploymentDAUsResponse{
|
||||
Entries: []codersdk.DAUEntry{},
|
||||
}, daus, "no DAUs when stats are empty")
|
||||
|
||||
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, res.Workspaces[0].LastUsedAt)
|
||||
|
||||
conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{
|
||||
Logger: slogtest.Make(t, nil).Named("tailnet"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
sshConn, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
_ = sshConn.Close()
|
||||
|
||||
wantDAUs := &codersdk.DeploymentDAUsResponse{
|
||||
Entries: []codersdk.DAUEntry{
|
||||
{
|
||||
|
||||
Date: time.Now().UTC().Truncate(time.Hour * 24),
|
||||
Amount: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventuallyf(t, func() bool {
|
||||
daus, err = client.DeploymentDAUs(ctx)
|
||||
require.NoError(t, err)
|
||||
return len(daus.Entries) > 0
|
||||
},
|
||||
testutil.WaitShort, testutil.IntervalFast,
|
||||
"deployment daus never loaded",
|
||||
)
|
||||
gotDAUs, err := client.DeploymentDAUs(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, gotDAUs, wantDAUs)
|
||||
|
||||
template, err = client.Template(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{})
|
||||
require.NoError(t, err)
|
||||
}
|
|
@ -27,6 +27,7 @@ type Cache struct {
|
|||
database database.Store
|
||||
log slog.Logger
|
||||
|
||||
deploymentDAUResponses atomic.Pointer[codersdk.DeploymentDAUsResponse]
|
||||
templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse]
|
||||
templateUniqueUsers atomic.Pointer[map[uuid.UUID]int]
|
||||
templateAverageBuildTime atomic.Pointer[map[uuid.UUID]database.GetTemplateAverageBuildTimeRow]
|
||||
|
@ -110,6 +111,28 @@ func convertDAUResponse(rows []database.GetTemplateDAUsRow) codersdk.TemplateDAU
|
|||
return resp
|
||||
}
|
||||
|
||||
func convertDeploymentDAUResponse(rows []database.GetDeploymentDAUsRow) codersdk.DeploymentDAUsResponse {
|
||||
respMap := make(map[time.Time][]uuid.UUID)
|
||||
for _, row := range rows {
|
||||
respMap[row.Date] = append(respMap[row.Date], row.UserID)
|
||||
}
|
||||
|
||||
dates := maps.Keys(respMap)
|
||||
slices.SortFunc(dates, func(a, b time.Time) bool {
|
||||
return a.Before(b)
|
||||
})
|
||||
|
||||
var resp codersdk.DeploymentDAUsResponse
|
||||
for _, date := range fillEmptyDays(dates) {
|
||||
resp.Entries = append(resp.Entries, codersdk.DAUEntry{
|
||||
Date: date,
|
||||
Amount: len(respMap[date]),
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func countUniqueUsers(rows []database.GetTemplateDAUsRow) int {
|
||||
seen := make(map[uuid.UUID]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
|
@ -130,10 +153,19 @@ func (c *Cache) refresh(ctx context.Context) error {
|
|||
}
|
||||
|
||||
var (
|
||||
deploymentDAUs = codersdk.DeploymentDAUsResponse{}
|
||||
templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates))
|
||||
templateUniqueUsers = make(map[uuid.UUID]int)
|
||||
templateAverageBuildTimes = make(map[uuid.UUID]database.GetTemplateAverageBuildTimeRow)
|
||||
)
|
||||
|
||||
rows, err := c.database.GetDeploymentDAUs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deploymentDAUs = convertDeploymentDAUResponse(rows)
|
||||
c.deploymentDAUResponses.Store(&deploymentDAUs)
|
||||
|
||||
for _, template := range templates {
|
||||
rows, err := c.database.GetTemplateDAUs(ctx, template.ID)
|
||||
if err != nil {
|
||||
|
@ -207,6 +239,11 @@ func (c *Cache) Close() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) DeploymentDAUs() (*codersdk.DeploymentDAUsResponse, bool) {
|
||||
m := c.deploymentDAUResponses.Load()
|
||||
return m, m != nil
|
||||
}
|
||||
|
||||
// TemplateDAUs returns an empty response if the template doesn't have users
|
||||
// or is loading for the first time.
|
||||
func (c *Cache) TemplateDAUs(id uuid.UUID) (*codersdk.TemplateDAUsResponse, bool) {
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
type DeploymentDAUsResponse struct {
|
||||
Entries []DAUEntry `json:"entries"`
|
||||
}
|
||||
|
||||
func (c *Client) DeploymentDAUs(ctx context.Context) (*DeploymentDAUsResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/insights/daus", nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, readBodyAsError(res)
|
||||
}
|
||||
|
||||
var resp DeploymentDAUsResponse
|
||||
return &resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
# Insights
|
||||
|
||||
## Get deployment DAUs
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/insights/daus \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /insights/daus`
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"amount": 0,
|
||||
"date": "2019-08-24T14:15:22Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------- |
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.DeploymentDAUsResponse](schemas.md#codersdkdeploymentdausresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
|
@ -2385,6 +2385,25 @@ CreateParameterRequest is a structure used to create a new parameter value for a
|
|||
| `usage` | string | false | | |
|
||||
| `value` | integer | false | | |
|
||||
|
||||
## codersdk.DeploymentDAUsResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"amount": 0,
|
||||
"date": "2019-08-24T14:15:22Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| --------- | ----------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `entries` | array of [codersdk.DAUEntry](#codersdkdauentry) | false | | |
|
||||
|
||||
## codersdk.Entitlement
|
||||
|
||||
```json
|
||||
|
|
|
@ -376,6 +376,10 @@
|
|||
"title": "Files",
|
||||
"path": "./api/files.md"
|
||||
},
|
||||
{
|
||||
"title": "Insights",
|
||||
"path": "./api/insights.md"
|
||||
},
|
||||
{
|
||||
"title": "Members",
|
||||
"path": "./api/members.md"
|
||||
|
|
|
@ -644,6 +644,12 @@ export const getTemplateDAUs = async (
|
|||
return response.data
|
||||
}
|
||||
|
||||
export const getDeploymentDAUs =
|
||||
async (): Promise<TypesGen.DeploymentDAUsResponse> => {
|
||||
const response = await axios.get(`/api/v2/insights/daus`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getTemplateACL = async (
|
||||
templateId: string,
|
||||
): Promise<TypesGen.TemplateACL> => {
|
||||
|
|
|
@ -342,6 +342,11 @@ export interface DeploymentConfigField<T extends Flaggable> {
|
|||
readonly value: T
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface DeploymentDAUsResponse {
|
||||
readonly entries: DAUEntry[]
|
||||
}
|
||||
|
||||
// From codersdk/features.go
|
||||
export interface Entitlements {
|
||||
readonly features: Record<FeatureName, Feature>
|
||||
|
|
|
@ -13,7 +13,7 @@ describe("DAUChart", () => {
|
|||
it("renders a helpful paragraph on empty state", async () => {
|
||||
render(
|
||||
<DAUChart
|
||||
templateDAUs={{
|
||||
daus={{
|
||||
entries: [],
|
||||
}}
|
||||
/>,
|
||||
|
@ -24,7 +24,7 @@ describe("DAUChart", () => {
|
|||
it("renders a graph", async () => {
|
||||
render(
|
||||
<DAUChart
|
||||
templateDAUs={{
|
||||
daus={{
|
||||
entries: [{ date: "2020-01-01", amount: 1 }],
|
||||
}}
|
||||
/>,
|
|
@ -38,19 +38,17 @@ ChartJS.register(
|
|||
)
|
||||
|
||||
export interface DAUChartProps {
|
||||
templateDAUs: TypesGen.TemplateDAUsResponse
|
||||
daus: TypesGen.TemplateDAUsResponse | TypesGen.DeploymentDAUsResponse
|
||||
}
|
||||
export const Language = {
|
||||
loadingText: "DAU stats are loading. Check back later.",
|
||||
chartTitle: "Daily Active Users",
|
||||
}
|
||||
|
||||
export const DAUChart: FC<DAUChartProps> = ({
|
||||
templateDAUs: templateMetricsData,
|
||||
}) => {
|
||||
export const DAUChart: FC<DAUChartProps> = ({ daus }) => {
|
||||
const theme: Theme = useTheme()
|
||||
|
||||
if (templateMetricsData.entries.length === 0) {
|
||||
if (daus.entries.length === 0) {
|
||||
return (
|
||||
// We generate hidden element to prove this path is taken in the test
|
||||
// and through site inspection.
|
||||
|
@ -60,11 +58,11 @@ export const DAUChart: FC<DAUChartProps> = ({
|
|||
)
|
||||
}
|
||||
|
||||
const labels = templateMetricsData.entries.map((val) => {
|
||||
const labels = daus.entries.map((val) => {
|
||||
return dayjs(val.date).format("YYYY-MM-DD")
|
||||
})
|
||||
|
||||
const data = templateMetricsData.entries.map((val) => {
|
||||
const data = daus.entries.map((val) => {
|
||||
return val.amount
|
||||
})
|
||||
|
|
@ -5,13 +5,18 @@ import { Sidebar } from "./Sidebar"
|
|||
import { createContext, Suspense, useContext, FC } from "react"
|
||||
import { useMachine } from "@xstate/react"
|
||||
import { Loader } from "components/Loader/Loader"
|
||||
import { DeploymentConfig } from "api/typesGenerated"
|
||||
import { DeploymentConfig, DeploymentDAUsResponse } from "api/typesGenerated"
|
||||
import { deploymentConfigMachine } from "xServices/deploymentConfig/deploymentConfigMachine"
|
||||
import { RequirePermission } from "components/RequirePermission/RequirePermission"
|
||||
import { usePermissions } from "hooks/usePermissions"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
type DeploySettingsContextValue = { deploymentConfig: DeploymentConfig }
|
||||
type DeploySettingsContextValue = {
|
||||
deploymentConfig: DeploymentConfig
|
||||
getDeploymentConfigError: unknown
|
||||
deploymentDAUs?: DeploymentDAUsResponse
|
||||
getDeploymentDAUsError: unknown
|
||||
}
|
||||
|
||||
const DeploySettingsContext = createContext<
|
||||
DeploySettingsContextValue | undefined
|
||||
|
@ -30,7 +35,12 @@ export const useDeploySettings = (): DeploySettingsContextValue => {
|
|||
export const DeploySettingsLayout: FC = () => {
|
||||
const [state] = useMachine(deploymentConfigMachine)
|
||||
const styles = useStyles()
|
||||
const { deploymentConfig } = state.context
|
||||
const {
|
||||
deploymentConfig,
|
||||
deploymentDAUs,
|
||||
getDeploymentConfigError,
|
||||
getDeploymentDAUsError,
|
||||
} = state.context
|
||||
const permissions = usePermissions()
|
||||
|
||||
return (
|
||||
|
@ -41,7 +51,12 @@ export const DeploySettingsLayout: FC = () => {
|
|||
<main className={styles.content}>
|
||||
{deploymentConfig ? (
|
||||
<DeploySettingsContext.Provider
|
||||
value={{ deploymentConfig: deploymentConfig }}
|
||||
value={{
|
||||
deploymentConfig,
|
||||
getDeploymentConfigError,
|
||||
deploymentDAUs,
|
||||
getDeploymentDAUsError,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<Loader />}>
|
||||
<Outlet />
|
||||
|
|
|
@ -5,14 +5,19 @@ import { pageTitle } from "util/page"
|
|||
import { GeneralSettingsPageView } from "./GeneralSettingsPageView"
|
||||
|
||||
const GeneralSettingsPage: FC = () => {
|
||||
const { deploymentConfig: deploymentConfig } = useDeploySettings()
|
||||
const { deploymentConfig, deploymentDAUs, getDeploymentDAUsError } =
|
||||
useDeploySettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("General Settings")}</title>
|
||||
</Helmet>
|
||||
<GeneralSettingsPageView deploymentConfig={deploymentConfig} />
|
||||
<GeneralSettingsPageView
|
||||
deploymentConfig={deploymentConfig}
|
||||
deploymentDAUs={deploymentDAUs}
|
||||
getDeploymentDAUsError={getDeploymentDAUsError}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
import { ComponentMeta, Story } from "@storybook/react"
|
||||
import {
|
||||
makeMockApiError,
|
||||
MockDeploymentDAUResponse,
|
||||
} from "testHelpers/entities"
|
||||
import {
|
||||
GeneralSettingsPageView,
|
||||
GeneralSettingsPageViewProps,
|
||||
|
@ -24,6 +28,9 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
deploymentDAUs: {
|
||||
defaultValue: MockDeploymentDAUResponse,
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof GeneralSettingsPageView>
|
||||
|
||||
|
@ -31,3 +38,14 @@ const Template: Story<GeneralSettingsPageViewProps> = (args) => (
|
|||
<GeneralSettingsPageView {...args} />
|
||||
)
|
||||
export const Page = Template.bind({})
|
||||
|
||||
export const NoDAUs = Template.bind({})
|
||||
NoDAUs.args = {
|
||||
deploymentDAUs: undefined,
|
||||
}
|
||||
|
||||
export const DAUError = Template.bind({})
|
||||
DAUError.args = {
|
||||
deploymentDAUs: undefined,
|
||||
getDeploymentDAUsError: makeMockApiError({ message: "Error fetching DAUs." }),
|
||||
}
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
import { DeploymentConfig } from "api/typesGenerated"
|
||||
import { DeploymentConfig, DeploymentDAUsResponse } from "api/typesGenerated"
|
||||
import { AlertBanner } from "components/AlertBanner/AlertBanner"
|
||||
import { DAUChart } from "components/DAUChart/DAUChart"
|
||||
import { Header } from "components/DeploySettingsLayout/Header"
|
||||
import OptionsTable from "components/DeploySettingsLayout/OptionsTable"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
|
||||
export type GeneralSettingsPageViewProps = {
|
||||
deploymentConfig: Pick<DeploymentConfig, "access_url" | "wildcard_access_url">
|
||||
deploymentDAUs?: DeploymentDAUsResponse
|
||||
getDeploymentDAUsError: unknown
|
||||
}
|
||||
export const GeneralSettingsPageView = ({
|
||||
deploymentConfig,
|
||||
deploymentDAUs,
|
||||
getDeploymentDAUsError,
|
||||
}: GeneralSettingsPageViewProps): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
|
@ -15,12 +22,18 @@ export const GeneralSettingsPageView = ({
|
|||
description="Information about your Coder deployment."
|
||||
docsHref="https://coder.com/docs/coder-oss/latest/admin/configure"
|
||||
/>
|
||||
<OptionsTable
|
||||
options={{
|
||||
access_url: deploymentConfig.access_url,
|
||||
wildcard_access_url: deploymentConfig.wildcard_access_url,
|
||||
}}
|
||||
/>
|
||||
<Stack spacing={4}>
|
||||
{Boolean(getDeploymentDAUsError) && (
|
||||
<AlertBanner error={getDeploymentDAUsError} severity="error" />
|
||||
)}
|
||||
{deploymentDAUs && <DAUChart daus={deploymentDAUs} />}
|
||||
<OptionsTable
|
||||
options={{
|
||||
access_url: deploymentConfig.access_url,
|
||||
wildcard_access_url: deploymentConfig.wildcard_access_url,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import { TemplateStats } from "components/TemplateStats/TemplateStats"
|
|||
import { VersionsTable } from "components/VersionsTable/VersionsTable"
|
||||
import frontMatter from "front-matter"
|
||||
import { FC } from "react"
|
||||
import { DAUChart } from "./DAUChart"
|
||||
import { DAUChart } from "../../../components/DAUChart/DAUChart"
|
||||
|
||||
export interface TemplateSummaryPageViewProps {
|
||||
template: Template
|
||||
|
@ -46,7 +46,7 @@ export const TemplateSummaryPageView: FC<
|
|||
template={template}
|
||||
activeVersion={activeTemplateVersion}
|
||||
/>
|
||||
{templateDAUs && <DAUChart templateDAUs={templateDAUs} />}
|
||||
{templateDAUs && <DAUChart daus={templateDAUs} />}
|
||||
<TemplateResourcesTable
|
||||
resources={getStartedResources(templateResources)}
|
||||
/>
|
||||
|
|
|
@ -13,6 +13,13 @@ export const MockTemplateDAUResponse: TypesGen.TemplateDAUsResponse = {
|
|||
{ date: "2022-08-30T00:00:00Z", amount: 1 },
|
||||
],
|
||||
}
|
||||
export const MockDeploymentDAUResponse: TypesGen.DeploymentDAUsResponse = {
|
||||
entries: [
|
||||
{ date: "2022-08-27T00:00:00Z", amount: 1 },
|
||||
{ date: "2022-08-29T00:00:00Z", amount: 2 },
|
||||
{ date: "2022-08-30T00:00:00Z", amount: 1 },
|
||||
],
|
||||
}
|
||||
export const MockSessionToken: TypesGen.LoginWithPasswordResponse = {
|
||||
session_token: "my-session-token",
|
||||
}
|
||||
|
|
|
@ -10,6 +10,10 @@ export const handlers = [
|
|||
return res(ctx.status(200), ctx.json(M.MockTemplateDAUResponse))
|
||||
}),
|
||||
|
||||
rest.get("/api/v2/insights/daus", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockDeploymentDAUResponse))
|
||||
}),
|
||||
|
||||
// build info
|
||||
rest.get("/api/v2/buildinfo", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockBuildInfo))
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { getDeploymentConfig } from "api/api"
|
||||
import { DeploymentConfig } from "api/typesGenerated"
|
||||
import { getDeploymentConfig, getDeploymentDAUs } from "api/api"
|
||||
import { DeploymentConfig, DeploymentDAUsResponse } from "api/typesGenerated"
|
||||
import { createMachine, assign } from "xstate"
|
||||
|
||||
export const deploymentConfigMachine = createMachine(
|
||||
|
@ -11,29 +11,49 @@ export const deploymentConfigMachine = createMachine(
|
|||
context: {} as {
|
||||
deploymentConfig?: DeploymentConfig
|
||||
getDeploymentConfigError?: unknown
|
||||
deploymentDAUs?: DeploymentDAUsResponse
|
||||
getDeploymentDAUsError?: unknown
|
||||
},
|
||||
events: {} as { type: "LOAD" },
|
||||
services: {} as {
|
||||
getDeploymentConfig: {
|
||||
data: DeploymentConfig
|
||||
}
|
||||
getDeploymentDAUs: {
|
||||
data: DeploymentDAUsResponse
|
||||
}
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import("./deploymentConfigMachine.typegen").Typegen0,
|
||||
initial: "loading",
|
||||
initial: "config",
|
||||
states: {
|
||||
loading: {
|
||||
config: {
|
||||
invoke: {
|
||||
src: "getDeploymentConfig",
|
||||
onDone: {
|
||||
target: "done",
|
||||
target: "daus",
|
||||
actions: ["assignDeploymentConfig"],
|
||||
},
|
||||
onError: {
|
||||
target: "done",
|
||||
target: "daus",
|
||||
actions: ["assignGetDeploymentConfigError"],
|
||||
},
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
daus: {
|
||||
invoke: {
|
||||
src: "getDeploymentDAUs",
|
||||
onDone: {
|
||||
target: "done",
|
||||
actions: ["assignDeploymentDAUs"],
|
||||
},
|
||||
onError: {
|
||||
target: "done",
|
||||
actions: ["assignGetDeploymentDAUsError"],
|
||||
},
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
done: {
|
||||
type: "final",
|
||||
|
@ -43,6 +63,7 @@ export const deploymentConfigMachine = createMachine(
|
|||
{
|
||||
services: {
|
||||
getDeploymentConfig: getDeploymentConfig,
|
||||
getDeploymentDAUs: getDeploymentDAUs,
|
||||
},
|
||||
actions: {
|
||||
assignDeploymentConfig: assign({
|
||||
|
@ -51,6 +72,12 @@ export const deploymentConfigMachine = createMachine(
|
|||
assignGetDeploymentConfigError: assign({
|
||||
getDeploymentConfigError: (_, { data }) => data,
|
||||
}),
|
||||
assignDeploymentDAUs: assign({
|
||||
deploymentDAUs: (_, { data }) => data,
|
||||
}),
|
||||
assignGetDeploymentDAUsError: assign({
|
||||
getDeploymentDAUsError: (_, { data }) => data,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue