diff --git a/pkg/config/config.go b/pkg/config/config.go
index 621cc073c6..02cc7c37c2 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -4,11 +4,13 @@ import (
"context"
"go.signoz.io/signoz/pkg/instrumentation"
+ "go.signoz.io/signoz/pkg/web"
)
// Config defines the entire configuration of signoz.
type Config struct {
Instrumentation instrumentation.Config `mapstructure:"instrumentation"`
+ Web web.Config `mapstructure:"web"`
}
func New(ctx context.Context, settings ProviderSettings) (*Config, error) {
@@ -24,6 +26,8 @@ func byName(name string) (any, bool) {
switch name {
case "instrumentation":
return &instrumentation.Config{}, true
+ case "web":
+ return &web.Config{}, true
default:
return nil, false
}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 04ede418f0..b3e3007bb4 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -10,12 +10,15 @@ import (
contribsdkconfig "go.opentelemetry.io/contrib/config"
"go.signoz.io/signoz/pkg/confmap/provider/signozenvprovider"
"go.signoz.io/signoz/pkg/instrumentation"
+ "go.signoz.io/signoz/pkg/web"
)
func TestNewWithSignozEnvProvider(t *testing.T) {
t.Setenv("SIGNOZ__INSTRUMENTATION__LOGS__ENABLED", "true")
t.Setenv("SIGNOZ__INSTRUMENTATION__LOGS__PROCESSORS__BATCH__EXPORTER__OTLP__ENDPOINT", "0.0.0.0:4317")
t.Setenv("SIGNOZ__INSTRUMENTATION__LOGS__PROCESSORS__BATCH__EXPORT_TIMEOUT", "10")
+ t.Setenv("SIGNOZ__WEB__PREFIX", "/web")
+ t.Setenv("SIGNOZ__WEB__DIRECTORY", "/build")
config, err := New(context.Background(), ProviderSettings{
ResolverSettings: confmap.ResolverSettings{
@@ -34,7 +37,7 @@ func TestNewWithSignozEnvProvider(t *testing.T) {
Enabled: true,
LoggerProvider: contribsdkconfig.LoggerProvider{
Processors: []contribsdkconfig.LogRecordProcessor{
- contribsdkconfig.LogRecordProcessor{
+ {
Batch: &contribsdkconfig.BatchLogRecordProcessor{
ExportTimeout: &i,
Exporter: contribsdkconfig.LogRecordExporter{
@@ -48,6 +51,10 @@ func TestNewWithSignozEnvProvider(t *testing.T) {
},
},
},
+ Web: web.Config{
+ Prefix: "/web",
+ Directory: "/build",
+ },
}
assert.Equal(t, expected, config)
diff --git a/pkg/http/middleware/cache.go b/pkg/http/middleware/cache.go
new file mode 100644
index 0000000000..ff66288354
--- /dev/null
+++ b/pkg/http/middleware/cache.go
@@ -0,0 +1,28 @@
+package middleware
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+)
+
+type Cache struct {
+ maxAge time.Duration
+}
+
+func NewCache(maxAge time.Duration) *Cache {
+ if maxAge == 0 {
+ maxAge = 7 * 24 * time.Hour
+ }
+
+ return &Cache{
+ maxAge: maxAge,
+ }
+}
+
+func (middleware *Cache) Wrap(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ rw.Header().Set("Cache-Control", "max-age="+strconv.Itoa(int(middleware.maxAge.Seconds())))
+ next.ServeHTTP(rw, req)
+ })
+}
diff --git a/pkg/http/middleware/cache_test.go b/pkg/http/middleware/cache_test.go
new file mode 100644
index 0000000000..bef0e3b36b
--- /dev/null
+++ b/pkg/http/middleware/cache_test.go
@@ -0,0 +1,56 @@
+package middleware
+
+import (
+ "net"
+ "net/http"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestCache(t *testing.T) {
+ t.Parallel()
+
+ age := 20 * 24 * time.Hour
+ m := NewCache(age)
+
+ listener, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+
+ server := &http.Server{
+ Handler: m.Wrap(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ rw.WriteHeader(204)
+ })),
+ }
+
+ go func() {
+ require.NoError(t, server.Serve(listener))
+ }()
+
+ testCases := []struct {
+ name string
+ age time.Duration
+ }{
+ {
+ name: "Success",
+ age: age,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ req, err := http.NewRequest("GET", "http://"+listener.Addr().String(), nil)
+ require.NoError(t, err)
+
+ res, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+
+ actual := res.Header.Get("Cache-control")
+ require.NoError(t, err)
+
+ require.Equal(t, "max-age="+strconv.Itoa(int(age.Seconds())), string(actual))
+ })
+ }
+}
diff --git a/pkg/web/config.go b/pkg/web/config.go
new file mode 100644
index 0000000000..a0e0c531de
--- /dev/null
+++ b/pkg/web/config.go
@@ -0,0 +1,29 @@
+package web
+
+import (
+ "go.signoz.io/signoz/pkg/confmap"
+)
+
+// Config satisfies the confmap.Config interface
+var _ confmap.Config = (*Config)(nil)
+
+// Config holds the configuration for web.
+type Config struct {
+ // 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"`
+}
+
+func (c *Config) NewWithDefaults() confmap.Config {
+ return &Config{
+ Prefix: "/",
+ Directory: "/etc/signoz/web",
+ }
+
+}
+
+func (c *Config) Validate() error {
+ return nil
+}
diff --git a/pkg/web/testdata/index.html b/pkg/web/testdata/index.html
new file mode 100644
index 0000000000..49c8c8383a
--- /dev/null
+++ b/pkg/web/testdata/index.html
@@ -0,0 +1 @@
+
Welcome to test data!!!
\ No newline at end of file
diff --git a/pkg/web/web.go b/pkg/web/web.go
new file mode 100644
index 0000000000..2e29b000f9
--- /dev/null
+++ b/pkg/web/web.go
@@ -0,0 +1,94 @@
+package web
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/gorilla/mux"
+ "go.signoz.io/signoz/pkg/http/middleware"
+ "go.uber.org/zap"
+)
+
+var _ http.Handler = (*Web)(nil)
+
+const (
+ indexFileName string = "index.html"
+)
+
+type Web struct {
+ logger *zap.Logger
+ cfg Config
+}
+
+func New(logger *zap.Logger, cfg Config) (*Web, error) {
+ if logger == nil {
+ return nil, fmt.Errorf("cannot build web, logger is required")
+ }
+
+ fi, err := os.Stat(cfg.Directory)
+ if err != nil {
+ return nil, fmt.Errorf("cannot access web directory: %w", err)
+ }
+
+ ok := fi.IsDir()
+ if !ok {
+ return nil, fmt.Errorf("web directory is not a directory")
+ }
+
+ fi, err = os.Stat(filepath.Join(cfg.Directory, indexFileName))
+ if err != nil {
+ return nil, fmt.Errorf("cannot access %q in web directory: %w", indexFileName, err)
+ }
+
+ if os.IsNotExist(err) || fi.IsDir() {
+ return nil, fmt.Errorf("%q does not exist", indexFileName)
+ }
+
+ return &Web{
+ logger: logger.Named("go.signoz.io/pkg/web"),
+ cfg: cfg,
+ }, nil
+}
+
+func (web *Web) AddToRouter(router *mux.Router) error {
+ cache := middleware.NewCache(7 * 24 * time.Hour)
+ err := router.PathPrefix(web.cfg.Prefix).
+ Handler(
+ http.StripPrefix(
+ web.cfg.Prefix,
+ cache.Wrap(http.HandlerFunc(web.ServeHTTP)),
+ ),
+ ).GetError()
+ if err != nil {
+ return fmt.Errorf("unable to add web to router: %w", err)
+ }
+
+ return nil
+}
+
+func (web *Web) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
+ // Join internally call path.Clean to prevent directory traversal
+ path := filepath.Join(web.cfg.Directory, req.URL.Path)
+
+ // check whether a file exists or is a directory at the given path
+ fi, err := os.Stat(path)
+ if os.IsNotExist(err) || fi.IsDir() {
+ // file does not exist or path is a directory, serve index.html
+ http.ServeFile(rw, req, filepath.Join(web.cfg.Directory, indexFileName))
+ return
+ }
+
+ if err != nil {
+ // if we got an error (that wasn't that the file doesn't exist) stating the
+ // file, return a 500 internal server error and stop
+ // TODO: Put down a crash html page here
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // otherwise, use http.FileServer to serve the static file
+ http.FileServer(http.Dir(web.cfg.Directory)).ServeHTTP(rw, req)
+}
diff --git a/pkg/web/web_test.go b/pkg/web/web_test.go
new file mode 100644
index 0000000000..d5111cf747
--- /dev/null
+++ b/pkg/web/web_test.go
@@ -0,0 +1,159 @@
+package web
+
+import (
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/gorilla/mux"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+)
+
+func TestServeHttpWithoutPrefix(t *testing.T) {
+ t.Parallel()
+ fi, err := os.Open(filepath.Join("testdata", indexFileName))
+ require.NoError(t, err)
+
+ expected, err := io.ReadAll(fi)
+ require.NoError(t, err)
+
+ web, err := New(zap.NewNop(), Config{Prefix: "/", 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: "Root",
+ path: "/",
+ },
+ {
+ name: "Index",
+ path: "/" + indexFileName,
+ },
+ {
+ name: "DoesNotExist",
+ path: "/does-not-exist",
+ },
+ }
+
+ 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()
+ }()
+
+ actual, err := io.ReadAll(res.Body)
+ require.NoError(t, err)
+
+ assert.Equal(t, expected, actual)
+ })
+ }
+
+}
+
+func TestServeHttpWithPrefix(t *testing.T) {
+ t.Parallel()
+ fi, err := os.Open(filepath.Join("testdata", indexFileName))
+ require.NoError(t, err)
+
+ expected, err := io.ReadAll(fi)
+ require.NoError(t, err)
+
+ web, err := New(zap.NewNop(), 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: "Root",
+ path: "/web",
+ found: true,
+ },
+ {
+ name: "Index",
+ path: "/web/" + indexFileName,
+ found: true,
+ },
+ {
+ name: "FileDoesNotExist",
+ path: "/web/does-not-exist",
+ found: true,
+ },
+ {
+ name: "DoesNotExist",
+ path: "/does-not-exist",
+ found: false,
+ },
+ }
+
+ 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)
+ }
+
+ })
+ }
+
+}