diff --git a/misk-admin/api/misk-admin.api b/misk-admin/api/misk-admin.api index 7a778ae9af5..a9e9ae3ced8 100644 --- a/misk-admin/api/misk-admin.api +++ b/misk-admin/api/misk-admin.api @@ -985,16 +985,6 @@ public final class misk/web/metadata/webaction/WebActionsDashboardTabModule : mi public fun (Z)V } -public final class misk/web/v2/DashboardHotwireTabAction : misk/web/actions/WebAction { - public fun (Lmisk/scope/ActionScoped;Lmisk/web/v2/DashboardPageLayout;Ljava/util/List;)V - public final fun get (Ljava/lang/String;)Ljava/lang/String; -} - -public final class misk/web/v2/DashboardIFrameTabAction : misk/web/actions/WebAction { - public fun (Lmisk/scope/ActionScoped;Lmisk/web/v2/DashboardPageLayout;Ljava/util/List;)V - public final fun get (Ljava/lang/String;)Ljava/lang/String; -} - public final class misk/web/v2/DashboardIndexAccessBlock { public fun (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function6;)V public final fun component1 ()Lkotlin/reflect/KClass; diff --git a/misk-admin/src/main/kotlin/misk/web/dashboard/MiskWebTabIndexAction.kt b/misk-admin/src/main/kotlin/misk/web/dashboard/MiskWebTabIndexAction.kt index 4df597aac63..e757e04de57 100644 --- a/misk-admin/src/main/kotlin/misk/web/dashboard/MiskWebTabIndexAction.kt +++ b/misk-admin/src/main/kotlin/misk/web/dashboard/MiskWebTabIndexAction.kt @@ -1,5 +1,7 @@ package misk.web.dashboard +import jakarta.inject.Inject +import jakarta.inject.Singleton import kotlinx.html.body import kotlinx.html.div import kotlinx.html.head @@ -15,14 +17,14 @@ import misk.web.PathParam import misk.web.ResponseContentType import misk.web.actions.WebAction import misk.web.mediatype.MediaTypes -import jakarta.inject.Inject -import jakarta.inject.Singleton /** * Kotlin backed tab loader, equivalent to /_tab/slug/index.html */ @Singleton class MiskWebTabIndexAction @Inject constructor( + // TODO this probably shouldn't be limited to AdminDashboard tabs only but Misk-Web is + // deprecated so this can be solved if it's raised as a problem @AdminDashboard private val dashboardTabs: List, ) : WebAction { @Get("$PATH/{slug}/{rest:.*}") @@ -33,6 +35,8 @@ class MiskWebTabIndexAction @Inject constructor( ?: throw NotFoundException("No Misk-Web tab found for slug: $slug") // TODO remove this hack when new Web Actions tab lands and old ones removed, v1 and v2 are in the same web-actions tab val normalizedSlug = if (dashboardTab.slug == "web-actions-v1") "web-actions" else dashboardTab.slug + val tabEntrypointJs = "/_tab/${normalizedSlug}/tab_${normalizedSlug}.js" + return buildHtml { html { head { @@ -73,7 +77,7 @@ class MiskWebTabIndexAction @Inject constructor( // Tab specific resources script { type = "text/javascript" - src = "/_tab/${normalizedSlug}/tab_${normalizedSlug}.js" + src = tabEntrypointJs } } } diff --git a/misk-admin/src/main/kotlin/misk/web/v2/DashboardHotwireTabAction.kt b/misk-admin/src/main/kotlin/misk/web/v2/DashboardHotwireTabAction.kt index 1df4d2fe077..28495a55168 100644 --- a/misk-admin/src/main/kotlin/misk/web/v2/DashboardHotwireTabAction.kt +++ b/misk-admin/src/main/kotlin/misk/web/v2/DashboardHotwireTabAction.kt @@ -19,7 +19,7 @@ import misk.web.dashboard.AdminDashboardAccess * Builds dashboard UI and loads Hotwire tab. */ @Singleton -class DashboardHotwireTabAction @Inject constructor( +internal class DashboardHotwireTabAction @Inject constructor( @JvmSuppressWildcards private val clientHttpCall: ActionScoped, private val dashboardPageLayout: DashboardPageLayout, private val entries: List, diff --git a/misk-admin/src/main/kotlin/misk/web/v2/DashboardIFrameTabAction.kt b/misk-admin/src/main/kotlin/misk/web/v2/DashboardIFrameTabAction.kt index 00a0983ad95..0f068a64d74 100644 --- a/misk-admin/src/main/kotlin/misk/web/v2/DashboardIFrameTabAction.kt +++ b/misk-admin/src/main/kotlin/misk/web/v2/DashboardIFrameTabAction.kt @@ -1,28 +1,40 @@ package misk.web.v2 -import kotlinx.html.h1 +import jakarta.inject.Inject +import jakarta.inject.Singleton +import kotlinx.html.div import kotlinx.html.iframe import misk.scope.ActionScoped +import misk.tailwind.components.AlertError +import misk.tailwind.components.AlertInfo import misk.web.Get import misk.web.HttpCall import misk.web.PathParam import misk.web.ResponseContentType import misk.web.actions.WebAction +import misk.web.dashboard.AdminDashboardAccess +import misk.web.dashboard.DashboardTab import misk.web.dashboard.DashboardTabLoader import misk.web.dashboard.DashboardTabLoaderEntry +import misk.web.dashboard.MiskWebTabIndexAction import misk.web.mediatype.MediaTypes -import jakarta.inject.Inject -import jakarta.inject.Singleton -import misk.web.dashboard.AdminDashboardAccess +import misk.web.proxy.WebProxyAction +import misk.web.resources.StaticResourceAction +import okhttp3.HttpUrl.Companion.toHttpUrl +import wisp.deployment.Deployment /** * Builds dashboard UI and loads IFrame tab. */ @Singleton -class DashboardIFrameTabAction @Inject constructor( +internal class DashboardIFrameTabAction @Inject constructor( @JvmSuppressWildcards private val clientHttpCall: ActionScoped, private val dashboardPageLayout: DashboardPageLayout, + private val dashboardTabs: List, + private val deployment: Deployment, private val entries: List, + private val staticResourceAction: StaticResourceAction, + private val webProxyAction: WebProxyAction, ) : WebAction { @Get("/{suffix:.*}") @ResponseContentType(MediaTypes.TEXT_HTML) @@ -34,10 +46,56 @@ class DashboardIFrameTabAction @Inject constructor( val entry = entries .filter { it.loader is DashboardTabLoader.IframeTab } .firstOrNull { fullPath.startsWith(it.urlPathPrefix) } - (entry?.loader as? DashboardTabLoader.IframeTab)?.let { - iframe(classes = "h-full w-full") { - src = "${it.iframePath}$suffix" + + val iframeTab = entry?.loader as? DashboardTabLoader.IframeTab + + div("container mx-auto p-8") { + if (iframeTab != null) { + // If the tab is found, render the iframe + + if (iframeTab.iframePath.startsWith(MiskWebTabIndexAction.PATH)) { + // If tab is Misk-Web, do extra validation to show a more helpful error message + + val slug = iframeTab.urlPathPrefix.split("/").last { it.isNotBlank() } + val dashboardTab = dashboardTabs.firstOrNull { slug == it.slug } + + if (dashboardTab == null) { + AlertError("No Misk-Web tab found for slug: $slug. Check your install bindings or Misk-Web build.") + } else { + // TODO remove this hack when new Web Actions tab lands and old ones removed, v1 and v2 are in the same web-actions tab + val normalizedSlug = + if (dashboardTab.slug == "web-actions-v1") "web-actions" else dashboardTab.slug + val tabEntrypointJs = "/_tab/${normalizedSlug}/tab_${normalizedSlug}.js" + + // If tab is Misk-Web do additional checks and show separate development and real errors + if (deployment.isLocalDevelopment) { + // If local development, check web proxy action and show fuller development 404 message + val tabEntrypointJsResponse = + webProxyAction.getResponse(("http://localhost/" + tabEntrypointJs).toHttpUrl()) + if (tabEntrypointJsResponse.statusCode != 200) { + AlertError("Failed to load Misk-Web tab: ${dashboardTab.menuCategory} / ${dashboardTab.menuLabel}") + AlertInfo("In local development, this can be from not having your local dev server (ie. Webpack) running or not doing an initial local Misk-Web build to generate the necessary web assets. Try running in your Terminal: \$ gradle buildMiskWeb OR \$ misk-web ci-build -e.") + } + } else if (deployment.isReal) { + // If real environment, only check static resource action and show limited 404 message + val tabEntrypointJsResponse = + staticResourceAction.getResponse(("http://localhost/" + tabEntrypointJs).toHttpUrl()) + if (tabEntrypointJsResponse.statusCode != 200) { + AlertError("Failed to load Misk-Web tab: ${dashboardTab.menuCategory} / ${dashboardTab.menuLabel}") + AlertInfo("In real environments, this is usually because of a web build failure in CI. Try checking CI logs and report this bug to your platform team. If the CI web build fails or is not run, the web assets will be missing from the Docker context when deployed and fail to load.") + } + } + } + } + + // Always still show iframe so that full load errors show up in browser console + iframe(classes = "h-full w-full") { + src = "${iframeTab.iframePath}$suffix" + } + } else { + // If tab is not found show alert error + AlertError("""Dashboard tab not found at $fullPath""") } - } ?: h1 { +"""Dashboard tab not found at $fullPath""" } + } } } diff --git a/misk/api/misk.api b/misk/api/misk.api index aafaf37c853..21729e3bfc3 100644 --- a/misk/api/misk.api +++ b/misk/api/misk.api @@ -2296,6 +2296,7 @@ public final class misk/web/proxy/OptionalBinder { public final class misk/web/proxy/WebProxyAction : misk/web/actions/WebAction { public fun (Lmisk/web/proxy/OptionalBinder;Lmisk/scope/ActionScoped;Lmisk/web/resources/StaticResourceAction;Lmisk/web/resources/ResourceEntryFinder;)V public final fun action ()Lmisk/web/Response; + public final fun getResponse (Lokhttp3/HttpUrl;)Lmisk/web/Response; } public final class misk/web/proxy/WebProxyEntry : misk/web/dashboard/ValidWebEntry { @@ -2327,7 +2328,7 @@ public final class misk/web/resources/ResourceEntryFinder { public final class misk/web/resources/StaticResourceAction : misk/web/actions/WebAction { public fun (Lmisk/scope/ActionScoped;Lmisk/resources/ResourceLoader;Lmisk/web/resources/ResourceEntryFinder;)V public final fun action ()Lmisk/web/Response; - public final fun getResponse (Lmisk/web/HttpCall;)Lmisk/web/Response; + public final fun getResponse (Lokhttp3/HttpUrl;)Lmisk/web/Response; } public final class misk/web/resources/StaticResourceEntry : misk/web/dashboard/ValidWebEntry { diff --git a/misk/src/main/kotlin/misk/web/proxy/WebProxyAction.kt b/misk/src/main/kotlin/misk/web/proxy/WebProxyAction.kt index f5a0ae2a449..166b86b3d7d 100644 --- a/misk/src/main/kotlin/misk/web/proxy/WebProxyAction.kt +++ b/misk/src/main/kotlin/misk/web/proxy/WebProxyAction.kt @@ -55,11 +55,15 @@ class WebProxyAction @Inject constructor( @Unauthenticated fun action(): Response { val httpCall = clientHttpCall.get() - val matchedEntry = resourceEntryFinder.webProxy(httpCall.url) as WebProxyEntry? - ?: return NotFoundAction.response(httpCall.url.toString()) + return getResponse(httpCall.url) + } + + fun getResponse(url: HttpUrl): Response { + val matchedEntry = resourceEntryFinder.webProxy(url) as WebProxyEntry? + ?: return NotFoundAction.response(url.toString()) val proxyUrl = matchedEntry.web_proxy_url.newBuilder() - .encodedPath(httpCall.url.encodedPath) - .query(httpCall.url.query) + .encodedPath(url.encodedPath) + .query(url.query) .build() return forwardRequestTo(proxyUrl) } @@ -70,7 +74,7 @@ class WebProxyAction @Inject constructor( return try { optionalBinder.proxyClient.newCall(proxyRequest).execute().toMisk() } catch (e: IOException) { - staticResourceAction.getResponse(httpCall) + staticResourceAction.getResponse(httpCall.url) } } diff --git a/misk/src/main/kotlin/misk/web/resources/StaticResourceAction.kt b/misk/src/main/kotlin/misk/web/resources/StaticResourceAction.kt index 9f780fa4f12..2f3709abfba 100644 --- a/misk/src/main/kotlin/misk/web/resources/StaticResourceAction.kt +++ b/misk/src/main/kotlin/misk/web/resources/StaticResourceAction.kt @@ -51,14 +51,14 @@ class StaticResourceAction @Inject constructor( @Unauthenticated // TODO(adrw) https://github.com/square/misk/issues/429 fun action(): Response { val httpCall = clientHttpCall.get() - return getResponse(httpCall) + return getResponse(httpCall.url) } - fun getResponse(httpCall: HttpCall): Response { + fun getResponse(url: HttpUrl): Response { val staticResourceEntry = - resourceEntryFinder.staticResource(httpCall.url) as StaticResourceEntry? - ?: return NotFoundAction.response(httpCall.url.encodedPath.drop(1)) - return MatchedResource(staticResourceEntry).getResponse(httpCall) + resourceEntryFinder.staticResource(url) as StaticResourceEntry? + ?: return NotFoundAction.response(url.encodedPath.drop(1)) + return MatchedResource(staticResourceEntry).getResponse(url) } private enum class Kind { @@ -68,11 +68,11 @@ class StaticResourceAction @Inject constructor( } private inner class MatchedResource(var matchedEntry: StaticResourceEntry) { - fun getResponse(httpCall: HttpCall): Response { - val urlPath = httpCall.url.encodedPath + fun getResponse(url: HttpUrl): Response { + val urlPath = url.encodedPath return when (exists(urlPath)) { Kind.NO_MATCH -> when { - !urlPath.endsWith("/") -> redirectResponse(normalizePathWithQuery(httpCall.url)) + !urlPath.endsWith("/") -> redirectResponse(normalizePathWithQuery(url)) // actually return the resource, don't redirect. Path must stay the same since this will be handled by React router urlPath.endsWith("/") -> resourceResponse( normalizePath(matchedEntry.url_path_prefix) @@ -82,8 +82,8 @@ class StaticResourceAction @Inject constructor( } Kind.RESOURCE -> resourceResponse(urlPath) - Kind.RESOURCE_DIRECTORY -> resourceResponse(normalizePathWithQuery(httpCall.url)) - } ?: NotFoundAction.response(httpCall.url.encodedPath.drop(1)) + Kind.RESOURCE_DIRECTORY -> resourceResponse(normalizePathWithQuery(url)) + } ?: NotFoundAction.response(url.encodedPath.drop(1)) } /** Returns true if the mapped path exists on either the resource path or file system. */ diff --git a/samples/exemplar/src/main/kotlin/com/squareup/exemplar/ExemplarService.kt b/samples/exemplar/src/main/kotlin/com/squareup/exemplar/ExemplarService.kt index 9443c98dff7..2248864ad28 100644 --- a/samples/exemplar/src/main/kotlin/com/squareup/exemplar/ExemplarService.kt +++ b/samples/exemplar/src/main/kotlin/com/squareup/exemplar/ExemplarService.kt @@ -19,7 +19,7 @@ fun main(args: Array) { ConfigModule.create("exemplar", config), DeploymentModule(deployment), ExemplarAccessModule(), - ExemplarDashboardModule(), + ExemplarDashboardModule(deployment), ExemplarMetadataModule(), ExemplarWebActionsModule(), ExemplarCronModule(), diff --git a/samples/exemplar/src/main/kotlin/com/squareup/exemplar/dashboard/ExemplarDashboardModule.kt b/samples/exemplar/src/main/kotlin/com/squareup/exemplar/dashboard/ExemplarDashboardModule.kt index 6064b78debf..6776cfa948b 100644 --- a/samples/exemplar/src/main/kotlin/com/squareup/exemplar/dashboard/ExemplarDashboardModule.kt +++ b/samples/exemplar/src/main/kotlin/com/squareup/exemplar/dashboard/ExemplarDashboardModule.kt @@ -21,8 +21,9 @@ import misk.web.dashboard.DashboardModule import misk.web.metadata.config.ConfigMetadataAction import misk.web.resources.StaticResourceAction import misk.web.resources.StaticResourceEntry +import wisp.deployment.Deployment -class ExemplarDashboardModule : KAbstractModule() { +class ExemplarDashboardModule(private val deployment: Deployment) : KAbstractModule() { override fun configure() { // Favicon.ico and any other shared static assets available at /static/* multibind() @@ -95,6 +96,19 @@ class ExemplarDashboardModule : KAbstractModule() { url = { appName, deployment -> "https://internal-tool.cash.app/?app=$appName&deployment=$deployment" }, category = "Internal" )) + + // Custom Admin Dashboard Tab at /_admin/... which doesn't exist and shows graceful failure 404 + install(WebActionModule.create()) + install( + DashboardModule.createMiskWebTab( + isDevelopment = deployment.isLocalDevelopment, + slug = "not-found", + urlPathPrefix = "/_admin/not-found/", + developmentWebProxyUrl = "http://localhost:3000/", + menuLabel = "Not Found", + menuCategory = "Admin Tools" + ) + ) } }