feat(web): add web package (#5743)

### Summary

Add a web package for serving frontend

#### Related Issues / PR's

https://github.com/SigNoz/signoz/pull/5710
This commit is contained in:
Vibhu Pandey 2024-08-22 20:56:15 +05:30 committed by GitHub
parent c322fc72d9
commit bfeceb0ed2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 379 additions and 1 deletions

View File

@ -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
}

View File

@ -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)

View File

@ -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)
})
}

View File

@ -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))
})
}
}

29
pkg/web/config.go Normal file
View File

@ -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
}

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

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

94
pkg/web/web.go Normal file
View File

@ -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)
}

159
pkg/web/web_test.go Normal file
View File

@ -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)
}
})
}
}