Compare commits

...

19 Commits

Author SHA1 Message Date
grandwizard28
057dcbe6e4 fix: remove unused files 2026-04-16 02:26:18 +05:30
grandwizard28
3a28d741a3 fix: remove unused files 2026-04-16 02:24:20 +05:30
grandwizard28
223e83154f style: formatting and test cleanup from review
Restructure Validate nil check, rename expectErr to fail with
early-return, trim trailing newlines in test assertions, remove
t.Parallel from subtests, inline short config literals, restore
struct field comments in web.Config.
2026-04-16 02:17:14 +05:30
grandwizard28
50ae51cdaa fix(web): resolve lint errors in provider and template
Fix errcheck on rw.Write in serveIndex, use ErrorContext instead of
Error in NewIndex for sloglint compliance. Move serveIndex below
ServeHTTP to order public methods before private ones.
2026-04-16 02:05:25 +05:30
grandwizard28
c8ae8476c3 style: add blank lines between logical blocks 2026-04-16 01:57:24 +05:30
grandwizard28
daaa66e1fc chore: remove redundant comments from added code 2026-04-16 01:54:14 +05:30
grandwizard28
b0717d6a69 refactor(web): use table-driven tests with named path cases
Replace for-loop path iteration with explicit table-driven test cases
for each path. Each path (root, non-existent, directory) is a named
subtest case in all three template tests.
2026-04-16 01:49:07 +05:30
grandwizard28
4aefe44313 refactor(web): rename get test helper to httpGet 2026-04-16 01:47:35 +05:30
grandwizard28
4dc6f6fe7b style(web): use raw string literals for expected test values 2026-04-16 01:44:46 +05:30
grandwizard28
d3e0c46ba2 test(web): use exact match instead of contains in template tests
Match the full expected response body in TestServeTemplatedIndex
instead of using assert.Contains.
2026-04-16 01:43:23 +05:30
grandwizard28
0fed17e11a test(web): add SPA fallback paths to no_template and invalid_template tests
Test /, /does-not-exist, and /assets in all three template test cases
to verify SPA fallback behavior (non-existent paths and directories
serve the index) regardless of template type.
2026-04-16 01:38:46 +05:30
grandwizard28
a2264b4960 refactor(web): rename test fixtures to no_template, valid_template, invalid_template
Drop the index_ prefix from test fixtures. Use web instead of w for
the variable name in test helpers.
2026-04-16 01:32:50 +05:30
grandwizard28
2740964106 test(web): add no-template and invalid-template index test cases
Add three distinct index fixtures in testdata:
- index.html: correct [[ ]] template with BaseHref
- index_no_template.html: plain HTML, no placeholders
- index_invalid_template.html: malformed template syntax

Tests verify: template substitution works, plain files pass through
unchanged, and invalid templates fall back to serving raw bytes.
Consolidate test helpers into startServer/get.
2026-04-16 01:28:37 +05:30
grandwizard28
0ca22dd7fe refactor(web): collapse testdata_basepath into testdata
Use a single testdata directory with a templated index.html for all
routerweb tests. Remove the redundant testdata_basepath directory.
2026-04-16 01:22:54 +05:30
grandwizard28
a3b6bddac8 refactor(web): make index filename configurable via web.index
Move the hardcoded indexFileName const from routerweb/provider.go to
web.Config.Index with default "index.html". This allows overriding the
SPA entrypoint file via configuration.
2026-04-16 01:19:35 +05:30
grandwizard28
d908ce321a refactor(global): rename RoutePrefix to ExternalPath, add ExternalPathTrailing
Rename RoutePrefix() to ExternalPath() to accurately reflect what it
returns: the path component of the external URL. Add
ExternalPathTrailing() which returns the path with a trailing slash,
used for HTML base href injection.
2026-04-16 01:13:16 +05:30
grandwizard28
c221a44f3d refactor(web): extract index.html templating into web.NewIndex
Move the template parsing and execution logic from routerweb provider
into pkg/web/template.go. NewIndex logs and returns raw bytes on
template failure; NewIndexE returns the error for callers that need it.

Rename BasePath to BaseHref to match the HTML attribute it populates.
Inject global.Config into routerweb via the factory closure pattern.
2026-04-16 01:08:46 +05:30
grandwizard28
22fb4daaf9 feat(web): template index.html with dynamic base href from global.external_url
Read index.html at startup, parse as Go template with [[ ]] delimiters,
execute with BasePath derived from global.external_url, and cache the
rendered bytes in memory. This injects <base href="/signoz/" /> (or
whatever the route prefix is) so the browser resolves relative URLs
correctly when SigNoz is served at a sub-path.

Inject global.Config into the routerweb provider via the factory closure
pattern. Static files (JS, CSS, images) are still served from disk
unchanged.
2026-04-16 00:58:20 +05:30
grandwizard28
1bdc059d76 feat(apiserver): derive HTTP route prefix from global.external_url
The path component of global.external_url is now used as the base path
for all HTTP routes (API and web frontend), enabling SigNoz to be served
behind a reverse proxy at a sub-path (e.g. https://example.com/signoz/).

The prefix is applied via http.StripPrefix at the outermost handler
level, requiring zero changes to route registration code. Health
endpoints (/api/v1/health, /api/v2/healthz, /api/v2/readyz,
/api/v2/livez) remain accessible without the prefix for container
healthchecks.

Removes web.prefix config in favor of the unified global.external_url
approach, avoiding the desync bugs seen in projects with separate
API/UI prefix configs (ArgoCD, Prometheus).

closes SigNoz/platform-pod#1775
2026-04-16 00:38:55 +05:30
21 changed files with 403 additions and 144 deletions

View File

@@ -75,7 +75,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(),
signoz.NewWebProviderFactories(config.Global),
func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
return signoz.NewSQLSchemaProviderFactories(sqlstore)
},

View File

@@ -96,7 +96,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(),
signoz.NewWebProviderFactories(config.Global),
func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
existingFactories := signoz.NewSQLSchemaProviderFactories(sqlstore)
if err := existingFactories.Add(postgressqlschema.NewFactory(sqlstore)); err != nil {

View File

@@ -6,6 +6,8 @@
##################### Global #####################
global:
# the url under which the signoz apiserver is externally reachable.
# the path component (e.g. /signoz in https://example.com/signoz) is used
# as the base path for all HTTP routes (both API and web frontend).
external_url: <unset>
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
ingestion_url: <unset>
@@ -50,8 +52,8 @@ pprof:
web:
# Whether to enable the web frontend
enabled: true
# The prefix to serve web on
prefix: /
# The index file to use as the SPA entrypoint.
index: index.html
# The directory containing the static build files.
directory: /etc/signoz/web

View File

@@ -262,6 +262,20 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
return nil, err
}
routePrefix := s.config.Global.ExternalPath()
if routePrefix != "" {
prefixed := http.StripPrefix(routePrefix, handler)
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
r.ServeHTTP(w, req)
return
}
prefixed.ServeHTTP(w, req)
})
}
return &http.Server{
Handler: handler,
}, nil

View File

@@ -2,6 +2,8 @@ package global
import (
"net/url"
"path"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -37,5 +39,34 @@ func newConfig() factory.Config {
}
func (c Config) Validate() error {
if c.ExternalURL != nil {
if c.ExternalURL.Path != "" && c.ExternalURL.Path != "/" {
if !strings.HasPrefix(c.ExternalURL.Path, "/") {
return errors.NewInvalidInputf(ErrCodeInvalidGlobalConfig, "global::external_url path must start with '/', got %q", c.ExternalURL.Path)
}
}
}
return nil
}
func (c Config) ExternalPath() string {
if c.ExternalURL == nil || c.ExternalURL.Path == "" || c.ExternalURL.Path == "/" {
return ""
}
p := path.Clean("/" + c.ExternalURL.Path)
if p == "/" {
return ""
}
return p
}
func (c Config) ExternalPathTrailing() string {
if p := c.ExternalPath(); p != "" {
return p + "/"
}
return "/"
}

139
pkg/global/config_test.go Normal file
View File

@@ -0,0 +1,139 @@
package global
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestExternalPath(t *testing.T) {
testCases := []struct {
name string
config Config
expected string
}{
{
name: "NilURL",
config: Config{ExternalURL: nil},
expected: "",
},
{
name: "EmptyPath",
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: ""}},
expected: "",
},
{
name: "RootPath",
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/"}},
expected: "",
},
{
name: "SingleSegment",
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
expected: "/signoz",
},
{
name: "TrailingSlash",
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz/"}},
expected: "/signoz",
},
{
name: "MultiSegment",
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/a/b/c"}},
expected: "/a/b/c",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, tc.config.ExternalPath())
})
}
}
func TestExternalPathTrailing(t *testing.T) {
testCases := []struct {
name string
config Config
expected string
}{
{
name: "NilURL",
config: Config{ExternalURL: nil},
expected: "/",
},
{
name: "EmptyPath",
config: Config{ExternalURL: &url.URL{Path: ""}},
expected: "/",
},
{
name: "RootPath",
config: Config{ExternalURL: &url.URL{Path: "/"}},
expected: "/",
},
{
name: "SingleSegment",
config: Config{ExternalURL: &url.URL{Path: "/signoz"}},
expected: "/signoz/",
},
{
name: "MultiSegment",
config: Config{ExternalURL: &url.URL{Path: "/a/b/c"}},
expected: "/a/b/c/",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, tc.config.ExternalPathTrailing())
})
}
}
func TestValidate(t *testing.T) {
testCases := []struct {
name string
config Config
fail bool
}{
{
name: "NilURL",
config: Config{ExternalURL: nil},
fail: false,
},
{
name: "EmptyPath",
config: Config{ExternalURL: &url.URL{Path: ""}},
fail: false,
},
{
name: "RootPath",
config: Config{ExternalURL: &url.URL{Path: "/"}},
fail: false,
},
{
name: "ValidPath",
config: Config{ExternalURL: &url.URL{Path: "/signoz"}},
fail: false,
},
{
name: "NoLeadingSlash",
config: Config{ExternalURL: &url.URL{Path: "signoz"}},
fail: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.config.Validate()
if tc.fail {
assert.Error(t, err)
return
}
assert.NoError(t, err)
})
}
}

View File

@@ -587,7 +587,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/query_filter/analyze", am.ViewAccess(aH.QueryParserAPI.AnalyzeQueryFilter)).Methods(http.MethodPost)
}
func Intersection(a, b []int) (c []int) {
m := make(map[int]bool)

View File

@@ -244,6 +244,20 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
return nil, err
}
routePrefix := s.config.Global.ExternalPath()
if routePrefix != "" {
prefixed := http.StripPrefix(routePrefix, handler)
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
r.ServeHTTP(w, req)
return
}
prefixed.ServeHTTP(w, req)
})
}
return &http.Server{
Handler: handler,
}, nil

View File

@@ -88,9 +88,9 @@ func NewCacheProviderFactories() factory.NamedMap[factory.ProviderFactory[cache.
)
}
func NewWebProviderFactories() factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]] {
func NewWebProviderFactories(globalConfig global.Config) factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]] {
return factory.MustNewNamedMap(
routerweb.NewFactory(),
routerweb.NewFactory(globalConfig),
noopweb.NewFactory(),
)
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -34,7 +35,7 @@ func TestNewProviderFactories(t *testing.T) {
})
assert.NotPanics(t, func() {
NewWebProviderFactories()
NewWebProviderFactories(global.Config{})
})
assert.NotPanics(t, func() {

View File

@@ -8,10 +8,11 @@ import (
type Config struct {
// Whether the web package is enabled.
Enabled bool `mapstructure:"enabled"`
// The prefix to serve the files from
Prefix string `mapstructure:"prefix"`
// The directory containing the static build files. The root of this directory should
// have an index.html file.
// The name of the index file to serve.
Index string `mapstructure:"index"`
// The directory from which to serve the web files.
Directory string `mapstructure:"directory"`
}
@@ -22,7 +23,7 @@ func NewConfigFactory() factory.ConfigFactory {
func newConfig() factory.Config {
return &Config{
Enabled: true,
Prefix: "/",
Index: "index.html",
Directory: "/etc/signoz/web",
}
}

View File

@@ -12,7 +12,6 @@ import (
)
func TestNewWithEnvProvider(t *testing.T) {
t.Setenv("SIGNOZ_WEB_PREFIX", "/web")
t.Setenv("SIGNOZ_WEB_ENABLED", "false")
conf, err := config.New(
@@ -37,7 +36,7 @@ func TestNewWithEnvProvider(t *testing.T) {
expected := &Config{
Enabled: false,
Prefix: "/web",
Index: def.Index,
Directory: def.Directory,
}

View File

@@ -8,56 +8,53 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/web"
"github.com/gorilla/mux"
)
const (
indexFileName string = "index.html"
)
type provider struct {
config web.Config
config web.Config
indexContents []byte
}
func NewFactory() factory.ProviderFactory[web.Web, web.Config] {
return factory.NewProviderFactory(factory.MustNewName("router"), New)
func NewFactory(globalConfig global.Config) factory.ProviderFactory[web.Web, web.Config] {
return factory.NewProviderFactory(factory.MustNewName("router"), func(ctx context.Context, settings factory.ProviderSettings, config web.Config) (web.Web, error) {
return New(ctx, settings, config, globalConfig)
})
}
func New(ctx context.Context, settings factory.ProviderSettings, config web.Config) (web.Web, error) {
func New(ctx context.Context, settings factory.ProviderSettings, config web.Config, globalConfig global.Config) (web.Web, error) {
fi, err := os.Stat(config.Directory)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot access web directory")
}
ok := fi.IsDir()
if !ok {
if !fi.IsDir() {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "web directory is not a directory")
}
fi, err = os.Stat(filepath.Join(config.Directory, indexFileName))
indexPath := filepath.Join(config.Directory, config.Index)
raw, err := os.ReadFile(indexPath)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot access %q in web directory", indexFileName)
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot read %q in web directory", config.Index)
}
if os.IsNotExist(err) || fi.IsDir() {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "%q does not exist", indexFileName)
}
logger := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/web/routerweb").Logger()
indexContents := web.NewIndex(ctx, logger, config.Index, raw, web.TemplateData{BaseHref: globalConfig.ExternalPathTrailing()})
return &provider{
config: config,
config: config,
indexContents: indexContents,
}, nil
}
func (provider *provider) AddToRouter(router *mux.Router) error {
cache := middleware.NewCache(0)
err := router.PathPrefix(provider.config.Prefix).
err := router.PathPrefix("/").
Handler(
http.StripPrefix(
provider.config.Prefix,
cache.Wrap(http.HandlerFunc(provider.ServeHTTP)),
),
cache.Wrap(http.HandlerFunc(provider.ServeHTTP)),
).GetError()
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "unable to add web to router")
@@ -75,7 +72,7 @@ func (provider *provider) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if err != nil {
// if the file doesn't exist, serve index.html
if os.IsNotExist(err) {
http.ServeFile(rw, req, filepath.Join(provider.config.Directory, indexFileName))
provider.serveIndex(rw)
return
}
@@ -87,10 +84,15 @@ func (provider *provider) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if fi.IsDir() {
// path is a directory, serve index.html
http.ServeFile(rw, req, filepath.Join(provider.config.Directory, indexFileName))
provider.serveIndex(rw)
return
}
// otherwise, use http.FileServer to serve the static file
http.FileServer(http.Dir(provider.config.Directory)).ServeHTTP(rw, req)
}
func (provider *provider) serveIndex(rw http.ResponseWriter) {
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = rw.Write(provider.indexContents)
}

View File

@@ -5,45 +5,113 @@ import (
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/web"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestServeHttpWithoutPrefix(t *testing.T) {
t.Parallel()
fi, err := os.Open(filepath.Join("testdata", indexFileName))
require.NoError(t, err)
func startServer(t *testing.T, config web.Config, globalConfig global.Config) string {
t.Helper()
expected, err := io.ReadAll(fi)
require.NoError(t, err)
web, err := New(context.Background(), factorytest.NewSettings(), web.Config{Prefix: "/", Directory: filepath.Join("testdata")})
web, err := New(context.Background(), factorytest.NewSettings(), config, globalConfig)
require.NoError(t, err)
router := mux.NewRouter()
err = web.AddToRouter(router)
require.NoError(t, err)
require.NoError(t, web.AddToRouter(router))
listener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
server := &http.Server{
Handler: router,
server := &http.Server{Handler: router}
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() { _ = server.Close() })
return "http://" + listener.Addr().String()
}
func httpGet(t *testing.T, url string) string {
t.Helper()
res, err := http.DefaultClient.Get(url)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return string(body)
}
func TestServeTemplatedIndex(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
path string
globalConfig global.Config
expected string
}{
{
name: "RootBaseHrefAtRoot",
path: "/",
globalConfig: global.Config{},
expected: `<html><head><base href="/" /></head><body>Welcome to test data!!!</body></html>`,
},
{
name: "RootBaseHrefAtNonExistentPath",
path: "/does-not-exist",
globalConfig: global.Config{},
expected: `<html><head><base href="/" /></head><body>Welcome to test data!!!</body></html>`,
},
{
name: "RootBaseHrefAtDirectory",
path: "/assets",
globalConfig: global.Config{},
expected: `<html><head><base href="/" /></head><body>Welcome to test data!!!</body></html>`,
},
{
name: "SubPathBaseHrefAtRoot",
path: "/",
globalConfig: global.Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
expected: `<html><head><base href="/signoz/" /></head><body>Welcome to test data!!!</body></html>`,
},
{
name: "SubPathBaseHrefAtNonExistentPath",
path: "/does-not-exist",
globalConfig: global.Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
expected: `<html><head><base href="/signoz/" /></head><body>Welcome to test data!!!</body></html>`,
},
{
name: "SubPathBaseHrefAtDirectory",
path: "/assets",
globalConfig: global.Config{ExternalURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/signoz"}},
expected: `<html><head><base href="/signoz/" /></head><body>Welcome to test data!!!</body></html>`,
},
}
go func() {
_ = server.Serve(listener)
}()
defer func() {
_ = server.Close()
}()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
base := startServer(t, web.Config{Index: "valid_template.html", Directory: "testdata"}, testCase.globalConfig)
assert.Equal(t, testCase.expected, strings.TrimSuffix(httpGet(t, base+testCase.path), "\n"))
})
}
}
func TestServeNoTemplateIndex(t *testing.T) {
t.Parallel()
expected, err := os.ReadFile(filepath.Join("testdata", "no_template.html"))
require.NoError(t, err)
testCases := []struct {
name string
@@ -54,11 +122,7 @@ func TestServeHttpWithoutPrefix(t *testing.T) {
path: "/",
},
{
name: "Index",
path: "/" + indexFileName,
},
{
name: "DoesNotExist",
name: "NonExistentPath",
path: "/does-not-exist",
},
{
@@ -67,104 +131,55 @@ func TestServeHttpWithoutPrefix(t *testing.T) {
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
res, err := http.DefaultClient.Get("http://" + listener.Addr().String() + tc.path)
require.NoError(t, err)
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
base := startServer(t, web.Config{Index: "no_template.html", Directory: "testdata"}, global.Config{})
defer func() {
_ = res.Body.Close()
}()
actual, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, expected, actual)
assert.Equal(t, string(expected), httpGet(t, base+testCase.path))
})
}
}
func TestServeHttpWithPrefix(t *testing.T) {
func TestServeInvalidTemplateIndex(t *testing.T) {
t.Parallel()
fi, err := os.Open(filepath.Join("testdata", indexFileName))
expected, err := os.ReadFile(filepath.Join("testdata", "invalid_template.html"))
require.NoError(t, err)
expected, err := io.ReadAll(fi)
require.NoError(t, err)
web, err := New(context.Background(), factorytest.NewSettings(), web.Config{Prefix: "/web", Directory: filepath.Join("testdata")})
require.NoError(t, err)
router := mux.NewRouter()
err = web.AddToRouter(router)
require.NoError(t, err)
listener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
server := &http.Server{
Handler: router,
}
go func() {
_ = server.Serve(listener)
}()
defer func() {
_ = server.Close()
}()
testCases := []struct {
name string
path string
found bool
name string
path string
}{
{
name: "Root",
path: "/web",
found: true,
name: "Root",
path: "/",
},
{
name: "Index",
path: "/web/" + indexFileName,
found: true,
name: "NonExistentPath",
path: "/does-not-exist",
},
{
name: "FileDoesNotExist",
path: "/web/does-not-exist",
found: true,
},
{
name: "Directory",
path: "/web/assets",
found: true,
},
{
name: "DoesNotExist",
path: "/does-not-exist",
found: false,
name: "Directory",
path: "/assets",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
res, err := http.DefaultClient.Get("http://" + listener.Addr().String() + tc.path)
require.NoError(t, err)
defer func() {
_ = res.Body.Close()
}()
if tc.found {
actual, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, expected, actual)
} else {
assert.Equal(t, http.StatusNotFound, res.StatusCode)
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
base := startServer(t, web.Config{Index: "invalid_template.html", Directory: "testdata"}, global.Config{ExternalURL: &url.URL{Path: "/signoz"}})
assert.Equal(t, string(expected), httpGet(t, base+testCase.path))
})
}
}
func TestServeStaticFilesUnchanged(t *testing.T) {
t.Parallel()
expected, err := os.ReadFile(filepath.Join("testdata", "assets", "style.css"))
require.NoError(t, err)
base := startServer(t, web.Config{Index: "valid_template.html", Directory: "testdata"}, global.Config{ExternalURL: &url.URL{Path: "/signoz"}})
assert.Equal(t, string(expected), httpGet(t, base+"/assets/style.css"))
}

View File

@@ -1,3 +0,0 @@
#root {
background-color: red;
}

View File

@@ -0,0 +1 @@
body { color: red; }

View File

@@ -1 +0,0 @@
<h1>Welcome to test data!!!</h1>

View File

@@ -0,0 +1 @@
<html><head><base href="[[." /></head><body>Bad template</body></html>

View File

@@ -0,0 +1 @@
<html><head></head><body>No template here</body></html>

View File

@@ -0,0 +1 @@
<html><head><base href="[[.BaseHref]]" /></head><body>Welcome to test data!!!</body></html>

42
pkg/web/template.go Normal file
View File

@@ -0,0 +1,42 @@
package web
import (
"bytes"
"context"
"log/slog"
"text/template"
"github.com/SigNoz/signoz/pkg/errors"
)
// Field names map to the HTML attributes they populate in the template:
// - BaseHref → <base href="[[.BaseHref]]" />
type TemplateData struct {
BaseHref string
}
// If the template cannot be parsed or executed, the raw bytes are
// returned unchanged and the error is logged.
func NewIndex(ctx context.Context, logger *slog.Logger, name string, raw []byte, data TemplateData) []byte {
result, err := NewIndexE(name, raw, data)
if err != nil {
logger.ErrorContext(ctx, "cannot render index template, serving raw file", slog.String("name", name), errors.Attr(err))
return raw
}
return result
}
func NewIndexE(name string, raw []byte, data TemplateData) ([]byte, error) {
tmpl, err := template.New(name).Delims("[[", "]]").Parse(string(raw))
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot parse %q as template", name)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot execute template for %q", name)
}
return buf.Bytes(), nil
}