dashboard

package
v1.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 2, 2026 License: Apache-2.0 Imports: 28 Imported by: 13

README

Dashboard Extension

Serves your dashboard's data. It collects health, metrics, services and request traces from the running app, and exposes them, along with whatever your own extensions contribute, through one endpoint: the dashboard contract at {BasePath}/api/dashboard/v1.

It serves no web pages. The UI is a React app built from the @forge-go/dashboard-* packages, which you build and serve yourself. forge dashboard new scaffolds one, standalone with Vite or mounted inside a Next.js app.

Quick Start

app := forge.New(forge.WithAppName("my-app"))

if err := app.RegisterExtension(dashboard.NewExtension(
    dashboard.WithBasePath("/dashboard"),
)); err != nil {
    log.Fatal(err)
}

// The contract is at http://localhost:8080/dashboard/api/dashboard/v1
app.Run()

Then build the UI:

forge dashboard new my-dashboard

Contributing from an Extension

You contribute in two halves. The Go half registers intents with the dashboard's dispatcher. The UI half is a React plugin that calls them.

For the Go half, implement ContractContributorAware. The dashboard finds it during Start():

func (e *ReportsExtension) RegisterContractContributor(
    disp *dispatcher.Dispatcher,
    reg contract.Registry,
    wreg contract.WardenRegistry,
) error {
    return reportscontract.Register(disp, reg, wreg, reportscontract.Deps{Store: e.store})
}

If your extension can be installed before it's configured, implement DashboardStatusAware as well, and the dashboard will show your plugin's setup guide until you report Configured: true.

A contributor can also live in another service. Serve it there with contract/server, then either register it with RegisterRemoteContractContributor, or turn on WithDiscovery(true) and let the dashboard find every service tagged forge-dashboard-contributor through SetDiscoveryService.

Authentication

The dashboard doesn't sign anyone in. You give it an AuthChecker, and it attaches the user to every contract request:

import dashauth "github.com/xraph/forge/extensions/dashboard/auth"

type MyChecker struct{}

func (c *MyChecker) CheckAuth(ctx context.Context, r *http.Request) (*dashauth.UserInfo, error) {
    token := r.Header.Get("Authorization")
    if token == "" {
        return nil, nil // signed out is not an error
    }

    return &dashauth.UserInfo{
        Subject:     "user-123",
        DisplayName: "Jane Doe",
        Roles:       []string{"admin"},
    }, nil
}

dashExt.(*dashboard.Extension).SetAuthChecker(&MyChecker{})

With WithEnableAuth(true), /principal answers 401 for a signed-out caller and tells the client where the login page is. Auth extensions such as authsome wire all of this themselves through DashboardAuthAware.

HTTP Endpoints

All paths are under the base path (default /dashboard).

Method Path Description
POST /api/dashboard/v1 Contract envelope: queries and commands
GET /api/dashboard/v1/capabilities Registered contributors and their status
GET /api/dashboard/v1/principal The signed-in user
GET /api/dashboard/v1/csrf CSRF token for commands
GET /api/dashboard/v1/stream Subscription updates over SSE
POST /api/dashboard/v1/stream/control Subscribe and unsubscribe on an open stream
GET /export/json, /export/csv, /export/prometheus Data snapshots, when export is on

Examples

  • basic: the extension with its default intents and nothing else registered

Full docs live at docs/content/docs/extensions/dashboard.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrCollectorNotInitialized is returned when the data collector is not initialized.
	ErrCollectorNotInitialized = errors.New("dashboard: collector not initialized")
)

Functions

func NewExtension

func NewExtension(opts ...ConfigOption) forge.Extension

NewExtension creates a new dashboard extension.

func TracingMiddleware added in v1.2.0

func TracingMiddleware(store *collector.TraceStore, basePath string, captureRequestBody ...bool) forge.Middleware

TracingMiddleware creates a forge middleware that auto-captures request traces and feeds them into the given TraceStore. Only dashboard internals (static assets, SSE streams, and bridge calls) are excluded — page navigations and API calls are traced so the tracing UI has data out of the box.

Types

type Config

type Config struct {
	// BasePath prefixes every route the extension mounts.
	BasePath string `json:"base_path" yaml:"base_path"`

	// Data collection
	RefreshInterval time.Duration `json:"refresh_interval" yaml:"refresh_interval"`
	HistoryDuration time.Duration `json:"history_duration" yaml:"history_duration"`
	MaxDataPoints   int           `json:"max_data_points"  yaml:"max_data_points"`

	// Tracing
	TraceMaxCount  int           `json:"trace_max_count"  yaml:"trace_max_count"`
	TraceRetention time.Duration `json:"trace_retention"  yaml:"trace_retention"`
	// TraceMaxSpansPerTrace caps how many spans one trace retains. A single
	// long-lived trace, such as a websocket, would otherwise grow unbounded.
	TraceMaxSpansPerTrace int `json:"trace_max_spans_per_trace" yaml:"trace_max_spans_per_trace"`
	// TraceIdleTTL is how long after the last dashboard request spans keep being
	// retained. A negative duration disables the gate, retaining always (the
	// pre-gate behaviour). Zero is not a reliable way to disable it: under a
	// ConfigManager, config merging (see extension_config.go) only overrides a
	// field when the source value is non-zero, so an explicit 0 here is
	// silently skipped and the default survives.
	TraceIdleTTL time.Duration `json:"trace_idle_ttl" yaml:"trace_idle_ttl"`
	// TraceCaptureRequestBody retains a bounded, redacted JSON request body in
	// the in-memory trace store. It is opt-in because arbitrary fields may hold
	// private data even after credential-shaped keys are removed.
	TraceCaptureRequestBody bool `json:"trace_capture_request_body" yaml:"trace_capture_request_body"`

	// Security
	EnableCSRF bool `json:"enable_csrf" yaml:"enable_csrf"`
	// EnableContractSecurity gates CSRF validation, idempotency dedup, and
	// distributed tracing on the contract envelope endpoint. Default true;
	// set to false during a rollout window where clients have not yet
	// adopted CSRF tokens or the idempotency-key contract.
	EnableContractSecurity bool `json:"enable_contract_security" yaml:"enable_contract_security"`
	// ContractMaxBodyBytes caps the contract envelope a client may POST.
	// Zero or less means transport.DefaultMaxBodyBytes (1 MiB).
	ContractMaxBodyBytes int64 `json:"contract_max_body_bytes" yaml:"contract_max_body_bytes"`

	// Authentication
	EnableAuth bool `json:"enable_auth" yaml:"enable_auth"`
	// LoginPath is where a signed-out user is sent, relative to BasePath. The
	// dashboard serves no login page itself: the principal endpoint reports
	// this path in its 401 so the client knows where to redirect.
	LoginPath string `json:"login_path" yaml:"login_path"`
	// RequiredRoles, when non-empty, restricts dashboard access to users
	// carrying at least one matching role. The principal endpoint returns
	// 403 PERMISSION_DENIED for users who don't qualify. Rendering that as
	// an "access denied" screen is the client's job.
	RequiredRoles []string `json:"required_roles" yaml:"required_roles"`

	// Discovery. With EnableDiscovery on and a discovery service set through
	// SetDiscoveryService, services registered under DiscoveryTag that serve
	// a contract manifest are added as remote contributors automatically.
	EnableDiscovery       bool          `json:"enable_discovery"        yaml:"enable_discovery"`
	DiscoveryTag          string        `json:"discovery_tag"           yaml:"discovery_tag"`
	DiscoveryPollInterval time.Duration `json:"discovery_poll_interval" yaml:"discovery_poll_interval"`

	// Export
	EnableExport  bool     `json:"enable_export"  yaml:"enable_export"`
	ExportFormats []string `json:"export_formats" yaml:"export_formats"`

	// Internal
	RequireConfig bool `json:"-" yaml:"-"`
}

Config contains dashboard extension configuration.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns the default dashboard configuration.

func (Config) Validate

func (c Config) Validate() error

Validate validates the configuration.

type ConfigOption

type ConfigOption func(*Config)

ConfigOption is a functional option for Config.

func WithBasePath

func WithBasePath(path string) ConfigOption

WithBasePath sets the base URL path for the dashboard.

func WithCSRF added in v0.9.12

func WithCSRF(enabled bool) ConfigOption

WithCSRF enables or disables CSRF token protection.

func WithConfig

func WithConfig(config Config) ConfigOption

WithConfig sets the complete config.

func WithContractMaxBodyBytes added in v1.12.0

func WithContractMaxBodyBytes(n int64) ConfigOption

WithContractMaxBodyBytes caps the contract envelope a client may POST. Larger bodies are refused with 413 before they are decoded. Zero or less keeps the 1 MiB default.

func WithContractSecurity added in v1.6.4

func WithContractSecurity(enabled bool) ConfigOption

WithContractSecurity enables or disables the contract envelope's security stack (CSRF validation, idempotency dedup, request tracing). Defaults to true; switching off should be reserved for rollout windows where clients have not yet adopted CSRF tokens or idempotency keys.

func WithDiscovery added in v0.9.12

func WithDiscovery(enabled bool) ConfigOption

WithDiscovery turns on automatic registration of remote contributors found through the discovery service set with SetDiscoveryService.

func WithDiscoveryPollInterval added in v0.9.12

func WithDiscoveryPollInterval(interval time.Duration) ConfigOption

WithDiscoveryPollInterval sets how often discovery is checked for new, changed or departed contributors.

func WithDiscoveryTag added in v0.9.12

func WithDiscoveryTag(tag string) ConfigOption

WithDiscoveryTag sets the discovery tag a service registers under to be picked up as a dashboard contributor.

func WithEnableAuth added in v0.10.0

func WithEnableAuth(enabled bool) ConfigOption

WithEnableAuth enables or disables authentication support.

func WithExport

func WithExport(enabled bool) ConfigOption

WithExport enables or disables export functionality.

func WithExportFormats added in v0.9.12

func WithExportFormats(formats []string) ConfigOption

WithExportFormats sets the supported export formats.

func WithHistoryDuration

func WithHistoryDuration(duration time.Duration) ConfigOption

WithHistoryDuration sets the data retention duration.

func WithLoginPath added in v0.10.0

func WithLoginPath(path string) ConfigOption

WithLoginPath sets the path, relative to BasePath, that the principal endpoint reports for signed-out users (e.g. "/auth/login").

func WithMaxDataPoints

func WithMaxDataPoints(maxPoints int) ConfigOption

WithMaxDataPoints sets the maximum number of data points to retain.

func WithMemoryProfile added in v1.6.0

func WithMemoryProfile(profile MemoryProfile) ConfigOption

WithMemoryProfile auto-tunes data collection and retention settings based on the available system memory. Individual settings applied after this option will override the profile defaults.

func WithRefreshInterval

func WithRefreshInterval(interval time.Duration) ConfigOption

WithRefreshInterval sets the data collection refresh interval.

func WithRequireConfig

func WithRequireConfig(required bool) ConfigOption

WithRequireConfig requires config from ConfigManager.

func WithRequiredRoles added in v1.6.4

func WithRequiredRoles(roles []string) ConfigOption

WithRequiredRoles restricts dashboard access to users carrying at least one of the given roles. Pass nil/empty to allow all authenticated users. Auth extensions (e.g. authsome) call this via Extension.SetRequiredRoles when their config declares a role gate; deployments can also configure it directly via this option.

func WithTraceCaptureRequestBody added in v1.11.2

func WithTraceCaptureRequestBody(enabled bool) ConfigOption

WithTraceCaptureRequestBody enables bounded JSON request-body inspection.

func WithTraceIdleTTL added in v1.11.0

func WithTraceIdleTTL(duration time.Duration) ConfigOption

WithTraceIdleTTL sets how long after the last dashboard request traces keep being collected. Pass a negative duration to collect always, disabling the gate. Passing zero is not reliable for this: under a ConfigManager, config merging only overrides a field when the source value is non-zero, so an explicit zero here can be silently skipped in favor of the existing value.

func WithTraceMaxCount added in v1.6.0

func WithTraceMaxCount(count int) ConfigOption

WithTraceMaxCount sets the maximum number of traces kept in memory.

func WithTraceMaxSpansPerTrace added in v1.11.0

func WithTraceMaxSpansPerTrace(n int) ConfigOption

WithTraceMaxSpansPerTrace sets how many spans a single trace retains.

func WithTraceRetention added in v1.6.0

func WithTraceRetention(duration time.Duration) ConfigOption

WithTraceRetention sets the retention duration for traces.

type ContractContributorAware added in v1.6.4

type ContractContributorAware interface {
	RegisterContractContributor(
		disp *dispatcher.Dispatcher,
		reg contract.Registry,
		wreg contract.WardenRegistry,
	) error
}

ContractContributorAware is an optional interface that Forge extensions can implement to register a contract-based dashboard contributor (the slice (f)+ shape — declarative YAML manifest + typed dispatcher handlers). The dashboard auto-discovers extensions implementing this interface during Start() and calls RegisterContractContributor with the dashboard's contract registry, warden registry, and dispatcher.

This is the only way an extension contributes to the dashboard. Its UI ships separately, as a React plugin that talks to these intents.

Example implementation:

func (e *StreamingExtension) RegisterContractContributor(
    disp *dispatcher.Dispatcher,
    reg contract.Registry,
    wreg contract.WardenRegistry,
) error {
    return streamingcontract.Register(disp, reg, wreg, streamingcontract.Deps{
        Manager: func() streaming.Manager { return e.manager },
        Config:  func() streaming.Config { return e.config },
    })
}

type DashboardAuthAware added in v1.2.0

type DashboardAuthAware interface {
	RegisterDashboardAuth(ext *Extension)
}

DashboardAuthAware is an optional interface that Forge extensions can implement to provide authentication for the dashboard. The dashboard auto-discovers extensions implementing this interface during Start() and calls RegisterDashboardAuth with itself, allowing the extension to set up auth checking, required roles, and tenant resolution.

Example implementation:

func (a *AuthExtension) RegisterDashboardAuth(ext *dashboard.Extension) {
    ext.SetAuthChecker(myAuthChecker)
    ext.EnableAuth()
}

Wiring an auth extension into the contract:

Auth extensions plug in by implementing both DashboardAuthAware *and* ContractContributorAware:

  • DashboardAuthAware.RegisterDashboardAuth wires the AuthChecker so /api/dashboard/v1/principal returns the current user. That endpoint distinguishes the 401 envelope (auth required) from the 200 `{authenticated:false}` envelope (auth disabled), so a client can tell "log in" apart from "auth is off".
  • ContractContributorAware.RegisterContractContributor registers the `auth.login` command intent (and optionally `auth.logout`) on the dispatcher.

This is the whole server side. The dashboard serves no login page of its own: the React dashboard reads /principal to decide whether to show a login gate, and the login UI itself belongs to the auth extension's plugin.

Example combined integration sketch:

func (a *AuthsomeExtension) RegisterDashboardAuth(ext *dashboard.Extension) {
    ext.SetAuthChecker(a.checker)
    ext.EnableAuth()
}
func (a *AuthsomeExtension) RegisterContractContributor(
    disp *dispatcher.Dispatcher,
    reg contract.Registry,
    wreg contract.WardenRegistry,
) error {
    return authsomecontract.Register(disp, reg, wreg, authsomecontract.Deps{
        Sessions: a.sessions, // registers the `auth.login` command
    })
}

type DashboardStatus added in v1.11.1

type DashboardStatus struct {
	// Version is the extension's semver, checked against the plugin's
	// `requires` range. Empty means "do not check".
	Version string `json:"version,omitempty"`
	// Configured reports whether the extension has everything it needs to
	// serve. False makes the dashboard render the plugin's setup guide.
	Configured bool `json:"configured"`
	// Message is optional detail shown in the setup panel.
	Message string `json:"message,omitempty"`
}

DashboardStatus is what an extension reports about itself to the dashboard.

Set Configured explicitly on every value you return. The struct's zero value is Configured=false, the restrictive answer, so a bare DashboardStatus{} returned from a switch default asks the dashboard for a setup panel.

The permissive default lives elsewhere: an extension that does not implement DashboardStatusAware at all is reported with an empty Version and Configured=true, so the plugin host skips the version check rather than failing it, and renders the plugin normally. Nobody has to implement this to keep working.

type DashboardStatusAware added in v1.11.1

type DashboardStatusAware interface {
	DashboardStatus() DashboardStatus
}

DashboardStatusAware is an optional interface a Forge extension can implement to tell the dashboard its version and whether it is configured.

Without it the dashboard would have to infer setup state by calling a data method and interpreting the failure, which cannot distinguish "not configured yet" from "configured but failing" from "you lack permission". Those are three different screens.

type Extension

type Extension struct {
	*forge.BaseExtension
	// contains filtered or unexported fields
}

Extension serves the dashboard's data plane: the contract API under {BasePath}/api/dashboard/v1 that every dashboard client talks to, plus the collector, trace store and export endpoints behind it. It serves no web pages. The dashboard UI is a separate React app built from the @forge-go/dashboard-* packages, and extensions contribute to it by registering contract intents (ContractContributorAware) and shipping a plugin.

func (*Extension) AuthChecker added in v0.10.0

func (e *Extension) AuthChecker() dashauth.AuthChecker

AuthChecker returns the configured authentication checker. Returns nil if none is set.

func (*Extension) BasePath added in v1.12.0

func (e *Extension) BasePath() string

BasePath returns the prefix every dashboard route is mounted under.

func (*Extension) CSRFManager added in v0.9.12

func (e *Extension) CSRFManager() *security.CSRFManager

CSRFManager returns the CSRF token manager. Returns nil if CSRF is disabled.

func (*Extension) Collector added in v0.8.0

func (e *Extension) Collector() *collector.DataCollector

Collector returns the data collector instance.

func (*Extension) Dependencies

func (e *Extension) Dependencies() []string

Dependencies returns extension dependencies.

func (*Extension) EnableAuth added in v1.2.0

func (e *Extension) EnableAuth()

EnableAuth turns on authentication support. Auth extensions such as authsome call this from RegisterDashboardAuth. With auth on, the principal endpoint answers 401 for a signed-out caller instead of an anonymous 200.

func (*Extension) Health

func (e *Extension) Health(ctx context.Context) error

Health checks if the dashboard is healthy.

func (*Extension) History added in v0.8.0

func (e *Extension) History() *collector.DataHistory

History returns the data history instance.

func (*Extension) Register

func (e *Extension) Register(app forge.App) error

Register registers the dashboard extension.

func (*Extension) RegisterRemoteContractContributor added in v1.6.4

func (e *Extension) RegisterRemoteContractContributor(ctx context.Context, baseURL, apiKey string) error

RegisterRemoteContractContributor registers a contract contributor whose handlers live in another service. The dashboard fetches the upstream's manifest, validates it, records the endpoint, and installs a forwarding dispatcher (idempotently) so subsequent requests for any of the remote's intents are proxied to the upstream over HTTP.

Slice (m) added this so a single dashboard can aggregate contributors from multiple microservices.

baseURL is the upstream service root (e.g. https://svc.internal:8443); the manifest endpoint is fetched at <baseURL>/_forge/contract/manifest and envelopes are POSTed to <baseURL>/_forge/contract/dispatch. apiKey, when non-empty, is sent as Authorization: Bearer on dashboard→service hops; end-user identity flows in parallel via X-Forwarded-Authorization and X-Forwarded-Cookie.

func (*Extension) SetAuthChecker added in v0.10.0

func (e *Extension) SetAuthChecker(checker dashauth.AuthChecker)

SetAuthChecker configures the authentication checker used to validate requests. Call this after Register() and before Start(). When auth is enabled, the checker is invoked on every request to populate the user context.

Example using the adapter for the forge auth extension:

checker := dashauth.NewAuthExtensionChecker(authRegistry, "oidc")
dashExt.SetAuthChecker(checker)

func (*Extension) SetDiscoveryService added in v0.9.12

func (e *Extension) SetDiscoveryService(svc dashboarddiscovery.DiscoveryService)

SetDiscoveryService sets the discovery service used to find remote contributors. Call it before the app runs, typically from a PhaseAfterRegister hook, and turn discovery on with WithDiscovery(true).

A remote service takes part by serving its intents with contract/server and registering itself in discovery under DiscoveryTag. An instance's "forge-api-key" metadata, when set, is sent to it as a bearer token.

func (*Extension) SetRequiredRoles added in v1.6.4

func (e *Extension) SetRequiredRoles(roles []string)

SetRequiredRoles restricts dashboard access to authenticated users that hold at least one of the given roles. Pass nil/empty to clear the gate. Auth extensions like authsome call this from RegisterDashboardAuth when their own configuration declares a role list. The principal endpoint returns 403 PERMISSION_DENIED for users who don't qualify. Rendering that as an "access denied" screen is the client's job.

func (*Extension) SetTenantResolver added in v1.2.0

func (e *Extension) SetTenantResolver(resolver dashauth.TenantResolver)

SetTenantResolver configures the tenant resolver used to populate tenant context on every request. Call this after Register() and before Start(). When configured, the contract handlers can read tenant info via dashauth.TenantFromContext(ctx).

A default ScopeTenantResolver is available that reads forge.Scope from the request context:

dashExt.SetTenantResolver(dashauth.ScopeTenantResolver{})

func (*Extension) Start

func (e *Extension) Start(ctx context.Context) error

Start starts the dashboard extension.

func (*Extension) Stop

func (e *Extension) Stop(ctx context.Context) error

Stop stops the dashboard extension.

func (*Extension) TenantResolver added in v1.2.0

func (e *Extension) TenantResolver() dashauth.TenantResolver

TenantResolver returns the configured tenant resolver. Returns nil if none is set.

func (*Extension) TraceStore added in v1.2.0

func (e *Extension) TraceStore() *collector.TraceStore

TraceStore returns the trace store instance.

func (*Extension) UnregisterRemoteContractContributor added in v1.6.4

func (e *Extension) UnregisterRemoteContractContributor(name string)

UnregisterRemoteContractContributor removes a previously registered remote. Safe to call for unknown names; future dispatches to the contributor will fall through to CodeNotFound.

type MemoryProfile added in v1.6.0

type MemoryProfile string

MemoryProfile controls memory usage tuning for the dashboard. Use "low" for 512MB systems, "medium" for 1-2GB, "high" for 4GB+.

const (
	// MemoryProfileLow tunes for 512MB systems with minimal history and trace retention.
	MemoryProfileLow MemoryProfile = "low"
	// MemoryProfileMedium is the default, suitable for 1-2GB systems.
	MemoryProfileMedium MemoryProfile = "medium"
	// MemoryProfileHigh allows maximum history and trace retention for 4GB+ systems.
	MemoryProfileHigh MemoryProfile = "high"
)

Directories

Path Synopsis
Package dashauth provides authentication and authorization abstractions for the dashboard extension.
Package dashauth provides authentication and authorization abstractions for the dashboard extension.
Package contract defines the declarative, single-endpoint contract for the admin dashboard: contributor manifests, request/response envelopes, the permission model, and the per-contributor version negotiation protocol.
Package contract defines the declarative, single-endpoint contract for the admin dashboard: contributor manifests, request/response envelopes, the permission model, and the per-contributor version negotiation protocol.
dispatcher
Package dispatcher implements transport.Dispatcher and transport.SubscriptionSource against a function-table of registered handlers.
Package dispatcher implements transport.Dispatcher and transport.SubscriptionSource against a function-table of registered handlers.
idempotency
Package idempotency provides command deduplication for the dashboard contract: a Store interface plus an in-memory implementation.
Package idempotency provides command deduplication for the dashboard contract: a Store interface plus an in-memory implementation.
loader
validate.go
validate.go
pilot
Package pilot ships the migrated dashboard contributor used to validate the contract end-to-end: extensions.list, services.list, services.detail, and the metrics.summary subscription, all wired against the existing collector and contributor registry.
Package pilot ships the migrated dashboard contributor used to validate the contract end-to-end: extensions.list, services.list, services.detail, and the metrics.summary subscription, all wired against the existing collector and contributor registry.
remote
Package remote implements the contract dispatcher's HTTP forwarding layer.
Package remote implements the contract dispatcher's HTTP forwarding layer.
server
Package server exposes the two HTTP endpoints a non-dashboard service needs to advertise itself as a contract contributor that other dashboards can discover + dispatch into.
Package server exposes the two HTTP endpoints a non-dashboard service needs to advertise itself as a contract contributor that other dashboards can discover + dispatch into.
transport
capabilities.go
capabilities.go
Package discovery registers remote dashboard contributors found through a service discovery backend.
Package discovery registers remote dashboard contributors found through a service discovery backend.
examples
basic command
Package main demonstrates a basic dashboard setup with default configuration and no contributors registered.
Package main demonstrates a basic dashboard setup with default configuration and no contributors registered.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL