Compare commits

..

2 Commits

Author SHA1 Message Date
Abhi Kumar
8e1916daa6 chore: minor cleanup 2026-04-16 00:53:40 +05:30
Abhi Kumar
7eb8806c0f chore: added changes for crosshair sync for tooltip 2026-04-16 00:50:16 +05:30
26 changed files with 228 additions and 406 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(config.Global),
signoz.NewWebProviderFactories(),
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(config.Global),
signoz.NewWebProviderFactories(),
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,8 +6,6 @@
##################### 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>
@@ -52,8 +50,8 @@ pprof:
web:
# Whether to enable the web frontend
enabled: true
# The index file to use as the SPA entrypoint.
index: index.html
# The prefix to serve web on
prefix: /
# The directory containing the static build files.
directory: /etc/signoz/web

View File

@@ -262,20 +262,6 @@ 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

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
@@ -8,6 +8,7 @@ import {
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import { CursorSyncPanelMetadata } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import noop from 'lodash-es/noop';
import uPlot from 'uplot';
@@ -27,6 +28,7 @@ export default function ChartWrapper({
canPinTooltip = false,
syncMode,
syncKey,
yAxisUnit,
onDestroy = noop,
children,
layoutChildren,
@@ -34,6 +36,9 @@ export default function ChartWrapper({
pinnedTooltipElement,
'data-testid': testId,
}: ChartProps): JSX.Element {
const panelMetadata = useMemo<CursorSyncPanelMetadata>(() => ({ yAxisUnit }), [
yAxisUnit,
]);
const plotInstanceRef = useRef<uPlot | null>(null);
const legendComponent = useCallback(
@@ -99,6 +104,7 @@ export default function ChartWrapper({
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
panelMetadata={panelMetadata}
render={renderTooltipCallback}
pinnedTooltipElement={pinnedTooltipElement}
/>

View File

@@ -4,6 +4,7 @@ import cx from 'classnames';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import uPlot from 'uplot';
import { syncCursorRegistry } from './syncCursorRegistry';
import {
createInitialControllerState,
createSetCursorHandler,
@@ -40,6 +41,7 @@ export default function TooltipPlugin({
maxHeight = 600,
syncMode = DashboardCursorSync.None,
syncKey = '_tooltip_sync_global_',
panelMetadata,
pinnedTooltipElement,
canPinTooltip = false,
}: TooltipPluginProps): JSX.Element | null {
@@ -100,7 +102,29 @@ export default function TooltipPlugin({
// crosshair / tooltip can follow the dashboard-wide cursor.
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
config.setCursor({
sync: { key: syncKey, scales: ['x', null] },
sync: { key: syncKey, scales: ['x', 'y'] },
});
// Show the horizontal crosshair only when the receiving panel shares
// the same y-axis unit as the source panel. When this panel is the
// source (cursor.event != null) the line is always shown and this
// panel's metadata is written to the registry so receivers can read it.
config.addHook('setCursor', (u: uPlot): void => {
const yCursorEl = u.root.querySelector<HTMLElement>('.u-cursor-y');
if (!yCursorEl) {
return;
}
if (u.cursor.event != null) {
// This panel is the source — publish metadata and always show line.
syncCursorRegistry.setMetadata(syncKey, panelMetadata);
yCursorEl.style.display = '';
} else {
// This panel is receiving sync — show only if units match.
const sourceMeta = syncCursorRegistry.getMetadata(syncKey);
yCursorEl.style.display =
sourceMeta?.yAxisUnit === panelMetadata?.yAxisUnit ? '' : 'none';
}
});
}

View File

@@ -0,0 +1,30 @@
import type { CursorSyncPanelMetadata } from './types';
/**
* Module-level registry that tracks the metadata of the panel currently
* acting as the cursor source (the one being hovered) per sync group.
*
* uPlot fires the source panel's setCursor hook before broadcasting to
* receivers, so the registry is always populated before receivers read it.
*
* Receivers use this to make decisions such as:
* - Whether to show the horizontal crosshair line (matching yAxisUnit)
* - Future: what to render inside the tooltip (matching groupBy, etc.)
*/
const metadataBySyncKey = new Map<
string,
CursorSyncPanelMetadata | undefined
>();
export const syncCursorRegistry = {
setMetadata(
syncKey: string,
metadata: CursorSyncPanelMetadata | undefined,
): void {
metadataBySyncKey.set(syncKey, metadata);
},
getMetadata(syncKey: string): CursorSyncPanelMetadata | undefined {
return metadataBySyncKey.get(syncKey);
},
};

View File

@@ -4,11 +4,29 @@ import type {
ReactNode,
RefObject,
} from 'react';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type uPlot from 'uplot';
import type { TooltipRenderArgs } from '../../components/types';
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
/**
* Per-panel metadata shared with the cursor sync registry so that
* panels receiving a synced cursor can make informed decisions about
* rendering (e.g. whether to show the horizontal crosshair, what to
* show in the tooltip for the source panel's context).
*
* Add new fields here as sync-aware features need them.
*/
export interface CursorSyncPanelMetadata {
/** Y-axis unit of this panel (e.g. 'ms', 'req/s'). Used to decide
* whether the horizontal crosshair should be shown on receiving panels. */
yAxisUnit?: string;
/** Label dimensions this panel groups by. Will be used to enrich
* tooltip content on receiving panels to show correlated series. */
groupBy?: BaseAutocompleteData[];
}
export const TOOLTIP_OFFSET = 10;
export enum DashboardCursorSync {
@@ -39,6 +57,9 @@ export interface TooltipPluginProps {
canPinTooltip?: boolean;
syncMode?: DashboardCursorSync;
syncKey?: string;
/** Metadata about this panel shared with the sync registry so that
* receiving panels can make context-aware rendering decisions. */
panelMetadata?: CursorSyncPanelMetadata;
render: (args: TooltipRenderArgs) => ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
maxWidth?: number;

View File

@@ -516,7 +516,7 @@ describe('TooltipPlugin', () => {
);
expect(setCursorSpy).toHaveBeenCalledWith({
sync: { key: 'dashboard-sync', scales: ['x', null] },
sync: { key: 'dashboard-sync', scales: ['x', 'y'] },
});
});

View File

@@ -2,8 +2,6 @@ package global
import (
"net/url"
"path"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -39,34 +37,5 @@ 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 "/"
}

View File

@@ -1,139 +0,0 @@
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,6 +587,7 @@ 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,20 +244,6 @@ 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(globalConfig global.Config) factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]] {
func NewWebProviderFactories() factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]] {
return factory.MustNewNamedMap(
routerweb.NewFactory(globalConfig),
routerweb.NewFactory(),
noopweb.NewFactory(),
)
}

View File

@@ -8,7 +8,6 @@ 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"
@@ -35,7 +34,7 @@ func TestNewProviderFactories(t *testing.T) {
})
assert.NotPanics(t, func() {
NewWebProviderFactories(global.Config{})
NewWebProviderFactories()
})
assert.NotPanics(t, func() {

View File

@@ -8,11 +8,10 @@ import (
type Config struct {
// Whether the web package is enabled.
Enabled bool `mapstructure:"enabled"`
// The name of the index file to serve.
Index string `mapstructure:"index"`
// The directory from which to serve the web files.
// 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.
Directory string `mapstructure:"directory"`
}
@@ -23,7 +22,7 @@ func NewConfigFactory() factory.ConfigFactory {
func newConfig() factory.Config {
return &Config{
Enabled: true,
Index: "index.html",
Prefix: "/",
Directory: "/etc/signoz/web",
}
}

View File

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

View File

@@ -8,53 +8,56 @@ 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
indexContents []byte
config web.Config
}
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 NewFactory() factory.ProviderFactory[web.Web, web.Config] {
return factory.NewProviderFactory(factory.MustNewName("router"), New)
}
func New(ctx context.Context, settings factory.ProviderSettings, config web.Config, globalConfig global.Config) (web.Web, error) {
func New(ctx context.Context, settings factory.ProviderSettings, config web.Config) (web.Web, error) {
fi, err := os.Stat(config.Directory)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot access web directory")
}
if !fi.IsDir() {
ok := fi.IsDir()
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "web directory is not a directory")
}
indexPath := filepath.Join(config.Directory, config.Index)
raw, err := os.ReadFile(indexPath)
fi, err = os.Stat(filepath.Join(config.Directory, indexFileName))
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot read %q in web directory", config.Index)
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "cannot access %q in web directory", 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()})
if os.IsNotExist(err) || fi.IsDir() {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "%q does not exist", indexFileName)
}
return &provider{
config: config,
indexContents: indexContents,
config: config,
}, nil
}
func (provider *provider) AddToRouter(router *mux.Router) error {
cache := middleware.NewCache(0)
err := router.PathPrefix("/").
err := router.PathPrefix(provider.config.Prefix).
Handler(
cache.Wrap(http.HandlerFunc(provider.ServeHTTP)),
http.StripPrefix(
provider.config.Prefix,
cache.Wrap(http.HandlerFunc(provider.ServeHTTP)),
),
).GetError()
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "unable to add web to router")
@@ -72,7 +75,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) {
provider.serveIndex(rw)
http.ServeFile(rw, req, filepath.Join(provider.config.Directory, indexFileName))
return
}
@@ -84,15 +87,10 @@ func (provider *provider) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if fi.IsDir() {
// path is a directory, serve index.html
provider.serveIndex(rw)
http.ServeFile(rw, req, filepath.Join(provider.config.Directory, indexFileName))
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,113 +5,45 @@ 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 startServer(t *testing.T, config web.Config, globalConfig global.Config) string {
t.Helper()
func TestServeHttpWithoutPrefix(t *testing.T) {
t.Parallel()
fi, err := os.Open(filepath.Join("testdata", indexFileName))
require.NoError(t, err)
web, err := New(context.Background(), factorytest.NewSettings(), config, globalConfig)
expected, err := io.ReadAll(fi)
require.NoError(t, err)
web, err := New(context.Background(), factorytest.NewSettings(), web.Config{Prefix: "/", Directory: filepath.Join("testdata")})
require.NoError(t, err)
router := mux.NewRouter()
require.NoError(t, web.AddToRouter(router))
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) }()
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>`,
},
server := &http.Server{
Handler: router,
}
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)
go func() {
_ = server.Serve(listener)
}()
defer func() {
_ = server.Close()
}()
testCases := []struct {
name string
@@ -122,7 +54,11 @@ func TestServeNoTemplateIndex(t *testing.T) {
path: "/",
},
{
name: "NonExistentPath",
name: "Index",
path: "/" + indexFileName,
},
{
name: "DoesNotExist",
path: "/does-not-exist",
},
{
@@ -131,55 +67,104 @@ func TestServeNoTemplateIndex(t *testing.T) {
},
}
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{})
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)
assert.Equal(t, string(expected), httpGet(t, base+testCase.path))
defer func() {
_ = res.Body.Close()
}()
actual, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, expected, actual)
})
}
}
func TestServeInvalidTemplateIndex(t *testing.T) {
func TestServeHttpWithPrefix(t *testing.T) {
t.Parallel()
expected, err := os.ReadFile(filepath.Join("testdata", "invalid_template.html"))
fi, err := os.Open(filepath.Join("testdata", indexFileName))
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
name string
path string
found bool
}{
{
name: "Root",
path: "/",
name: "Root",
path: "/web",
found: true,
},
{
name: "NonExistentPath",
path: "/does-not-exist",
name: "Index",
path: "/web/" + indexFileName,
found: true,
},
{
name: "Directory",
path: "/assets",
name: "FileDoesNotExist",
path: "/web/does-not-exist",
found: true,
},
{
name: "Directory",
path: "/web/assets",
found: true,
},
{
name: "DoesNotExist",
path: "/does-not-exist",
found: false,
},
}
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"}})
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)
}
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

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

View File

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

1
pkg/web/routerweb/testdata/index.html vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,42 +0,0 @@
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
}