mirror of https://github.com/coder/coder.git
feat: static error page in applications handlers (#4299)
This commit is contained in:
parent
ce953441fb
commit
d165d76338
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
@ -66,10 +67,9 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
|||
Workspace: workspace,
|
||||
Agent: agent,
|
||||
// We do not support port proxying for paths.
|
||||
AppName: chi.URLParam(r, "workspaceapp"),
|
||||
Port: 0,
|
||||
Path: chiPath,
|
||||
DashboardOnError: true,
|
||||
AppName: chi.URLParam(r, "workspaceapp"),
|
||||
Port: 0,
|
||||
Path: chiPath,
|
||||
}, rw, r)
|
||||
}
|
||||
|
||||
|
@ -162,12 +162,11 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
|
|||
}
|
||||
|
||||
api.proxyWorkspaceApplication(proxyApplication{
|
||||
Workspace: workspace,
|
||||
Agent: agent,
|
||||
AppName: app.AppName,
|
||||
Port: app.Port,
|
||||
Path: r.URL.Path,
|
||||
DashboardOnError: false,
|
||||
Workspace: workspace,
|
||||
Agent: agent,
|
||||
AppName: app.AppName,
|
||||
Port: app.Port,
|
||||
Path: r.URL.Path,
|
||||
}, rw, r)
|
||||
})).ServeHTTP(rw, r.WithContext(ctx))
|
||||
})
|
||||
|
@ -175,20 +174,19 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
|
|||
}
|
||||
|
||||
func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) {
|
||||
ctx := r.Context()
|
||||
// Check if the hostname matches the access URL. If it does, the
|
||||
// user was definitely trying to connect to the dashboard/API.
|
||||
// Check if the hostname matches the access URL. If it does, the user was
|
||||
// definitely trying to connect to the dashboard/API.
|
||||
if httpapi.HostnamesMatch(api.AccessURL.Hostname(), host) {
|
||||
next.ServeHTTP(rw, r)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
// Split the subdomain so we can parse the application details and
|
||||
// verify it matches the configured app hostname later.
|
||||
// Split the subdomain so we can parse the application details and verify it
|
||||
// matches the configured app hostname later.
|
||||
subdomain, rest := httpapi.SplitSubdomain(host)
|
||||
if rest == "" {
|
||||
// If there are no periods in the hostname, then it can't be a
|
||||
// valid application URL.
|
||||
// If there are no periods in the hostname, then it can't be a valid
|
||||
// application URL.
|
||||
next.ServeHTTP(rw, r)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
@ -197,27 +195,34 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
|
|||
// Parse the application URL from the subdomain.
|
||||
app, err := httpapi.ParseSubdomainAppURL(subdomain)
|
||||
if err != nil {
|
||||
// If it isn't a valid app URL and the base domain doesn't match
|
||||
// the configured app hostname, this request was probably
|
||||
// destined for the dashboard/API router.
|
||||
// If it isn't a valid app URL and the base domain doesn't match the
|
||||
// configured app hostname, this request was probably destined for the
|
||||
// dashboard/API router.
|
||||
if !matchingBaseHostname {
|
||||
next.ServeHTTP(rw, r)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Could not parse subdomain application URL.",
|
||||
Detail: err.Error(),
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
Title: "Invalid application URL",
|
||||
Description: fmt.Sprintf("Could not parse subdomain application URL %q: %s", subdomain, err.Error()),
|
||||
RetryEnabled: false,
|
||||
DashboardURL: api.AccessURL.String(),
|
||||
})
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
// At this point we've verified that the subdomain looks like a
|
||||
// valid application URL, so the base hostname should match the
|
||||
// configured app hostname.
|
||||
// At this point we've verified that the subdomain looks like a valid
|
||||
// application URL, so the base hostname should match the configured app
|
||||
// hostname.
|
||||
if !matchingBaseHostname {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "The server does not accept application requests on this hostname.",
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusNotFound,
|
||||
Title: "Not Found",
|
||||
Description: "The server does not accept application requests on this hostname.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: api.AccessURL.String(),
|
||||
})
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
@ -230,12 +235,10 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
|
|||
// they will be redirected to the route below. If the user does have a session
|
||||
// key but insufficient permissions a static error page will be rendered.
|
||||
func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, workspace database.Workspace, host string) bool {
|
||||
ctx := r.Context()
|
||||
_, ok := httpmw.APIKeyOptional(r)
|
||||
if ok {
|
||||
if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) {
|
||||
// TODO: This should be a static error page.
|
||||
httpapi.ResourceNotFound(rw)
|
||||
renderApplicationNotFound(rw, r, api.AccessURL)
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -249,9 +252,14 @@ func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.R
|
|||
// Exchange the encoded API key for a real one.
|
||||
_, apiKey, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Could not decrypt API key. Please remove the query parameter and try again.",
|
||||
Detail: err.Error(),
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
Title: "Bad Request",
|
||||
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
|
||||
// Retry is disabled because the user needs to remove the query
|
||||
// parameter before they try again.
|
||||
RetryEnabled: false,
|
||||
DashboardURL: api.AccessURL.String(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
@ -302,6 +310,10 @@ func (api *API) verifyWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.R
|
|||
|
||||
// workspaceApplicationAuth is an endpoint on the main router that handles
|
||||
// redirects from the subdomain handler.
|
||||
//
|
||||
// This endpoint is under /api so we don't return the friendly error page here.
|
||||
// Any errors on this endpoint should be errors that are unlikely to happen
|
||||
// in production unless the user messes with the URL.
|
||||
func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if api.AppHostname == "" {
|
||||
|
@ -413,11 +425,6 @@ type proxyApplication struct {
|
|||
Port uint16
|
||||
// Path must either be empty or have a leading slash.
|
||||
Path string
|
||||
|
||||
// DashboardOnError determines whether or not the dashboard should be
|
||||
// rendered on error. This should be set for proxy path URLs but not
|
||||
// hostname based URLs.
|
||||
DashboardOnError bool
|
||||
}
|
||||
|
||||
func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.ResponseWriter, r *http.Request) {
|
||||
|
@ -439,17 +446,28 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
|
|||
AgentID: proxyApp.Agent.ID,
|
||||
Name: proxyApp.AppName,
|
||||
})
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
renderApplicationNotFound(rw, r, api.AccessURL)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace application.",
|
||||
Detail: err.Error(),
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
Title: "Internal Server Error",
|
||||
Description: "Could not fetch workspace application: " + err.Error(),
|
||||
RetryEnabled: true,
|
||||
DashboardURL: api.AccessURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !app.Url.Valid {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Application %s does not have a url.", app.Name),
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
Title: "Bad Request",
|
||||
Description: fmt.Sprintf("Application %q does not have a URL set.", app.Name),
|
||||
RetryEnabled: true,
|
||||
DashboardURL: api.AccessURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -458,9 +476,12 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
|
|||
|
||||
appURL, err := url.Parse(internalURL)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: fmt.Sprintf("App URL %q is invalid.", internalURL),
|
||||
Detail: err.Error(),
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
Title: "Bad Request",
|
||||
Description: fmt.Sprintf("Application has an invalid URL %q: %s", internalURL, err.Error()),
|
||||
RetryEnabled: true,
|
||||
DashboardURL: api.AccessURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -489,28 +510,23 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
|
|||
|
||||
proxy := httputil.NewSingleHostReverseProxy(appURL)
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if proxyApp.DashboardOnError {
|
||||
// To pass friendly errors to the frontend, special meta tags are
|
||||
// overridden in the index.html with the content passed here.
|
||||
r = r.WithContext(site.WithAPIResponse(ctx, site.APIResponse{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Message: err.Error(),
|
||||
}))
|
||||
api.siteHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, w, http.StatusBadGateway, codersdk.Response{
|
||||
Message: "Failed to proxy request to application.",
|
||||
Detail: err.Error(),
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadGateway,
|
||||
Title: "Bad Gateway",
|
||||
Description: "Failed to proxy request to application: " + err.Error(),
|
||||
RetryEnabled: true,
|
||||
DashboardURL: api.AccessURL.String(),
|
||||
})
|
||||
}
|
||||
|
||||
conn, release, err := api.workspaceAgentCache.Acquire(r, proxyApp.Agent.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to dial workspace agent.",
|
||||
Detail: err.Error(),
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadGateway,
|
||||
Title: "Bad Gateway",
|
||||
Description: "Could not connect to workspace agent: " + err.Error(),
|
||||
RetryEnabled: true,
|
||||
DashboardURL: api.AccessURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -648,3 +664,15 @@ func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey strin
|
|||
|
||||
return key, payload.APIKey, nil
|
||||
}
|
||||
|
||||
// renderApplicationNotFound should always be used when the app is not found or
|
||||
// the current user doesn't have permission to access it.
|
||||
func renderApplicationNotFound(rw http.ResponseWriter, r *http.Request, accessURL *url.URL) {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusNotFound,
|
||||
Title: "Application not found",
|
||||
Description: "The application or workspace you are trying to access does not exist.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: accessURL.String(),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -258,8 +258,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
|
|||
resp, err := client.Request(ctx, http.MethodGet, "/@me/"+workspace.Name+"/apps/fake/", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
// this is 200 OK because it returns a dashboard page
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -529,10 +528,9 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
|
|||
|
||||
// Should have an error response.
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
var resBody codersdk.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&resBody)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, resBody.Message, "does not accept application requests on this hostname")
|
||||
require.Contains(t, string(body), "does not accept application requests on this hostname")
|
||||
})
|
||||
|
||||
t.Run("InvalidSubdomain", func(t *testing.T) {
|
||||
|
@ -547,12 +545,11 @@ func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should have an error response.
|
||||
// Should have a HTML error response.
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
var resBody codersdk.Response
|
||||
err = json.NewDecoder(resp.Body).Decode(&resBody)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, resBody.Message, "Could not parse subdomain application URL")
|
||||
require.Contains(t, string(body), "Could not parse subdomain application URL")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -16,11 +16,6 @@
|
|||
<meta property="og:type" content="website" />
|
||||
<meta property="csp-nonce" content="{{ .CSP.Nonce }}" />
|
||||
<meta property="csrf-token" content="{{ .CSRF.Token }}" />
|
||||
<meta
|
||||
id="api-response"
|
||||
data-statuscode="{{ .APIResponse.StatusCode }}"
|
||||
data-message="{{ .APIResponse.Message }}"
|
||||
/>
|
||||
<!-- We need to set data-react-helmet to be able to override it in the workspace page -->
|
||||
<link
|
||||
rel="alternate icon"
|
||||
|
|
76
site/site.go
76
site/site.go
|
@ -3,11 +3,12 @@ package site
|
|||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1" //#nosec // Not used for cryptography.
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
htmltemplate "html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
@ -24,15 +25,26 @@ import (
|
|||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
type apiResponseContextKey struct{}
|
||||
// We always embed the error page HTML because it it doesn't need to be built,
|
||||
// and it's tiny and doesn't contribute much to the binary size.
|
||||
var (
|
||||
//go:embed static/error.html
|
||||
errorHTML string
|
||||
|
||||
// WithAPIResponse returns a context with the APIResponse value attached.
|
||||
// This is used to inject API response data to the index.html for additional
|
||||
// metadata in error pages.
|
||||
func WithAPIResponse(ctx context.Context, apiResponse APIResponse) context.Context {
|
||||
return context.WithValue(ctx, apiResponseContextKey{}, apiResponse)
|
||||
errorTemplate *htmltemplate.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
errorTemplate, err = htmltemplate.New("error").Parse(errorHTML)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns an HTTP handler for serving the static site.
|
||||
|
@ -87,14 +99,8 @@ func (h *handler) exists(filePath string) bool {
|
|||
}
|
||||
|
||||
type htmlState struct {
|
||||
APIResponse APIResponse
|
||||
CSP cspState
|
||||
CSRF csrfState
|
||||
}
|
||||
|
||||
type APIResponse struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
CSP cspState
|
||||
CSRF csrfState
|
||||
}
|
||||
|
||||
type cspState struct {
|
||||
|
@ -142,15 +148,6 @@ func (h *handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
|||
CSRF: csrfState{Token: nosurf.Token(req)},
|
||||
}
|
||||
|
||||
apiResponseRaw := req.Context().Value(apiResponseContextKey{})
|
||||
if apiResponseRaw != nil {
|
||||
apiResponse, ok := apiResponseRaw.(APIResponse)
|
||||
if !ok {
|
||||
panic("dev error: api response in context isn't the correct type")
|
||||
}
|
||||
state.APIResponse = apiResponse
|
||||
}
|
||||
|
||||
// First check if it's a file we have in our templates
|
||||
if h.serveHTML(resp, req, reqFile, state) {
|
||||
return
|
||||
|
@ -628,3 +625,34 @@ func extractBin(dest string, r io.Reader) (numExtracted int, err error) {
|
|||
n++
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorPageData contains the variables that are found in
|
||||
// site/static/error.html.
|
||||
type ErrorPageData struct {
|
||||
Status int
|
||||
Title string
|
||||
Description string
|
||||
RetryEnabled bool
|
||||
DashboardURL string
|
||||
}
|
||||
|
||||
// RenderStaticErrorPage renders the static error page. This is used by app
|
||||
// requests to avoid dependence on the dashboard but maintain the ability to
|
||||
// render a friendly error page on subdomains.
|
||||
func RenderStaticErrorPage(rw http.ResponseWriter, r *http.Request, data ErrorPageData) {
|
||||
type outerData struct {
|
||||
Error ErrorPageData
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
rw.WriteHeader(data.Status)
|
||||
|
||||
err := errorTemplate.Execute(rw, outerData{Error: data})
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to render error page: " + err.Error(),
|
||||
Detail: fmt.Sprintf("Original error was: %d %s, %s", data.Status, data.Title, data.Description),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ package site_test
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
@ -11,6 +10,7 @@ import (
|
|||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
@ -441,41 +441,32 @@ func TestExtractOrReadBinFS(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestServeAPIResponse(t *testing.T) {
|
||||
func TestRenderStaticErrorPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a test server
|
||||
rootFS := fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte(`{"code":{{ .APIResponse.StatusCode }},"message":"{{ .APIResponse.Message }}"}`),
|
||||
},
|
||||
d := site.ErrorPageData{
|
||||
Status: http.StatusBadGateway,
|
||||
Title: "Bad Gateway 1234",
|
||||
Description: "shout out colin",
|
||||
RetryEnabled: true,
|
||||
DashboardURL: "https://example.com",
|
||||
}
|
||||
binFS := http.FS(fstest.MapFS{})
|
||||
|
||||
apiResponse := site.APIResponse{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Message: "This could be an error message!",
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = r.WithContext(site.WithAPIResponse(r.Context(), apiResponse))
|
||||
site.Handler(rootFS, binFS).ServeHTTP(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
site.RenderStaticErrorPage(rw, r, d)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp := rw.Result()
|
||||
defer resp.Body.Close()
|
||||
var body struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
require.Equal(t, d.Status, resp.StatusCode)
|
||||
require.Contains(t, resp.Header.Get("Content-Type"), "text/html")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("resp: %q", data)
|
||||
err = json.Unmarshal(data, &body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, apiResponse.StatusCode, body.Code)
|
||||
require.Equal(t, apiResponse.Message, body.Message)
|
||||
bodyStr := string(body)
|
||||
require.Contains(t, bodyStr, strconv.Itoa(d.Status))
|
||||
require.Contains(t, bodyStr, d.Title)
|
||||
require.Contains(t, bodyStr, d.Description)
|
||||
require.Contains(t, bodyStr, "Retry")
|
||||
require.Contains(t, bodyStr, d.DashboardURL)
|
||||
}
|
||||
|
|
|
@ -34,9 +34,6 @@ const WorkspacePage = lazy(() => import("./pages/WorkspacePage/WorkspacePage"))
|
|||
const WorkspaceSchedulePage = lazy(
|
||||
() => import("./pages/WorkspaceSchedulePage/WorkspaceSchedulePage"),
|
||||
)
|
||||
const WorkspaceAppErrorPage = lazy(
|
||||
() => import("./pages/WorkspaceAppErrorPage/WorkspaceAppErrorPage"),
|
||||
)
|
||||
const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage"))
|
||||
const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"))
|
||||
const TemplatePage = lazy(() => import("./pages/TemplatePage/TemplatePage"))
|
||||
|
@ -189,17 +186,6 @@ export const AppRouter: FC = () => {
|
|||
}
|
||||
/>
|
||||
|
||||
<Route path="apps">
|
||||
<Route
|
||||
path=":app/*"
|
||||
element={
|
||||
<AuthAndFrame>
|
||||
<WorkspaceAppErrorPage />
|
||||
</AuthAndFrame>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="builds/:buildNumber"
|
||||
element={
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import { FC, useMemo } from "react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { WorkspaceAppErrorPageView } from "./WorkspaceAppErrorPageView"
|
||||
|
||||
const WorkspaceAppErrorView: FC = () => {
|
||||
const { app } = useParams()
|
||||
const message = useMemo(() => {
|
||||
const tag = document.getElementById("api-response")
|
||||
if (!tag) {
|
||||
throw new Error("dev error: api-response meta tag not found")
|
||||
}
|
||||
return tag.getAttribute("data-message") as string
|
||||
}, [])
|
||||
|
||||
return <WorkspaceAppErrorPageView appName={app as string} message={message} />
|
||||
}
|
||||
|
||||
export default WorkspaceAppErrorView
|
|
@ -1,22 +0,0 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import {
|
||||
WorkspaceAppErrorPageView,
|
||||
WorkspaceAppErrorPageViewProps,
|
||||
} from "./WorkspaceAppErrorPageView"
|
||||
|
||||
export default {
|
||||
title: "pages/WorkspaceAppErrorPageView",
|
||||
component: WorkspaceAppErrorPageView,
|
||||
}
|
||||
|
||||
const Template: Story<WorkspaceAppErrorPageViewProps> = (args) => (
|
||||
<WorkspaceAppErrorPageView {...args} />
|
||||
)
|
||||
|
||||
export const NotRunning = Template.bind({})
|
||||
NotRunning.args = {
|
||||
appName: "code-server",
|
||||
// This is a real message copied and pasted from the backend!
|
||||
message:
|
||||
"remote dial error: dial 'tcp://localhost:13337': dial tcp 127.0.0.1:13337: connect: connection refused",
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { FC } from "react"
|
||||
|
||||
export interface WorkspaceAppErrorPageViewProps {
|
||||
appName: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const WorkspaceAppErrorPageView: FC<
|
||||
React.PropsWithChildren<WorkspaceAppErrorPageViewProps>
|
||||
> = (props) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<h1 className={styles.title}>{props.appName} is offline!</h1>
|
||||
<p className={styles.message}>{props.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
padding: theme.spacing(10),
|
||||
},
|
||||
title: {
|
||||
textAlign: "center",
|
||||
},
|
||||
message: {
|
||||
textAlign: "center",
|
||||
},
|
||||
}))
|
Loading…
Reference in New Issue