Skip to content

Commit 4f29ed0

Browse files
authored
refactor(dashboard)!: replace the server-rendered UI with React plugins (#99)
* feat(dashboard): let extensions report version and configured state * fix(dashboard): attribute contributor status at registration instead of inferring it * fix(dashboard): forget contributor status when unregistering through the recording registry recordingRegistry overrode Register and RegisterRemote but not Unregister, so a name freed through the registry an extension was handed kept pointing at the old owner. Whoever claimed that name next served the previous extension's live status. forgetContributorStatus already existed and was already wired into UnregisterRemoteContractContributor. This covers the other route that frees a name, which is the one an extension reaches on its own. * refactor(dashboard): delete the server-driven React shell and its 11MB embed * refactor(dashboard): remove the graph kind from the contract transport * refactor(dashboard): delete the graph model, slot merging and the component vocabulary * test(streaming): drop the dead graph-route-count assertion extensions/dashboard/contract just deleted ContractManifest.Graph (the UI-graph model). This test lives outside the dashboard tree, in the streaming extension's own module, and was the only other consumer in the whole repo referencing that field. Its remaining assertions (contributor name, intent count, warden validation) still cover live behaviour and are untouched. * refactor(dashboard): trim the pilot contributor to data intents only The pilot manifest no longer describes screens. Cut the graph section from manifest.yaml along with the navigation query and intent, since the plugin host now builds the sidebar from each plugin's own nav array instead of walking the contract registry. navigation.go and its test go with it, they had no other purpose. This also restores the build: navigation.go was the last reader of ContractManifest.Graph after Task 6 dropped the field, and types_test.go asserted a graph route count that no longer compiles. Updated the intents count in that test to 12 and dropped the graph assertion. Seven data intents survive untouched: overview, health, metrics, services, extensions.list, traces, audit. * fix(dashboard): clear the graph residue tasks 6+7 review round 1 found Delete four exported declarations with zero non-declaration references: NavConfig, DataBinding (with its UnmarshalYAML), ExtensionTarget, and PermissionsHash. The TestDataBinding_BothShapes and PermissionsHash tests go with them, they had no other reason to exist. Delete the streaming contract's 149-line graph: block, the largest surviving graph document in the repo. It was already inert (yaml.Unmarshal has no KnownFields, so the key was silently dropped) but left standing it would read as live config to the next person through this file. Drop ResponseMeta.RouteParams: no writer anywhere in the tree since MatchRoute went, and no wire consumer in forge-dashboard either. Add TestProjectAppHome_PrefixesWithSlug. The /@<slug><home> URL shape projectAppHome implements lost its only assertion when TestNavigationHandler_PrefixesHrefWithAppSlug died with navigation_test.go, even though the behavior it drove survives in apps.list. Verified by hand: a version of projectAppHome that skips prefixing fails the new test, restored, passes clean. Reworded ten comments and one doc string that still described deleted graph/slot machinery (contract/doc.go, manifest.go, loader/validate.go, loader/yaml.go, warden.go, transport/http.go, pilot/apps.go, aware.go) and cleaned two loader test fixtures that still carried dead graph: YAML. Also fixed the dashboard-contract-probe flag help text Task 5 left stale, and a ragged comment reflow in registry.go's Unregister doc. * fix(dashboard): stop redirecting the core paths into a prefix that no longer exists Task 4 deleted the {base}/ui routes. Nothing checked who was pointing at them. registerShellRedirects was still handing out ten 302s onto that prefix, and it ran whenever LegacyUI was false, which is the default. So GET /dashboard sent you to /dashboard/ui/ and /dashboard/ui/ was a 404. Same for health, metrics, services, extensions, traces and the three metrics deep links. Those ten registrations are gone. The core paths now 404 directly. That is the honest answer until the prebuilt shell artifact lands: bouncing a browser through a dead prefix tells you less than the 404 does. LegacyUI is untouched and still off by default, so WithLegacyUI(true) remains the escape hatch and still serves the templ pages at all ten paths. RootContributor still owns "/" either way, since an embedded dashboard that claimed the root asked for that explicitly and the redirects were never what made it work. redirectTo and redirectTraceDetail went with the call sites. So did the registerRootContributor fallback that 302'd a proxy-less remote into /ui/. The three comments asserting the opposite are rewritten. config.go claimed the shell stays mounted at {BasePath}/ui either way and that deep links keep working, both false. extension.go and pages.go both described a React shell serving six pages that is not there. Also in this pass, all of it the same defect in prose: - manifest.go said Query is a binding a contributor's React code references by name. Nothing references it and nothing can. Plugins never see the Go manifest; they call intents through the scoped client. Queries is read only by loader.Validate's self-consistency check and QueryCache by nobody. The section stays, the invented consumer does not. - manifest.go and pilot/apps.go both told plugin authors their React routes live under /@<slug>/*. definePlugin's own example declares /authsome/users with no prefix, and apps.list has no TypeScript caller at all. The comments now say the projection has no consumer and that a later wave picks one way or the other. projectAppHome and its test stay; the function does what the test says. - contract/doc.go pointed at DESIGN.md as "the spec this implements". DESIGN.md is the graph spec and says graph 18 times. Now marked as history. - .gitignore kept three un-ignore rules for contract/shell/, deleted in 6925566. loader.Validate's query-to-intent loop had no negative test while the doc comment advertised it as one of four jobs. It has one now, four cases across the looksCrossContributor boundary. Break the loop and two of them fail. * docs(dashboard): stop the GoDoc promising a React shell that was deleted Comments only. `git diff` with comment lines filtered out is empty. The two that mattered most are exported GoDoc, which is what somebody reads to decide how to configure their dashboard. WithRootContributor said empty "keeps the shell redirect". WithLegacyUI said "The shell is still mounted at {BasePath}/ui, so this does not remove it." Read that second one and you conclude you can leave the flag off and still reach a UI at /ui. You cannot. There is no shell, no /ui, and with the flag off the core paths 404. Both now say that, and PagesConfig's near-verbatim copy of the struct comment says it too. No wave is named for when /ui comes back, only "a later wave", because this wave has now been bitten twice by a comment naming a wave that slipped. Then I grepped for the rest of them, which is the lesson from the last round. Nine more sites asserted a live shell. Six are the same claim wearing different clothes: the principal endpoint, the CSRF endpoint and the RequiredRoles 403 were all documented as things "the React shell" renders. Those endpoints are real and still serve. Their consumer is not, so they now say what they return and note that nothing renders it yet. aware.go was the worst of the nine and the reason this is not a cosmetic pass. It tells auth-extension authors how to integrate, and it told them the shell ships a LoginScreen and an AuthGate that picks between it and their own /login page. Both deleted. An author following it wires a real DashboardAuthAware and a real auth.login intent, then waits for a login screen that cannot appear. Server side is unchanged and still correct; the client half now says plainly that it does not exist and that registering is still worth doing. pilot.go and apps.go described apps.list as feeding "the React shell's app switcher". Same finding as Important 3 last round, which I fixed in the type docs and missed in the registration site and the loop body. Both now match. Left alone, deliberately. The `dashboard/ui/shell` package is the templ shell, topbar and breadcrumbs and user dropdown, which is live and compiled and has nothing to do with the React one. "Micro-frontend shell" on the Extension type describes the extension itself. The `shellEnvelopes` wording on the capabilities handler matches the JSON key the endpoint actually emits. And tracing_middleware_test's {"/dashboard/ui", true} tests a prefix matcher, which returns true for any subpath whether or not something serves it, so it asserts nothing about /ui existing. * feat(dashboard): serve the prebuilt shell behind a ShellSource switch {base}/ui returns a page again. It has been a 404 since W3 deleted the server-driven React shell, which is what we wanted at the time: that app was committed into this repo and //go:embed all:dist pulled 11MB into every binary importing the extension, 6MB of it sourcemaps. The shell lives in forge-dashboard now and ships as a release tarball. Release CI unpacks it over extensions/dashboard/shellassets/dist before it builds. What is committed there is a placeholder: a small page that explains itself if you hit it, and a README saying how to fetch the real thing by hand. //go:embed needs files at build time, so without the placeholder a plain go build fails offline and in every consumer's CI. IsPlaceholder() looks for a meta tag in that page rather than checking a size, because a real build's size is not a stable signal, and the extension logs a warning at startup when it finds one. Two things in the handler look like details and are not. It injects window.__FORGE_DASHBOARD__ before </head>, because the bundle cannot know at build time where it is mounted. And it rewrites "./assets/ to one absolute URL under the base path, on the bytes, before the response goes out. That second one cannot move into the bootstrap. The browser resolves src and href while parsing the document, so by the time any script of ours runs the wrong requests are already gone and already 404ing. ShellSource defaults to ShellEmbedded, and the serving path reads the zero value "" as embedded too, because a Config built as a struct literal never passes through DefaultConfig(). ShellExternal mounts nothing at all, for deployments that build their own shell with their own plugins. The bootstrap sets basePath, contractBase, shellBase, authEnabled and loginPath. loginContributor and loginOp are left out on purpose. They were hardcoded to authsome's values behind a comment about deployments overriding them later, later never came, and nothing read them by the time the old shell went. W5 builds the login UI and can decide whether they belong in the bootstrap at all. .gitignore needed a negation. A blanket dist/ rule was hiding the placeholder, which would have left a fresh clone unbuildable with no obvious cause. The new rules re-include index.html and README.md and nothing else, so an unpacked artifact stays ignored even under git add -A. * fix(dashboard): sweep the stale shell docs and tighten the static handler pages.go still said nothing was mounted at {BasePath}/ui. Three places, and the worst of them reasoned out loud about why no redirect was needed, which is advice someone would have acted on. I corrected the same claim in config.go and extension.go in the last commit and missed this file, so this is the grep I should have run before committing: grep -rn "prebuilt shell artifact|no renderer|nothing is mounted" \ extensions/dashboard --include='*.go' One hit is left and it is accurate history, in shell_handlers.go, about the old shell having been deleted. Validate() now allow-lists shell_source the way it already allow-lists theme and default_access. Empty stays legal and still means embedded. A typo used to match neither branch of shellEnabled(), so `shell_source: embeded` cost you the whole dashboard with nothing in the logs. The static handler had two smaller problems. It tested the asset path with strings.Contains, so /vendor/assets/x.js got a year of immutable caching without a content hash to back the promise. That is HasPrefix now. And it set Cache-Control before handing off to the file server, which decides the status afterwards, so an error response could in principle inherit the immutable directive. Go's http.Error happens to clear the header map first, so no 404 was ever actually served that way, but the guarantee should not rest on that. The wrapper applies the header on 2xx and on 304. The 304 case matters: a revalidation response has to carry the freshness directives the 200 would have, or a client that revalidates ends up worse off than one that never asked. Also pinned IsPlaceholder() against the committed placeholder. Edit the meta tag without editing the sentinel and the startup warning stops firing, and the only symptom is a log line that is not there. The test skips when a real artifact is unpacked over dist/, so release CI is unaffected. Two doc corrections. The dist README had its opening sentence inverted, saying everything except the two placeholder files was the placeholder. And WithShellSource claimed ShellExternal left {BasePath}/ui unmounted; the routes are not registered, but the path falls through to the ForgeUI catch-all at {BasePath}/*. Measured: it still 404s today, through a different handler, and a deployment that registers its own page there gets it. The route-ordering comment now names the backends it was measured against and admits the fourth. extras.httprouter panics at registration on these three routes in either order rather than resolving them, so "every backend forge ships" was an overclaim. * ci(release): embed the pinned dashboard shell artifact * feat(cli): forge dashboard new * ci(release): fix non-atomic dist swap, exit-code leak, and guard status handling - extract into a staging directory and swap it into dist/ rather than clearing dist/ then extracting in place, so a failed extraction (disk full, I/O error) never leaves dist/ empty - the cleanup() EXIT trap could itself fail under set -e and silently turn a successful run into exit 1; made every cleanup step best-effort - the uncommitted-changes guard is capturing git status's output before testing it, so a failing git status can no longer be misread as clean - documented the residual gap where nothing distinguishes a real goreleaser release from an ad hoc local one with a real token * fix(cli): add PluginErrorBoundary and dashboard-runtime to dashboard scaffold * fix(cli): anchor the boundary discriminator to structure, tidy dashboard plugin docs * fix(dashboard): gate the shell pin behind a sentinel and verify its digest The next forge release would have died in the before-hook. .dashboard-shell-version said 0.0.0, no forge-dashboard release exists under that tag, and the fetch script goes strict whenever CI=true, which every GitHub runner sets. Both the release path and the dry-run rehearsal fail there. So the script knows a sentinel now. An empty version file, or the literal "none", means no shell is pinned yet. That path keeps the placeholder and exits 0 even under CI, and it warns loudly on the way out naming the sentinel, because a release built that way serves a placeholder page and ought to say so. Both files read "none" today. Cutting a real shell release is the owner's action, so the bump procedure now lives in extensions/dashboard/shellassets/dist/README.md: who tags what, in which order, and what goes in each file. The other half is integrity. A GitHub release asset can be deleted and re-uploaded under an existing tag with no commit and no trace in git history, so pinning a version pins a name and nothing about the bytes. Anyone with write access to that repo's releases could put arbitrary JavaScript into every Forge binary's dashboard. .dashboard-shell-version.sha256 now sits beside the version pin, and the download is checked against it before anything is unpacked. A mismatch is fatal in every mode, strict or not, and a pinned version carrying no digest is refused outright. Also in here: * The scaffold's @forge-go deps move from "^0.0.0" to "latest". Caret does not widen on a 0.0.x, so "^0.0.0" means exactly 0.0.0, and every scaffolded project failed its first install. * `forge dashboard new` and the README it writes both say the packages are not published yet. That was honest in the source comments and nowhere a user would look. * The fetch script strips and validates a leading "v", so a version file reading "v1.4.0" no longer builds a URL under download/vv1.4.0/. * .gitignore covers the script's staging and stale sibling directories. * The Cache-Control comments blamed http.Error. The real mechanism is the unexported net/http.serveError in fs.go, gated behind GODEBUG=httpservecontentkeepheaders. * embed.go says why the directive is a plain `dist`. Dropping the `all:` is what keeps the fetch marker out of the binary. * "Task 2", "fix round 1" and "fix round 2" are gone from shipped source. * docs(dashboard): point the bootstrap comment at shellBase, not basePath The doc comment on shellBootstrap still said the client reads basePath for its router basename. It never did, and after this wave the basename is shellBase. Two other comments in this file were corrected in the same round and this one was missed, so it sat directly beside the fix it contradicts. That is worse than a stale comment. basePath is the contract's prefix, one directory above the pages, and a reader who follows the sentence wires basename={basePath}, which matches nothing and leaves an empty content pane under a sidebar and header that look perfectly healthy. It is the exact bug the new App tests exist to catch, and substituting basePath for shellBase fails both of them. The correction names shellBase and then says plainly that basePath is not the basename, because the field is two lines below and picking the wrong one is the natural mistake. * refactor(streaming): drop the server-rendered dashboard The React plugin @forge-go/dashboard-plugin-streaming has been rendering overview, rooms and connections since W5, and it reads everything from the streaming-contract contributor. That leaves extensions/streaming/dashboard serving nothing, so it goes. Deleting the package takes two registration sites with it. DashboardContributor built the templ LocalContributor, and RegisterDashboardBridge registered ten streaming.* RPC functions on the forgeui bridge. Both are optional interfaces that the dashboard extension probes with a type assertion, so the assertions stop matching and discovery skips streaming. RegisterContractContributor stays, and it is now the only dashboard surface streaming exposes. templ and forgeui are no longer direct dependencies. Both stay in go.mod as indirect, because extensions/dashboard/contract pulls in extensions/dashboard/auth, and that package still renders its own login pages with templ and routes them through forgeui. Those two lines will clear on their own once forge's own dashboard migrates off templ, which is the last step of W6 and waits on authsome. * feat(dashboard)!: remove the LegacyUI escape hatch and CoreContributor LegacyUI gated exactly one thing: constructing CoreContributor and registering it under "core" so the templ core pages (overview, health, metrics, services, extensions, traces) had something to render through. Default was already off, so this drops dead configuration rather than changing behavior for anyone running the default. Removes Config.LegacyUI, WithLegacyUI, PagesConfig.LegacyUI, and core_contributor.go. WithLegacyUI was exported; nothing in this monorepo called it (confirmed by the W6 GA legacy-surface-map), but it is a breaking API change for anything outside it. PagesManager.RegisterPages no longer branches on LegacyUI -- it always takes the RootContributor path when one is set, same as before with the flag off. registerLegacyCorePages and its ten page handlers stay in pages.go, now unreachable: retiring the rest of the templ contributor system is a separate decision under review, not this task's job. * docs(dashboard): repoint the slice f link at the contract package extensions/streaming/dashboard is gone, so the link in the context section resolved to nothing. It now names the deleted path in plain text and links to extensions/streaming/contract, which is what took over. The rest of the doc still reads as though the migration window is open. It says the legacy /dashboard/ext/streaming/* URLs keep working, and it defers retiring the templ paths to a follow-on slice. That slice is W6, and the streaming half has landed, so the body needs a pass once the dashboard side settles. * feat(dashboard)!: delete the core server-rendered pages Removes the ten templ page components (overview, health, metrics, metrics/all, metrics collector detail, metrics detail, services, extensions, traces, trace detail), the ten now-unreachable PagesManager methods that rendered them, and registerLegacyCorePages, which used to wire them onto routes. Their supporting *_helpers.go files and chart_helpers.go go with them. 15,096 lines gone. This is safe because nothing could reach any of it any more. Task 2 already removed LegacyUI and CoreContributor, the only thing that constructed the "core" contributor these pages rendered through and the only caller of registerLegacyCorePages, so the pages had been unreachable since that commit. On the data side, core-contract already serves an intent for everything these ten covered: Overview, Health, Metrics, Traces, Services, and Extensions. The templ contributor system stays, and that's deliberate, not something this commit forgot. RegisterPages had exactly one LegacyUI-gated branch, the one choosing between registerLegacyCorePages and whatever RootContributor was set. Everything else it does, settings pages, extension-layout contributor pages, remote contributor pages and widgets, is live code with no contract-side replacement, and none of it was ever behind the flag. Extensions can still contribute pages, widget fragments, and settings panels through that system, and the shell renders them through the fragment proxy. Two pieces of the deleted pages' plumbing stay for the same reason. ui/pages/error.templ (ErrorPage) and the bare ui package's WidgetErrorFragment are called from live routes in pages.go: contributor lookup failures, remote widget failures, settings errors. None of that is CoreContributor-specific. And a-h/templ stays in go.mod because the contributor system this commit leaves standing is templ end to end. The old core paths (/, /health, /metrics, /services, /extensions, /traces) 404 now, and nothing redirects them. The replacement is the React shell at {BasePath}/ui, which the extension mounts itself. * docs(dashboard): stop advertising the deleted core pages features.mdx, index.mdx, the extension README, and the basic example all described /, /health, /metrics, and /services as pages the built-in core contributor serves. That contributor is gone, nothing configures those routes back, and docs/content/docs is mirrored to the public site, so this was about to ship four 404ing URLs in a GA release. Replaced each with what's actually there: the React shell at {BasePath}/ui, mounted directly by the extension, which covers those same four views and reads its data over the contract envelope. The legacy JSON API at {BasePath}/api/* is untouched and still documented as is, since it's a deliberate, separate thing. Called out explicitly in both docs and the README that the templ contributor system survives: extensions still register pages, widgets, and settings through it, and the shell renders them via the fragment proxy. The basic example's header comment and inline URLs pointed at the same dead routes. Updated it to print the working shell URL and describe what it actually demonstrates now: default configuration, no contributors registered. * docs(dashboard): correct SLICE_I_DESIGN's rejected redirect plan This design doc's "replace with redirects" section described a plan that was reconsidered before implementation and never built: the ten old core paths don't 302 anywhere, they 404, and pages.go says so in its own comment. The doc still claimed 302s in three places: the redirect table, a curl example in Verification, and a bookmarks claim in Risks. All three would mislead anyone who read this after the fact looking for a redirect that doesn't exist. The table's targets were also wrong on their own terms: the shell moved to {BasePath}/ui, not the /dashboard/contract/app/* path this doc assumed throughout. Added a "What actually shipped" section up top pointing at the correction, marked the redirect table as considered-but-rejected rather than deleting it, and updated Verification and Risks to describe the actual 404 behavior instead of a 302 that was never built. Fixed the same stale URL in "Why now." Left the "Out of scope" keep list alone. It's the one place in the repo that names the surviving templ pieces (ui/shell, layouts, the bare ui components, error.templ, the settings and extension handlers) accurately, and it's worth keeping findable. * fix(dashboard): correct comments left describing deleted code Four comments still described CoreContributor or its core pages as present tense fact after both were removed. pages.go's RegisterPages doc comment said core dashboard pages inherit the default layout; there are no core dashboard pages, only the root contributor page does that when one is configured. The line above defaultMW said it resolved middleware "for core pages"; that middleware is what settings, the root contributor, and remote contributor pages actually use. pilot.go's Deps doc said slice (h) added providers so the pilot covers every page CoreContributor serves; reworded to say what survives without the name, since the pilot's job (covering the same data the old pages rendered) didn't change when the type went away. extension.go had the same pattern in the pilot.Register call: a comment about wiring "the rest of CoreContributor's data sources" now names the data sources instead of the type that no longer exists. Also corrected extension.go's other RebuildIndex call, which runs right after e.registry is constructed and before anything registers into it. Its comment claimed it rebuilds "against any contributors registered later," which was true when CoreContributor's construction sat between those two points; now nothing does, so the call indexes zero contributors every time. Kept the call rather than removing it (this wave is comment-only, no behavior changes) and rewrote the comment to say why an empty rebuild here is harmless: RebuildIndex is a cheap, idempotent scan, and the rebuild that actually matters runs later in discoverExtensionContributors, after contributors exist to index.
1 parent 4ee53c3 commit 4f29ed0

342 files changed

Lines changed: 3650 additions & 59287 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.dashboard-shell-version‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
none

‎.dashboard-shell-version.sha256‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
none

‎.gitignore‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,26 @@ vendor/
7373
bin/
7474
dist/
7575

76+
# ...except the dashboard shell placeholder, which is committed on purpose.
77+
# //go:embed needs files present at build time, so without it a plain
78+
# `go build` fails in a fresh clone and in every consumer's CI. Only these two
79+
# files: the real shell artifact is a tarball that release CI unpacks over this
80+
# directory, and it must never be committed. Un-exclude the directory before
81+
# re-including anything inside it. Git cannot re-include a file whose parent
82+
# directory is excluded.
83+
!extensions/dashboard/shellassets/dist/
84+
extensions/dashboard/shellassets/dist/*
85+
!extensions/dashboard/shellassets/dist/index.html
86+
!extensions/dashboard/shellassets/dist/README.md
87+
88+
# scripts/fetch-dashboard-shell.sh extracts into a sibling staging directory
89+
# and moves the old dist/ aside before swapping, so both of these live next
90+
# to dist/ rather than inside it and neither is covered by the rules above.
91+
# They are cleaned up on the way out, including on the EXIT trap, but a hard
92+
# kill mid-run leaves one behind and it should not turn up in `git status`.
93+
extensions/dashboard/shellassets/.dist-staging.*
94+
extensions/dashboard/shellassets/dist.stale.*
95+
7696
# Documentation artifacts
7797
/_impl_docs/docs/_build/
7898
_impl_docs/
@@ -123,9 +143,6 @@ METRICS_DEADLOCK_FIX.md
123143
!/CONTRIBUTING.md
124144
!docs/**/*.md
125145
!extensions/dashboard/contract/*.md
126-
!extensions/dashboard/contract/shell/*.md
127-
!extensions/dashboard/contract/shell/test/
128-
!extensions/dashboard/contract/shell/test/**
129146
/**/*.disabled
130147
extensions/dashboard/forgeui
131148
*.blob

‎.goreleaser.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ before:
3030
# sit inside another workspace -- which a git worktree under the main clone
3131
# does. Pinning it keeps the hook working wherever the tree is checked out.
3232
- sh -c 'rm -f go.work go.work.sum && GOWORK="$PWD/go.work" go work init . ./cmd/forge'
33+
# Swaps the committed placeholder in extensions/dashboard/shellassets/dist/
34+
# for the real dashboard shell artifact published by xraph/forge-dashboard,
35+
# since //go:embed reads that directory at build time below. Runs on every
36+
# goreleaser invocation this file drives -- the real release job AND its
37+
# snapshot dry-run rehearsal (release.yml:455-461 runs the same
38+
# .goreleaser.yml with --snapshot), so the two can never embed different
39+
# things -- as well as a developer's local `goreleaser --snapshot`, which
40+
# the script degrades gracefully for. See the script's own header comment
41+
# for the reachability and uncommitted-changes rules it applies.
42+
- scripts/fetch-dashboard-shell.sh
3343
- go mod tidy
3444
- go mod verify
3545
- sh -c "cd cmd/forge && go mod tidy"

‎cmd/dashboard-contract-probe/main.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515

1616
func main() {
1717
base := flag.String("base", "http://localhost:8080", "dashboard base URL (no trailing slash)")
18-
kind := flag.String("kind", "query", "graph | query | command")
18+
kind := flag.String("kind", "query", "query | command | subscribe")
1919
contributor := flag.String("contributor", "", "contributor name")
2020
intent := flag.String("intent", "", "intent name")
2121
payload := flag.String("payload", "{}", "JSON payload")

‎cmd/forge/main.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func main() {
4747
plugins.NewDatabasePlugin(forgeConfig), // forge db:*
4848
plugins.NewExtensionPlugin(forgeConfig), // forge extension:*
4949
plugins.NewContributorPlugin(forgeConfig), // forge contributor:*
50+
plugins.NewDashboardPlugin(forgeConfig), // forge dashboard:*
5051
plugins.NewDoctorPlugin(forgeConfig), // forge doctor
5152
plugins.NewInitPlugin(forgeConfig), // forge init
5253
plugins.NewClientPlugin(forgeConfig), // forge client:*

‎cmd/forge/plugins/dashboard.go‎

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// cmd/forge/plugins/dashboard.go
2+
package plugins
3+
4+
import (
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"strings"
10+
11+
"github.com/xraph/forge/cli"
12+
"github.com/xraph/forge/cmd/forge/config"
13+
"github.com/xraph/forge/errors"
14+
)
15+
16+
// DashboardPlugin scaffolds a standalone dashboard shell for deployments that
17+
// build their own UI and serve it themselves, as an alternative to the
18+
// prebuilt shell Forge embeds and serves at {BasePath}/ui by default.
19+
//
20+
// This is a separate namespace from `forge contributor`, which scaffolds a
21+
// Go-side dashboard *contributor* (a backend extension that reports pages,
22+
// widgets and settings into the embedded shell). `forge dashboard` scaffolds
23+
// the shell itself, for the WithShellSource(ShellExternal) case where nobody
24+
// on the Forge side is building it for you.
25+
type DashboardPlugin struct {
26+
config *config.ForgeConfig
27+
}
28+
29+
// NewDashboardPlugin creates a new dashboard plugin.
30+
func NewDashboardPlugin(cfg *config.ForgeConfig) cli.Plugin {
31+
return &DashboardPlugin{config: cfg}
32+
}
33+
34+
func (p *DashboardPlugin) Name() string { return "dashboard" }
35+
func (p *DashboardPlugin) Version() string { return "1.0.0" }
36+
func (p *DashboardPlugin) Description() string {
37+
return "Standalone dashboard shell scaffolding (for WithShellSource(ShellExternal))"
38+
}
39+
func (p *DashboardPlugin) Dependencies() []string { return nil }
40+
func (p *DashboardPlugin) Initialize() error { return nil }
41+
42+
func (p *DashboardPlugin) Commands() []cli.Command {
43+
dashboardCmd := cli.NewCommand(
44+
"dashboard",
45+
"Standalone dashboard shell tools (for WithShellSource(ShellExternal))",
46+
nil, // No handler, requires subcommand
47+
)
48+
49+
dashboardCmd.AddSubcommand(cli.NewCommand(
50+
"new",
51+
"Scaffold a standalone Vite + React + TypeScript dashboard shell",
52+
p.newDashboard,
53+
cli.WithFlag(cli.NewStringFlag("name", "n", "Package name (defaults to the target directory's name)", "")),
54+
))
55+
56+
return []cli.Command{dashboardCmd}
57+
}
58+
59+
// newDashboard is the `forge dashboard new` command handler. It only parses
60+
// arguments and reports progress; the actual file generation lives in
61+
// scaffoldDashboard so it can be tested directly without a CommandContext.
62+
func (p *DashboardPlugin) newDashboard(ctx cli.CommandContext) error {
63+
targetArg := ctx.Arg(0)
64+
if targetArg == "" {
65+
var err error
66+
targetArg, err = ctx.Prompt("Target directory:")
67+
if err != nil {
68+
return err
69+
}
70+
}
71+
if targetArg == "" {
72+
return errors.New("target directory is required")
73+
}
74+
75+
targetDir, err := filepath.Abs(targetArg)
76+
if err != nil {
77+
return err
78+
}
79+
80+
name := ctx.String("name")
81+
if name == "" {
82+
name = filepath.Base(targetDir)
83+
}
84+
85+
spinner := ctx.Spinner(fmt.Sprintf("Scaffolding dashboard shell in %s...", targetDir))
86+
87+
if err := p.scaffoldDashboard(targetDir, name); err != nil {
88+
spinner.Stop(cli.Red("✗ Failed"))
89+
return err
90+
}
91+
92+
spinner.Stop(cli.Green("✓ Dashboard shell scaffolded!"))
93+
94+
cwd, _ := os.Getwd()
95+
96+
ctx.Println("")
97+
ctx.Success("Next steps:")
98+
ctx.Println(fmt.Sprintf(" 1. cd %s", relPath(cwd, targetDir)))
99+
ctx.Println(" 2. pnpm install")
100+
ctx.Println(" 3. pnpm add <your plugin package>, then list it in the `plugins` array in src/App.tsx")
101+
ctx.Println(" 4. pnpm build")
102+
ctx.Println(" 5. Serve dist/ yourself and pass WithShellSource(dashboard.ShellExternal) to dashboard.NewExtension")
103+
ctx.Println("")
104+
// Said here and not only in the source comments, because the person who
105+
// hits it is standing at step 2 with a registry error and no idea whether
106+
// they typed something wrong. The three @forge-go packages are not
107+
// published yet.
108+
ctx.Println("Note: the three @forge-go/dashboard-* packages this depends on are not")
109+
ctx.Println("published to npm yet, so step 2 will fail to resolve them until they are.")
110+
ctx.Println("")
111+
ctx.Println("See README.md in the scaffolded directory for details.")
112+
113+
return nil
114+
}
115+
116+
// dashboardScaffoldData is the template data for every file scaffoldDashboard
117+
// writes.
118+
type dashboardScaffoldData struct {
119+
// Name is the sanitized npm package name written into package.json.
120+
Name string
121+
// DisplayName is a human-readable title, used in index.html and README.md.
122+
DisplayName string
123+
}
124+
125+
// scaffoldDashboard writes a standalone Vite + React + TypeScript dashboard
126+
// shell into targetDir. It depends on all three @forge-go packages the
127+
// dashboard front end is split into: dashboard-plugin, dashboard-kit, and
128+
// dashboard-runtime (the last supplies ForgeDashboardProvider and
129+
// PluginErrorBoundary -- a third-party plugin's throw must not blank the
130+
// whole dashboard, and that containment matters more in a custom build than
131+
// in the first-party shell, not less).
132+
//
133+
// It does not run `pnpm install`, `pnpm build`, or anything else that needs a
134+
// registry -- those packages are not published yet, so a scaffolded project
135+
// cannot install today. That is expected until they are released; it is not
136+
// a bug in the scaffold.
137+
func (p *DashboardPlugin) scaffoldDashboard(targetDir, rawName string) error {
138+
if strings.TrimSpace(rawName) == "" {
139+
rawName = "dashboard"
140+
}
141+
142+
data := dashboardScaffoldData{
143+
Name: npmPackageName(rawName),
144+
DisplayName: toDisplayName(strings.ReplaceAll(rawName, "-", "_")),
145+
}
146+
147+
if err := os.MkdirAll(filepath.Join(targetDir, "src"), 0755); err != nil {
148+
return err
149+
}
150+
151+
files := []struct {
152+
rel string
153+
tmpl string
154+
}{
155+
{"package.json", dashboardPackageJSONTemplate},
156+
{"vite.config.ts", dashboardViteConfigTemplate},
157+
{"tsconfig.json", dashboardTSConfigTemplate},
158+
{"index.html", dashboardIndexHTMLTemplate},
159+
{"README.md", dashboardReadmeTemplate},
160+
{".gitignore", dashboardGitignoreTemplate},
161+
{filepath.Join("src", "main.tsx"), dashboardMainTSXTemplate},
162+
{filepath.Join("src", "App.tsx"), dashboardAppTSXTemplate},
163+
}
164+
165+
for _, f := range files {
166+
if err := writeTemplate(filepath.Join(targetDir, f.rel), f.tmpl, data); err != nil {
167+
return err
168+
}
169+
}
170+
171+
return nil
172+
}
173+
174+
// npmPackageNamePattern matches characters legal in an unscoped npm package
175+
// name: lowercase letters, digits, hyphens, underscores and dots.
176+
var npmPackageNamePattern = regexp.MustCompile(`[^a-z0-9._-]+`)
177+
178+
// npmPackageName sanitizes an arbitrary directory or flag value into a name
179+
// npm's package.json will accept: lowercased, invalid characters collapsed to
180+
// a hyphen, and leading dots/underscores (which npm also rejects) stripped.
181+
// Falls back to "dashboard" if that leaves nothing usable.
182+
func npmPackageName(name string) string {
183+
s := strings.ToLower(strings.TrimSpace(name))
184+
s = npmPackageNamePattern.ReplaceAllString(s, "-")
185+
s = strings.TrimLeft(s, "._-")
186+
s = strings.Trim(s, "-")
187+
if s == "" {
188+
return "dashboard"
189+
}
190+
return s
191+
}

0 commit comments

Comments
 (0)