mirror of
https://github.com/sablierapp/sablier.git
synced 2025-12-21 13:23:03 +01:00
refactor(theme): themes are loaded at startup instead of every request
This commit is contained in:
@@ -20,10 +20,11 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
|
||||
FROM alpine:3.19.1
|
||||
|
||||
RUN mkdir -p /etc/sablier/themes
|
||||
EXPOSE 10000
|
||||
|
||||
COPY --from=build /src/sablier* /etc/sablier/sablier
|
||||
COPY docker/sablier.yaml /etc/sablier/sablier.yaml
|
||||
|
||||
EXPOSE 10000
|
||||
|
||||
ENTRYPOINT [ "/etc/sablier/sablier" ]
|
||||
CMD [ "--configFile=/etc/sablier/sablier.yaml", "start" ]
|
||||
@@ -1,111 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed themes/*
|
||||
var Themes embed.FS
|
||||
|
||||
type RenderOptionsInstanceState struct {
|
||||
Name string
|
||||
CurrentReplicas int
|
||||
DesiredReplicas int
|
||||
Status string
|
||||
Error error
|
||||
}
|
||||
|
||||
type RenderOptions struct {
|
||||
DisplayName string
|
||||
ShowDetails bool
|
||||
InstanceStates []RenderOptionsInstanceState
|
||||
SessionDuration time.Duration
|
||||
RefreshFrequency time.Duration
|
||||
Theme string
|
||||
CustomThemes fs.FS
|
||||
// If custom theme is loaded through os.DirFS, nothing prevents you
|
||||
// from escaping the prefix with relative path such as ..
|
||||
// The `AllowedCustomThemes` are the themes that were scanned during initilization
|
||||
AllowedCustomThemes map[string]bool
|
||||
Version string
|
||||
}
|
||||
|
||||
type TemplateValues struct {
|
||||
DisplayName string
|
||||
InstanceStates []RenderOptionsInstanceState
|
||||
SessionDuration string
|
||||
RefreshFrequency string
|
||||
Version string
|
||||
}
|
||||
|
||||
func Render(options RenderOptions, writer io.Writer) error {
|
||||
var tpl *template.Template
|
||||
var err error
|
||||
|
||||
// Load custom theme if provided
|
||||
if options.CustomThemes != nil && options.AllowedCustomThemes[options.Theme] {
|
||||
tpl, err = template.ParseFS(options.CustomThemes, fmt.Sprintf("%s.html", options.Theme))
|
||||
} else {
|
||||
// Load from the embedded FS
|
||||
tpl, err = template.ParseFS(Themes, fmt.Sprintf("themes/%s.html", options.Theme))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
instanceStates := []RenderOptionsInstanceState{}
|
||||
|
||||
if options.ShowDetails {
|
||||
instanceStates = options.InstanceStates
|
||||
}
|
||||
|
||||
return tpl.Execute(writer, TemplateValues{
|
||||
DisplayName: options.DisplayName,
|
||||
InstanceStates: instanceStates,
|
||||
SessionDuration: humanizeDuration(options.SessionDuration),
|
||||
RefreshFrequency: fmt.Sprintf("%d", int64(options.RefreshFrequency.Seconds())),
|
||||
Version: options.Version,
|
||||
})
|
||||
}
|
||||
|
||||
// humanizeDuration humanizes time.Duration output to a meaningful value,
|
||||
// golang's default “time.Duration“ output is badly formatted and unreadable.
|
||||
func humanizeDuration(duration time.Duration) string {
|
||||
if duration.Seconds() < 60.0 {
|
||||
return fmt.Sprintf("%d seconds", int64(duration.Seconds()))
|
||||
}
|
||||
if duration.Minutes() < 60.0 {
|
||||
remainingSeconds := math.Mod(duration.Seconds(), 60)
|
||||
if remainingSeconds > 0 {
|
||||
return fmt.Sprintf("%d minutes %d seconds", int64(duration.Minutes()), int64(remainingSeconds))
|
||||
}
|
||||
return fmt.Sprintf("%d minutes", int64(duration.Minutes()))
|
||||
}
|
||||
if duration.Hours() < 24.0 {
|
||||
remainingMinutes := math.Mod(duration.Minutes(), 60)
|
||||
remainingSeconds := math.Mod(duration.Seconds(), 60)
|
||||
|
||||
if remainingMinutes > 0 {
|
||||
if remainingSeconds > 0 {
|
||||
return fmt.Sprintf("%d hours %d minutes %d seconds", int64(duration.Hours()), int64(remainingMinutes), int64(remainingSeconds))
|
||||
}
|
||||
return fmt.Sprintf("%d hours %d minutes", int64(duration.Hours()), int64(remainingMinutes))
|
||||
}
|
||||
return fmt.Sprintf("%d hours", int64(duration.Hours()))
|
||||
}
|
||||
remainingHours := math.Mod(duration.Hours(), 24)
|
||||
remainingMinutes := math.Mod(duration.Minutes(), 60)
|
||||
remainingSeconds := math.Mod(duration.Seconds(), 60)
|
||||
return fmt.Sprintf("%d days %d hours %d minutes %d seconds",
|
||||
int64(duration.Hours()/24), int64(remainingHours),
|
||||
int64(remainingMinutes), int64(remainingSeconds))
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var instanceStates []RenderOptionsInstanceState = []RenderOptionsInstanceState{
|
||||
{
|
||||
Name: "nginx",
|
||||
CurrentReplicas: 0,
|
||||
DesiredReplicas: 4,
|
||||
Status: "starting",
|
||||
Error: nil,
|
||||
},
|
||||
{
|
||||
Name: "whoami",
|
||||
CurrentReplicas: 4,
|
||||
DesiredReplicas: 4,
|
||||
Status: "started",
|
||||
Error: nil,
|
||||
},
|
||||
{
|
||||
Name: "devil",
|
||||
CurrentReplicas: 0,
|
||||
DesiredReplicas: 4,
|
||||
Status: "error",
|
||||
Error: fmt.Errorf("devil service does not exist"),
|
||||
},
|
||||
}
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
type args struct {
|
||||
options RenderOptions
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Load ghost theme",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "ghost",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
CustomThemes: nil,
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Load hacker-terminal theme",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "hacker-terminal",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
CustomThemes: nil,
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Load matrix theme",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "matrix",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
CustomThemes: nil,
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Load shiffle theme",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "shuffle",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
CustomThemes: nil,
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Load non existent theme",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "nonexistent",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
CustomThemes: nil,
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Load custom theme",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "dc-comics",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
CustomThemes: fstest.MapFS{
|
||||
"marvel.html": {Data: []byte("{{ .DisplayName }}")},
|
||||
"dc-comics.html": {Data: []byte("batman")},
|
||||
},
|
||||
AllowedCustomThemes: map[string]bool{
|
||||
"marvel": true,
|
||||
"dc-comics": true,
|
||||
},
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Load non existent custom theme",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "nonexistent",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
CustomThemes: fstest.MapFS{
|
||||
"marvel.html": {Data: []byte("thor")},
|
||||
"dc-comics.html": {Data: []byte("batman")},
|
||||
},
|
||||
AllowedCustomThemes: map[string]bool{
|
||||
"marvel": true,
|
||||
"dc-comics": true,
|
||||
},
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Load embedded theme with custom theme provided",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "hacker-terminal",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
CustomThemes: fstest.MapFS{
|
||||
"marvel.html": {Data: []byte("thor")},
|
||||
"dc-comics.html": {Data: []byte("batman")},
|
||||
},
|
||||
AllowedCustomThemes: map[string]bool{
|
||||
"marvel": true,
|
||||
"dc-comics": true,
|
||||
},
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Error loading non allowed custom theme",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "dc-comics",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
CustomThemes: fstest.MapFS{
|
||||
"marvel.html": {Data: []byte("thor")},
|
||||
"dc-comics.html": {Data: []byte("batman")},
|
||||
},
|
||||
AllowedCustomThemes: map[string]bool{
|
||||
"marvel": true,
|
||||
},
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
writer := &bytes.Buffer{}
|
||||
if err := Render(tt.args.options, writer); (err != nil) != tt.wantErr {
|
||||
t.Errorf("Render() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContent(t *testing.T) {
|
||||
type args struct {
|
||||
options RenderOptions
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "refresh frequency is 10 seconds",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "ghost",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 10 * time.Second,
|
||||
CustomThemes: nil,
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantContent: "<meta http-equiv=\"refresh\" content=\"10\" />",
|
||||
},
|
||||
{
|
||||
name: "details is rendered",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
ShowDetails: true,
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "ghost",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 10 * time.Second,
|
||||
CustomThemes: nil,
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantContent: "started (4/4)",
|
||||
},
|
||||
{
|
||||
name: "details is not rendered",
|
||||
args: args{
|
||||
options: RenderOptions{
|
||||
DisplayName: "Test",
|
||||
ShowDetails: false,
|
||||
InstanceStates: instanceStates,
|
||||
Theme: "ghost",
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 10 * time.Second,
|
||||
CustomThemes: nil,
|
||||
Version: "v0.0.0",
|
||||
},
|
||||
},
|
||||
wantContent: "<table></table>",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
writer := &bytes.Buffer{}
|
||||
if err := Render(tt.args.options, writer); err != nil {
|
||||
t.Errorf("Render() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(writer)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ReadAll() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Contains(t, string(content), tt.wantContent)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -10,40 +9,33 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/acouvreur/sablier/app/http/pages"
|
||||
"github.com/acouvreur/sablier/app/http/routes/models"
|
||||
"github.com/acouvreur/sablier/app/instance"
|
||||
"github.com/acouvreur/sablier/app/sessions"
|
||||
"github.com/acouvreur/sablier/app/theme"
|
||||
"github.com/acouvreur/sablier/config"
|
||||
"github.com/acouvreur/sablier/version"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var osDirFS = os.DirFS
|
||||
|
||||
type ServeStrategy struct {
|
||||
customThemesFS fs.FS
|
||||
customThemes map[string]bool
|
||||
Theme *theme.Themes
|
||||
|
||||
SessionsManager sessions.Manager
|
||||
StrategyConfig config.Strategy
|
||||
SessionsConfig config.Sessions
|
||||
}
|
||||
|
||||
func NewServeStrategy(sessionsManager sessions.Manager, strategyConf config.Strategy, sessionsConf config.Sessions) *ServeStrategy {
|
||||
func NewServeStrategy(sessionsManager sessions.Manager, strategyConf config.Strategy, sessionsConf config.Sessions, themes *theme.Themes) *ServeStrategy {
|
||||
|
||||
serveStrategy := &ServeStrategy{
|
||||
Theme: themes,
|
||||
SessionsManager: sessionsManager,
|
||||
StrategyConfig: strategyConf,
|
||||
SessionsConfig: sessionsConf,
|
||||
}
|
||||
|
||||
if strategyConf.Dynamic.CustomThemesPath != "" {
|
||||
customThemesFs := osDirFS(strategyConf.Dynamic.CustomThemesPath)
|
||||
serveStrategy.customThemesFS = customThemesFs
|
||||
serveStrategy.customThemes = listThemes(customThemesFs)
|
||||
}
|
||||
|
||||
return serveStrategy
|
||||
}
|
||||
|
||||
@@ -78,46 +70,22 @@ func (s *ServeStrategy) ServeDynamic(c *gin.Context) {
|
||||
c.Header("X-Sablier-Session-Status", "not-ready")
|
||||
}
|
||||
|
||||
renderOptions := pages.RenderOptions{
|
||||
DisplayName: request.DisplayName,
|
||||
ShowDetails: request.ShowDetails,
|
||||
SessionDuration: request.SessionDuration,
|
||||
Theme: request.Theme,
|
||||
CustomThemes: s.customThemesFS,
|
||||
AllowedCustomThemes: s.customThemes,
|
||||
Version: version.Version,
|
||||
RefreshFrequency: request.RefreshFrequency,
|
||||
InstanceStates: sessionStateToRenderOptionsInstanceState(sessionState),
|
||||
renderOptions := theme.Options{
|
||||
DisplayName: request.DisplayName,
|
||||
ShowDetails: request.ShowDetails,
|
||||
SessionDuration: request.SessionDuration,
|
||||
RefreshFrequency: request.RefreshFrequency,
|
||||
InstanceStates: sessionStateToRenderOptionsInstanceState(sessionState),
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html")
|
||||
if err := pages.Render(renderOptions, c.Writer); err != nil {
|
||||
if err := s.Theme.Render(request.Theme, renderOptions, c.Writer); err != nil {
|
||||
log.Error(err)
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServeStrategy) ServeDynamicThemes(c *gin.Context) {
|
||||
|
||||
customThemes := []string{}
|
||||
for theme := range s.customThemes {
|
||||
customThemes = append(customThemes, theme)
|
||||
}
|
||||
sort.Strings(customThemes)
|
||||
|
||||
embeddedThemes := []string{}
|
||||
for theme := range listThemes(pages.Themes) {
|
||||
embeddedThemes = append(embeddedThemes, strings.TrimPrefix(theme, "themes/"))
|
||||
}
|
||||
sort.Strings(embeddedThemes)
|
||||
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"custom": customThemes,
|
||||
"embedded": embeddedThemes,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ServeStrategy) ServeBlocking(c *gin.Context) {
|
||||
request := models.BlockingRequest{
|
||||
Timeout: s.StrategyConfig.Blocking.DefaultTimeout,
|
||||
@@ -161,7 +129,7 @@ func (s *ServeStrategy) ServeBlocking(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, map[string]interface{}{"session": sessionState})
|
||||
}
|
||||
|
||||
func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionState) (instances []pages.RenderOptionsInstanceState) {
|
||||
func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionState) (instances []theme.Instance) {
|
||||
sessionState.Instances.Range(func(key, value any) bool {
|
||||
instances = append(instances, instanceStateToRenderOptionsRequestState(value.(sessions.InstanceState).Instance))
|
||||
return true
|
||||
@@ -174,7 +142,7 @@ func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionStat
|
||||
return
|
||||
}
|
||||
|
||||
func instanceStateToRenderOptionsRequestState(instanceState *instance.State) pages.RenderOptionsInstanceState {
|
||||
func instanceStateToRenderOptionsRequestState(instanceState *instance.State) theme.Instance {
|
||||
|
||||
var err error
|
||||
if instanceState.Message == "" {
|
||||
@@ -183,7 +151,7 @@ func instanceStateToRenderOptionsRequestState(instanceState *instance.State) pag
|
||||
err = fmt.Errorf(instanceState.Message)
|
||||
}
|
||||
|
||||
return pages.RenderOptionsInstanceState{
|
||||
return theme.Instance{
|
||||
Name: instanceState.Name,
|
||||
Status: instanceState.Status,
|
||||
CurrentReplicas: instanceState.CurrentReplicas,
|
||||
@@ -191,25 +159,3 @@ func instanceStateToRenderOptionsRequestState(instanceState *instance.State) pag
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
func listThemes(dir fs.FS) (themes map[string]bool) {
|
||||
themes = make(map[string]bool)
|
||||
fs.WalkDir(dir, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.HasSuffix(d.Name(), ".html") {
|
||||
log.Debugf("found theme at \"%s\" can be loaded using \"%s\"", path, strings.TrimSuffix(path, ".html"))
|
||||
themes[strings.TrimSuffix(path, ".html")] = true
|
||||
} else {
|
||||
log.Tracef("ignoring file \"%s\" because it has no .html suffix", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
@@ -18,6 +16,7 @@ import (
|
||||
"github.com/acouvreur/sablier/app/http/routes/models"
|
||||
"github.com/acouvreur/sablier/app/instance"
|
||||
"github.com/acouvreur/sablier/app/sessions"
|
||||
"github.com/acouvreur/sablier/app/theme"
|
||||
"github.com/acouvreur/sablier/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gotest.tools/v3/assert"
|
||||
@@ -96,11 +95,16 @@ func TestServeStrategy_ServeDynamic(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
theme, err := theme.NewWithCustomThemes(fstest.MapFS{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s := &ServeStrategy{
|
||||
SessionsManager: &SessionsManagerMock{
|
||||
SessionState: tt.arg.session,
|
||||
},
|
||||
StrategyConfig: config.NewStrategyConfig(),
|
||||
Theme: theme,
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
c := GetTestGinContext(recorder)
|
||||
@@ -261,195 +265,3 @@ func createMap(instances []*instance.State) (store *sync.Map) {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func TestNewServeStrategy(t *testing.T) {
|
||||
type args struct {
|
||||
sessionsManager sessions.Manager
|
||||
strategyConf config.Strategy
|
||||
sessionsConf config.Sessions
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
osDirFS fs.FS
|
||||
want map[string]bool
|
||||
}{
|
||||
{
|
||||
name: "load custom themes",
|
||||
args: args{
|
||||
sessionsManager: &SessionsManagerMock{},
|
||||
strategyConf: config.Strategy{
|
||||
Dynamic: config.DynamicStrategy{
|
||||
CustomThemesPath: "my/path/to/themes",
|
||||
},
|
||||
},
|
||||
},
|
||||
osDirFS: fstest.MapFS{
|
||||
"my/path/to/themes/marvel.html": {Data: []byte("thor")},
|
||||
"my/path/to/themes/dc-comics.html": {Data: []byte("batman")},
|
||||
},
|
||||
want: map[string]bool{
|
||||
"marvel": true,
|
||||
"dc-comics": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "load custom themes recursively",
|
||||
args: args{
|
||||
sessionsManager: &SessionsManagerMock{},
|
||||
strategyConf: config.Strategy{
|
||||
Dynamic: config.DynamicStrategy{
|
||||
CustomThemesPath: "my/path/to/themes",
|
||||
},
|
||||
},
|
||||
},
|
||||
osDirFS: fstest.MapFS{
|
||||
"my/path/to/themes/marvel.html": {Data: []byte("thor")},
|
||||
"my/path/to/themes/dc-comics.html": {Data: []byte("batman")},
|
||||
"my/path/to/themes/inner/dc-comics.html": {Data: []byte("batman")},
|
||||
},
|
||||
want: map[string]bool{
|
||||
"marvel": true,
|
||||
"dc-comics": true,
|
||||
"inner/dc-comics": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "do not load custom themes outside of path",
|
||||
args: args{
|
||||
sessionsManager: &SessionsManagerMock{},
|
||||
strategyConf: config.Strategy{
|
||||
Dynamic: config.DynamicStrategy{
|
||||
CustomThemesPath: "my/path/to/themes",
|
||||
},
|
||||
},
|
||||
},
|
||||
osDirFS: fstest.MapFS{
|
||||
"my/path/to/superman.html": {Data: []byte("superman")},
|
||||
"my/path/to/themes/marvel.html": {Data: []byte("thor")},
|
||||
"my/path/to/themes/dc-comics.html": {Data: []byte("batman")},
|
||||
"my/path/to/themes/inner/dc-comics.html": {Data: []byte("batman")},
|
||||
},
|
||||
want: map[string]bool{
|
||||
"marvel": true,
|
||||
"dc-comics": true,
|
||||
"inner/dc-comics": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
oldosDirFS := osDirFS
|
||||
defer func() { osDirFS = oldosDirFS }()
|
||||
|
||||
myOsDirFS := func(dir string) fs.FS {
|
||||
fs, err := fs.Sub(tt.osDirFS, dir)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}
|
||||
|
||||
osDirFS = myOsDirFS
|
||||
|
||||
if got := NewServeStrategy(tt.args.sessionsManager, tt.args.strategyConf, tt.args.sessionsConf); !reflect.DeepEqual(got.customThemes, tt.want) {
|
||||
t.Errorf("NewServeStrategy() = %v, want %v", got.customThemes, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStrategy_ServeDynamicThemes(t *testing.T) {
|
||||
type fields struct {
|
||||
StrategyConfig config.Strategy
|
||||
SessionsConfig config.Sessions
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
osDirFS fs.FS
|
||||
expected map[string]any
|
||||
}{
|
||||
{
|
||||
name: "load custom themes",
|
||||
fields: fields{StrategyConfig: config.Strategy{
|
||||
Dynamic: config.DynamicStrategy{
|
||||
CustomThemesPath: "my/path/to/themes",
|
||||
},
|
||||
}},
|
||||
osDirFS: fstest.MapFS{
|
||||
"my/path/to/superman.html": {Data: []byte("superman")},
|
||||
"my/path/to/themes/marvel.html": {Data: []byte("thor")},
|
||||
"my/path/to/themes/dc-comics.html": {Data: []byte("batman")},
|
||||
"my/path/to/themes/inner/dc-comics.html": {Data: []byte("batman")},
|
||||
},
|
||||
expected: map[string]any{
|
||||
"custom": []any{
|
||||
"dc-comics",
|
||||
"inner/dc-comics",
|
||||
"marvel",
|
||||
},
|
||||
"embedded": []any{
|
||||
"ghost",
|
||||
"hacker-terminal",
|
||||
"matrix",
|
||||
"shuffle",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "load without custom themes",
|
||||
expected: map[string]any{
|
||||
"custom": []any{},
|
||||
"embedded": []any{
|
||||
"ghost",
|
||||
"hacker-terminal",
|
||||
"matrix",
|
||||
"shuffle",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
oldosDirFS := osDirFS
|
||||
defer func() { osDirFS = oldosDirFS }()
|
||||
|
||||
myOsDirFS := func(dir string) fs.FS {
|
||||
fs, err := fs.Sub(tt.osDirFS, dir)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}
|
||||
|
||||
osDirFS = myOsDirFS
|
||||
|
||||
s := NewServeStrategy(nil, tt.fields.StrategyConfig, tt.fields.SessionsConfig)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c := GetTestGinContext(recorder)
|
||||
|
||||
s.ServeDynamicThemes(c)
|
||||
|
||||
res := recorder.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
jsonRes := make(map[string]interface{})
|
||||
err := json.NewDecoder(res.Body).Decode(&jsonRes)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, jsonRes, tt.expected)
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
13
app/http/routes/theme_list.go
Normal file
13
app/http/routes/theme_list.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *ServeStrategy) ServeDynamicThemes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"themes": s.Theme.List(),
|
||||
})
|
||||
}
|
||||
@@ -13,11 +13,13 @@ import (
|
||||
"github.com/acouvreur/sablier/app/http/middleware"
|
||||
"github.com/acouvreur/sablier/app/http/routes"
|
||||
"github.com/acouvreur/sablier/app/sessions"
|
||||
"github.com/acouvreur/sablier/app/theme"
|
||||
"github.com/acouvreur/sablier/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Start(serverConf config.Server, strategyConf config.Strategy, sessionsConf config.Sessions, sessionManager sessions.Manager) {
|
||||
func Start(serverConf config.Server, strategyConf config.Strategy, sessionsConf config.Sessions, sessionManager sessions.Manager, t *theme.Themes) {
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -29,7 +31,7 @@ func Start(serverConf config.Server, strategyConf config.Strategy, sessionsConf
|
||||
{
|
||||
api := base.Group("/api")
|
||||
{
|
||||
strategy := routes.NewServeStrategy(sessionManager, strategyConf, sessionsConf)
|
||||
strategy := routes.NewServeStrategy(sessionManager, strategyConf, sessionsConf, t)
|
||||
api.GET("/strategies/dynamic", strategy.ServeDynamic)
|
||||
api.GET("/strategies/dynamic/themes", strategy.ServeDynamicThemes)
|
||||
api.GET("/strategies/blocking", strategy.ServeBlocking)
|
||||
|
||||
@@ -2,12 +2,14 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/acouvreur/sablier/app/http"
|
||||
"github.com/acouvreur/sablier/app/instance"
|
||||
"github.com/acouvreur/sablier/app/providers"
|
||||
"github.com/acouvreur/sablier/app/sessions"
|
||||
"github.com/acouvreur/sablier/app/storage"
|
||||
"github.com/acouvreur/sablier/app/theme"
|
||||
"github.com/acouvreur/sablier/config"
|
||||
"github.com/acouvreur/sablier/pkg/tinykv"
|
||||
"github.com/acouvreur/sablier/version"
|
||||
@@ -49,7 +51,24 @@ func Start(conf config.Config) error {
|
||||
loadSessions(storage, sessionsManager)
|
||||
}
|
||||
|
||||
http.Start(conf.Server, conf.Strategy, conf.Sessions, sessionsManager)
|
||||
var t *theme.Themes
|
||||
|
||||
if conf.Strategy.Dynamic.CustomThemesPath != "" {
|
||||
log.Tracef("loading themes with custom theme path: %s", conf.Strategy.Dynamic.CustomThemesPath)
|
||||
custom := os.DirFS(conf.Strategy.Dynamic.CustomThemesPath)
|
||||
t, err = theme.NewWithCustomThemes(custom)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Trace("loading themes without custom themes")
|
||||
t, err = theme.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
http.Start(conf.Server, conf.Strategy, conf.Sessions, sessionsManager, t)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
16
app/theme/list.go
Normal file
16
app/theme/list.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package theme
|
||||
|
||||
import "strings"
|
||||
|
||||
// List all the loaded themes
|
||||
func (t *Themes) List() []string {
|
||||
themes := make([]string, 0)
|
||||
|
||||
for _, template := range t.themes.Templates() {
|
||||
if strings.HasSuffix(template.Name(), ".html") {
|
||||
themes = append(themes, strings.TrimSuffix(template.Name(), ".html"))
|
||||
}
|
||||
}
|
||||
|
||||
return themes
|
||||
}
|
||||
25
app/theme/list_test.go
Normal file
25
app/theme/list_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package theme_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/acouvreur/sablier/app/theme"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
themes, err := theme.NewWithCustomThemes(
|
||||
fstest.MapFS{
|
||||
"theme1.html": &fstest.MapFile{},
|
||||
"inner/theme2.html": &fstest.MapFile{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
list := themes.List()
|
||||
|
||||
assert.ElementsMatch(t, []string{"theme1", "theme2", "ghost", "hacker-terminal", "matrix", "shuffle"}, list)
|
||||
}
|
||||
25
app/theme/parse.go
Normal file
25
app/theme/parse.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func ParseTemplatesFS(f fs.FS, t *template.Template) error {
|
||||
err := fs.WalkDir(f, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if strings.Contains(path, ".html") {
|
||||
log.Tracef("found template %s", path)
|
||||
_, err = t.ParseFS(f, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Tracef("successfully added template %s", path)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
34
app/theme/render.go
Normal file
34
app/theme/render.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/acouvreur/sablier/pkg/durations"
|
||||
"github.com/acouvreur/sablier/version"
|
||||
)
|
||||
|
||||
func (t *Themes) Render(name string, opts Options, writer io.Writer) error {
|
||||
var instances []Instance
|
||||
|
||||
if opts.ShowDetails {
|
||||
instances = opts.InstanceStates
|
||||
} else {
|
||||
instances = []Instance{}
|
||||
}
|
||||
|
||||
options := templateOptions{
|
||||
DisplayName: opts.DisplayName,
|
||||
InstanceStates: instances,
|
||||
SessionDuration: durations.Humanize(opts.SessionDuration),
|
||||
RefreshFrequency: fmt.Sprintf("%d", int64(opts.RefreshFrequency.Seconds())),
|
||||
Version: version.Version,
|
||||
}
|
||||
|
||||
tpl := t.themes.Lookup(fmt.Sprintf("%s.html", name))
|
||||
if tpl == nil {
|
||||
return fmt.Errorf("theme %s does not exist", name)
|
||||
}
|
||||
|
||||
return tpl.Execute(writer, options)
|
||||
}
|
||||
229
app/theme/render_test.go
Normal file
229
app/theme/render_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package theme_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/acouvreur/sablier/app/theme"
|
||||
"github.com/acouvreur/sablier/version"
|
||||
)
|
||||
|
||||
var (
|
||||
StartingInstanceInfo = theme.Instance{
|
||||
Name: "starting-instance",
|
||||
Status: "instance is starting...",
|
||||
Error: nil,
|
||||
CurrentReplicas: 0,
|
||||
DesiredReplicas: 1,
|
||||
}
|
||||
StartedInstanceInfo = theme.Instance{
|
||||
Name: "started-instance",
|
||||
Status: "instance is started.",
|
||||
Error: nil,
|
||||
CurrentReplicas: 1,
|
||||
DesiredReplicas: 1,
|
||||
}
|
||||
ErrorInstanceInfo = theme.Instance{
|
||||
Name: "error-instance",
|
||||
Error: fmt.Errorf("instance does not exist"),
|
||||
CurrentReplicas: 0,
|
||||
DesiredReplicas: 1,
|
||||
}
|
||||
)
|
||||
|
||||
func TestThemes_Render(t *testing.T) {
|
||||
const customTheme = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="{{ .RefreshFrequency }}" />
|
||||
</head>
|
||||
<body>
|
||||
Starting</span> {{ .DisplayName }}
|
||||
Your instance(s) will stop after {{ .SessionDuration }} of inactivity
|
||||
|
||||
<table>
|
||||
{{- range $i, $instance := .InstanceStates }}
|
||||
<tr>
|
||||
<td>{{ $instance.Name }}</td>
|
||||
{{- if $instance.Error }}
|
||||
<td>{{ $instance.Error }}</td>
|
||||
{{- else }}
|
||||
<td>{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</td>
|
||||
{{- end}}
|
||||
</tr>
|
||||
{{ end -}}
|
||||
</table>
|
||||
Sablier version {{ .Version }}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
version.Version = "1.0.0"
|
||||
themes, err := theme.NewWithCustomThemes(fstest.MapFS{
|
||||
"inner/custom-theme.html": &fstest.MapFile{Data: []byte(customTheme)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
instances := []theme.Instance{
|
||||
StartingInstanceInfo,
|
||||
StartedInstanceInfo,
|
||||
ErrorInstanceInfo,
|
||||
}
|
||||
options := theme.Options{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instances,
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
}
|
||||
type args struct {
|
||||
name string
|
||||
opts theme.Options
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Load ghost theme",
|
||||
args: args{
|
||||
name: "ghost",
|
||||
opts: options,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Load hacker-terminal theme",
|
||||
args: args{
|
||||
name: "hacker-terminal",
|
||||
opts: options,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Load matrix theme",
|
||||
args: args{
|
||||
name: "matrix",
|
||||
opts: options,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Load shuffle theme",
|
||||
args: args{
|
||||
name: "shuffle",
|
||||
opts: options,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Load non existent theme",
|
||||
args: args{
|
||||
name: "non-existent",
|
||||
opts: options,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Load custom theme",
|
||||
args: args{
|
||||
name: "custom-theme",
|
||||
opts: options,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
writer := &bytes.Buffer{}
|
||||
if err := themes.Render(tt.args.name, tt.args.opts, writer); (err != nil) != tt.wantErr {
|
||||
t.Errorf("Themes.Render() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleThemes_Render() {
|
||||
const customTheme = `
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="{{ .RefreshFrequency }}" />
|
||||
</head>
|
||||
<body>
|
||||
Starting {{ .DisplayName }}
|
||||
Your instances will stop after {{ .SessionDuration }} of inactivity
|
||||
<table>
|
||||
{{- range $i, $instance := .InstanceStates }}
|
||||
<tr>
|
||||
<td>{{ $instance.Name }}</td>
|
||||
{{- if $instance.Error }}
|
||||
<td>{{ $instance.Error }}</td>
|
||||
{{- else }}
|
||||
<td>{{ $instance.Status }} ({{ $instance.CurrentReplicas }}/{{ $instance.DesiredReplicas }})</td>
|
||||
{{- end}}
|
||||
</tr>
|
||||
{{- end }}
|
||||
</table>
|
||||
Sablier version {{ .Version }}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
version.Version = "1.0.0"
|
||||
themes, err := theme.NewWithCustomThemes(fstest.MapFS{
|
||||
"inner/custom-theme.html": &fstest.MapFile{Data: []byte(customTheme)},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
instances := []theme.Instance{
|
||||
StartingInstanceInfo,
|
||||
StartedInstanceInfo,
|
||||
ErrorInstanceInfo,
|
||||
}
|
||||
|
||||
err = themes.Render("custom-theme", theme.Options{
|
||||
DisplayName: "Test",
|
||||
InstanceStates: instances,
|
||||
ShowDetails: true,
|
||||
SessionDuration: 10 * time.Minute,
|
||||
RefreshFrequency: 5 * time.Second,
|
||||
}, os.Stdout)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Output:
|
||||
//<html lang="en">
|
||||
// <head>
|
||||
// <meta http-equiv="refresh" content="5" />
|
||||
// </head>
|
||||
// <body>
|
||||
// Starting Test
|
||||
// Your instances will stop after 10 minutes of inactivity
|
||||
// <table>
|
||||
// <tr>
|
||||
// <td>starting-instance</td>
|
||||
// <td>instance is starting... (0/1)</td>
|
||||
// </tr>
|
||||
// <tr>
|
||||
// <td>started-instance</td>
|
||||
// <td>instance is started. (1/1)</td>
|
||||
// </tr>
|
||||
// <tr>
|
||||
// <td>error-instance</td>
|
||||
// <td>instance does not exist</td>
|
||||
// </tr>
|
||||
// </table>
|
||||
// Sablier version 1.0.0
|
||||
// </body>
|
||||
//</html>
|
||||
}
|
||||
54
app/theme/theme.go
Normal file
54
app/theme/theme.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// List of built-it themes
|
||||
//
|
||||
//go:embed embedded/*.html
|
||||
var embeddedThemesFS embed.FS
|
||||
|
||||
type Themes struct {
|
||||
themes *template.Template
|
||||
}
|
||||
|
||||
func New() (*Themes, error) {
|
||||
themes := &Themes{
|
||||
themes: template.New("root"),
|
||||
}
|
||||
|
||||
err := ParseTemplatesFS(embeddedThemesFS, themes.themes)
|
||||
if err != nil {
|
||||
// Should never happen
|
||||
log.Errorf("could not parse embedded templates: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return themes, nil
|
||||
}
|
||||
|
||||
func NewWithCustomThemes(custom fs.FS) (*Themes, error) {
|
||||
themes := &Themes{
|
||||
themes: template.New("root"),
|
||||
}
|
||||
|
||||
err := ParseTemplatesFS(embeddedThemesFS, themes.themes)
|
||||
if err != nil {
|
||||
// Should never happen
|
||||
log.Errorf("could not parse embedded templates: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = ParseTemplatesFS(custom, themes.themes)
|
||||
if err != nil {
|
||||
log.Errorf("could not parse custom templates: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return themes, nil
|
||||
}
|
||||
36
app/theme/types.go
Normal file
36
app/theme/types.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package theme
|
||||
|
||||
import "time"
|
||||
|
||||
// Theme represents an available theme
|
||||
type Theme struct {
|
||||
Name string
|
||||
Embedded bool
|
||||
}
|
||||
|
||||
// Instance holds the current state about an instance
|
||||
type Instance struct {
|
||||
Name string
|
||||
Status string
|
||||
Error error
|
||||
CurrentReplicas int
|
||||
DesiredReplicas int
|
||||
}
|
||||
|
||||
// Options holds the customizable input to template
|
||||
type Options struct {
|
||||
DisplayName string
|
||||
ShowDetails bool
|
||||
InstanceStates []Instance
|
||||
SessionDuration time.Duration
|
||||
RefreshFrequency time.Duration
|
||||
}
|
||||
|
||||
// templateOptions holds the internal options used to template
|
||||
type templateOptions struct {
|
||||
DisplayName string
|
||||
InstanceStates []Instance
|
||||
SessionDuration string
|
||||
RefreshFrequency string
|
||||
Version string
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func Test_Dynamic(t *testing.T) {
|
||||
Status(http.StatusOK).
|
||||
Body().
|
||||
Contains(`Dynamic Whoami`).
|
||||
Contains(`Your instance(s) will stop after 1 minutes of inactivity`)
|
||||
Contains(`Your instance(s) will stop after 1 minute of inactivity`)
|
||||
|
||||
e.GET("/whoami").
|
||||
WithMaxRetries(10).
|
||||
@@ -62,7 +62,7 @@ func Test_Multiple(t *testing.T) {
|
||||
Status(http.StatusOK).
|
||||
Body().
|
||||
Contains(`Multiple Whoami`).
|
||||
Contains(`Your instance(s) will stop after 1 minutes of inactivity`)
|
||||
Contains(`Your instance(s) will stop after 1 minute of inactivity`)
|
||||
|
||||
e.GET("/whoami").
|
||||
WithMaxRetries(10).
|
||||
@@ -114,7 +114,7 @@ func Test_Healthy(t *testing.T) {
|
||||
Status(http.StatusOK).
|
||||
Body().
|
||||
Contains(`Healthy Nginx`).
|
||||
Contains(`Your instance(s) will stop after 1 minutes of inactivity`)
|
||||
Contains(`Your instance(s) will stop after 1 minute of inactivity`)
|
||||
|
||||
e.GET("/nginx").
|
||||
WithMaxRetries(10).
|
||||
|
||||
34
pkg/durations/duration.go
Normal file
34
pkg/durations/duration.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package durations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Duration struct {
|
||||
time.Duration
|
||||
}
|
||||
|
||||
func (duration *Duration) UnmarshalJSON(b []byte) error {
|
||||
var unmarshalledJson interface{}
|
||||
|
||||
err := json.Unmarshal(b, &unmarshalledJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch value := unmarshalledJson.(type) {
|
||||
case float64:
|
||||
duration.Duration = time.Duration(value)
|
||||
case string:
|
||||
duration.Duration, err = time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid duration: %#v", unmarshalledJson)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
40
pkg/durations/humanize.go
Normal file
40
pkg/durations/humanize.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package durations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Humanize(d time.Duration) string {
|
||||
days := int64(d.Hours() / 24)
|
||||
hours := int64(math.Mod(d.Hours(), 24))
|
||||
minutes := int64(math.Mod(d.Minutes(), 60))
|
||||
seconds := int64(math.Mod(d.Seconds(), 60))
|
||||
|
||||
chunks := []struct {
|
||||
singularName string
|
||||
amount int64
|
||||
}{
|
||||
{"day", days},
|
||||
{"hour", hours},
|
||||
{"minute", minutes},
|
||||
{"second", seconds},
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
for _, chunk := range chunks {
|
||||
switch chunk.amount {
|
||||
case 0:
|
||||
continue
|
||||
case 1:
|
||||
parts = append(parts, fmt.Sprintf("%d %s", chunk.amount, chunk.singularName))
|
||||
default:
|
||||
parts = append(parts, fmt.Sprintf("%d %ss", chunk.amount, chunk.singularName))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -23,9 +23,9 @@ destroy_kubernetes() {
|
||||
}
|
||||
|
||||
prepare_traefik() {
|
||||
helm repo add traefik https://helm.traefik.io/traefik
|
||||
helm repo add traefik https://traefik.github.io/charts
|
||||
helm repo update
|
||||
helm install traefik traefik/traefik -f values.yaml --namespace kube-system
|
||||
helm install traefik --version 27.0.2 traefik/traefik -f values.yaml --namespace kube-system
|
||||
}
|
||||
|
||||
prepare_deployment() {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
# traefik helm values
|
||||
image:
|
||||
tag: "2.9.1"
|
||||
|
||||
additionalArguments:
|
||||
- "--experimental.localPlugins.sablier.moduleName=github.com/acouvreur/sablier"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user