Skip to content

Commit 16c3e00

Browse files
committed
feat(dashboard): embed renewed react console
1 parent dd52fc5 commit 16c3e00

38 files changed

Lines changed: 2507 additions & 100 deletions

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,12 @@ scripts/s3-autoloop/progress.md
1616
scripts/s3-autoloop/done.md
1717
scripts/s3-autoloop/iteration-*.out
1818
scripts/s3-autoloop/prompt-*.*
19+
scripts/dashboard-design-renewal-autoloop/.run-loop.lock
20+
scripts/dashboard-design-renewal-autoloop/.circuit-state
21+
scripts/dashboard-design-renewal-autoloop/runner.jsonl
22+
scripts/dashboard-design-renewal-autoloop/runner.log
23+
scripts/dashboard-design-renewal-autoloop/state.env
24+
scripts/dashboard-design-renewal-autoloop/progress.md
25+
scripts/dashboard-design-renewal-autoloop/done.md
26+
scripts/dashboard-design-renewal-autoloop/iteration-*.out
27+
scripts/dashboard-design-renewal-autoloop/prompt-*.*

internal/app/daemon_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package app
22

33
import (
44
"context"
5+
"errors"
56
"io"
67
"net"
78
"net/http"
9+
"os"
810
"strings"
911
"testing"
1012
"time"
@@ -64,6 +66,9 @@ func freeTCPPort(t *testing.T) int {
6466
t.Helper()
6567
listener, err := net.Listen("tcp", "127.0.0.1:0")
6668
if err != nil {
69+
if errors.Is(err, os.ErrPermission) {
70+
t.Skipf("cannot bind loopback TCP port in this environment: %v", err)
71+
}
6772
t.Fatalf("listen on free port: %v", err)
6873
}
6974
defer listener.Close()

internal/dashboard/assets.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package dashboard
2+
3+
import (
4+
"embed"
5+
"errors"
6+
"io/fs"
7+
"net/http"
8+
"strings"
9+
)
10+
11+
//go:embed all:assets/react
12+
var reactAssets embed.FS
13+
14+
func (s *Server) handleReactDashboardAssets(w http.ResponseWriter, r *http.Request) {
15+
if r.Method != http.MethodGet && r.Method != http.MethodHead {
16+
methodNotAllowed(w, "GET, HEAD")
17+
return
18+
}
19+
if r.URL.Path == "/dashboard" {
20+
http.Redirect(w, r, "/dashboard/", http.StatusMovedPermanently)
21+
return
22+
}
23+
24+
assetFS, err := fs.Sub(reactAssets, "assets/react")
25+
if err != nil {
26+
http.Error(w, "dashboard assets unavailable", http.StatusInternalServerError)
27+
return
28+
}
29+
30+
assetPath := strings.TrimPrefix(r.URL.Path, "/dashboard/")
31+
if assetPath == "" {
32+
serveReactDashboardIndex(w, r, assetFS)
33+
return
34+
}
35+
36+
file, err := assetFS.Open(assetPath)
37+
if err == nil {
38+
file.Close()
39+
if strings.HasPrefix(assetPath, "assets/") {
40+
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
41+
}
42+
http.StripPrefix("/dashboard/", http.FileServer(http.FS(assetFS))).ServeHTTP(w, r)
43+
return
44+
}
45+
if !errors.Is(err, fs.ErrNotExist) {
46+
http.Error(w, "dashboard asset unavailable", http.StatusInternalServerError)
47+
return
48+
}
49+
if strings.HasPrefix(assetPath, "assets/") {
50+
http.NotFound(w, r)
51+
return
52+
}
53+
54+
serveReactDashboardIndex(w, r, assetFS)
55+
}
56+
57+
func serveReactDashboardIndex(w http.ResponseWriter, r *http.Request, assetFS fs.FS) {
58+
index, err := fs.ReadFile(assetFS, "index.html")
59+
if err != nil {
60+
http.Error(w, "dashboard index unavailable", http.StatusInternalServerError)
61+
return
62+
}
63+
w.Header().Set("Cache-Control", "no-cache")
64+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
65+
if r.Method == http.MethodHead {
66+
return
67+
}
68+
w.Write(index)
69+
}

internal/dashboard/assets/react/assets/index-DFtFN3JG.js

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/dashboard/assets/react/assets/index-vK77LjrN.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>devcloud Dashboard</title>
7+
<script type="module" crossorigin src="/dashboard/assets/index-DFtFN3JG.js"></script>
8+
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-vK77LjrN.css">
9+
</head>
10+
<body>
11+
<div id="root"></div>
12+
</body>
13+
</html>

internal/dashboard/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ func (s *Server) Run(ctx context.Context) error {
6464
func (s *Server) routes() http.Handler {
6565
mux := http.NewServeMux()
6666
mux.HandleFunc("/", s.handleServiceIndex)
67+
mux.HandleFunc("/dashboard", s.handleReactDashboardAssets)
68+
mux.HandleFunc("/dashboard/", s.handleReactDashboardAssets)
6769
mux.HandleFunc("/mail", s.handleMailIndex)
6870
mux.HandleFunc("/s3", s.handleS3Index)
6971
mux.HandleFunc("/api/dashboard/services", s.handleDashboardServices)

internal/dashboard/server_test.go

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,10 @@ func TestIndexServesServiceLinks(t *testing.T) {
129129
func TestDashboardServicesAPIListsServiceRegistry(t *testing.T) {
130130
s3Store := s3svc.NewFileBucketStore(t.TempDir())
131131
server := NewServer(Config{
132-
MailEndpoint: "smtp://127.0.0.1:2525",
133-
S3Endpoint: "http://127.0.0.1:4567",
132+
MailEndpoint: "smtp://127.0.0.1:2525",
133+
MailStoragePath: ".devcloud/test/mail",
134+
S3Endpoint: "http://127.0.0.1:4567",
135+
S3StoragePath: ".devcloud/test/s3",
134136
}, newDashboardStore(nil, nil), s3Store)
135137

136138
rec := performRequest(server.routes(), http.MethodGet, "/api/dashboard/services")
@@ -149,18 +151,20 @@ func TestDashboardServicesAPIListsServiceRegistry(t *testing.T) {
149151
t.Fatalf("services len = %d, want 2: %#v", len(response.Services), response.Services)
150152
}
151153
assertService(t, response.Services[0], DashboardService{
152-
ID: "mail",
153-
Name: "Mail",
154-
Path: "/mail",
155-
Status: "running",
156-
Endpoint: "smtp://127.0.0.1:2525",
154+
ID: "mail",
155+
Name: "Mail",
156+
Path: "/mail",
157+
Status: "running",
158+
Endpoint: "smtp://127.0.0.1:2525",
159+
StoragePath: ".devcloud/test/mail",
157160
})
158161
assertService(t, response.Services[1], DashboardService{
159-
ID: "s3",
160-
Name: "S3",
161-
Path: "/s3",
162-
Status: "running",
163-
Endpoint: "http://127.0.0.1:4567",
162+
ID: "s3",
163+
Name: "S3",
164+
Path: "/s3",
165+
Status: "running",
166+
Endpoint: "http://127.0.0.1:4567",
167+
StoragePath: ".devcloud/test/s3",
164168
})
165169
}
166170

@@ -200,6 +204,77 @@ func TestDashboardServicesAPIRejectsUnsupportedMethods(t *testing.T) {
200204
}
201205
}
202206

207+
func TestReactDashboardAssetsServeWithoutInterceptingCompatibilityRoutes(t *testing.T) {
208+
routes := NewServer(Config{}, newDashboardStore(nil, nil)).routes()
209+
210+
index := performRequest(routes, http.MethodGet, "/dashboard/")
211+
if index.Code != http.StatusOK {
212+
t.Fatalf("react dashboard status = %d, want %d", index.Code, http.StatusOK)
213+
}
214+
if got := index.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
215+
t.Fatalf("react dashboard Content-Type = %q, want text/html", got)
216+
}
217+
if body := index.Body.String(); !strings.Contains(body, "devcloud Dashboard") {
218+
t.Fatalf("react dashboard index missing title: %s", body)
219+
}
220+
221+
nestedRoute := performRequest(routes, http.MethodGet, "/dashboard/mail")
222+
if nestedRoute.Code != http.StatusOK {
223+
t.Fatalf("react nested route status = %d, want %d", nestedRoute.Code, http.StatusOK)
224+
}
225+
if body := nestedRoute.Body.String(); !strings.Contains(body, "devcloud Dashboard") {
226+
t.Fatalf("react nested route did not fall back to index: %s", body)
227+
}
228+
if got := nestedRoute.Header().Get("Cache-Control"); got != "no-cache" {
229+
t.Fatalf("react nested route Cache-Control = %q, want no-cache", got)
230+
}
231+
232+
assetPath := reactAssetPath(t, index.Body.String())
233+
asset := performRequest(routes, http.MethodGet, assetPath)
234+
if asset.Code != http.StatusOK {
235+
t.Fatalf("react asset status = %d, want %d for %s", asset.Code, http.StatusOK, assetPath)
236+
}
237+
if got := asset.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
238+
t.Fatalf("react asset Cache-Control = %q, want immutable cache", got)
239+
}
240+
missingAsset := performRequest(routes, http.MethodGet, "/dashboard/assets/missing.js")
241+
if missingAsset.Code != http.StatusNotFound {
242+
t.Fatalf("missing react asset status = %d, want %d", missingAsset.Code, http.StatusNotFound)
243+
}
244+
245+
compatMail := performRequest(routes, http.MethodGet, "/mail")
246+
if compatMail.Code != http.StatusOK || !strings.Contains(compatMail.Body.String(), "devcloud Mail") {
247+
t.Fatalf("compat mail route changed: status=%d body=%s", compatMail.Code, compatMail.Body.String())
248+
}
249+
250+
registry := performRequest(routes, http.MethodGet, "/api/dashboard/services")
251+
if registry.Code != http.StatusOK {
252+
t.Fatalf("registry status = %d, want %d", registry.Code, http.StatusOK)
253+
}
254+
if got := registry.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
255+
t.Fatalf("registry Content-Type = %q, want application/json", got)
256+
}
257+
}
258+
259+
func reactAssetPath(t *testing.T, indexHTML string) string {
260+
t.Helper()
261+
marker := `src="/dashboard/`
262+
start := strings.Index(indexHTML, marker)
263+
if start == -1 {
264+
marker = `href="/dashboard/`
265+
start = strings.Index(indexHTML, marker)
266+
}
267+
if start == -1 {
268+
t.Fatalf("react index missing dashboard asset reference: %s", indexHTML)
269+
}
270+
start += len(marker) - len("/dashboard/")
271+
end := strings.Index(indexHTML[start:], `"`)
272+
if end == -1 {
273+
t.Fatalf("react index has unterminated asset reference: %s", indexHTML)
274+
}
275+
return indexHTML[start : start+end]
276+
}
277+
203278
func TestMailPathServesStaticMailDashboard(t *testing.T) {
204279
server := NewServer(Config{}, newDashboardStore(nil, nil))
205280
req := httptest.NewRequest(http.MethodGet, "/mail", nil)
@@ -497,7 +572,7 @@ func performRequest(handler http.Handler, method string, target string) *httptes
497572

498573
func assertService(t *testing.T, got DashboardService, want DashboardService) {
499574
t.Helper()
500-
if got.ID != want.ID || got.Name != want.Name || got.Path != want.Path || got.Status != want.Status || got.Endpoint != want.Endpoint {
575+
if got.ID != want.ID || got.Name != want.Name || got.Path != want.Path || got.Status != want.Status || got.Endpoint != want.Endpoint || got.StoragePath != want.StoragePath {
501576
t.Fatalf("service = %#v, want fields %#v", got, want)
502577
}
503578
if got.Description == "" {

0 commit comments

Comments
 (0)