diff --git a/app/http/pages/render.go b/app/http/pages/render.go new file mode 100644 index 0000000..fa69505 --- /dev/null +++ b/app/http/pages/render.go @@ -0,0 +1,100 @@ +package pages + +import ( + "io" + "io/fs" + + "fmt" + "html/template" + "math" + "time" + + "embed" +) + +//go:embed themes/* +var themes embed.FS + +type RenderOptionsRequestState struct { + Name string + CurrentReplicas int + DesiredReplicas int + Status string + Error error +} + +type RenderOptions struct { + DisplayName string + RequestStates []RenderOptionsRequestState + SessionDuration time.Duration + RefreshFrequency time.Duration + Theme string + CustomThemes fs.FS + Version string +} + +type TemplateValues struct { + DisplayName string + RequestStates []RenderOptionsRequestState + SessionDuration string + RefreshFrequency time.Duration + 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 { + tpl, err = template.ParseFS(options.CustomThemes, options.Theme) + } else { + // Load selected theme + tpl, err = template.ParseFS(themes, fmt.Sprintf("themes/%s.html", options.Theme)) + } + + if err != nil { + return err + } + + return tpl.Execute(writer, TemplateValues{ + DisplayName: options.DisplayName, + RequestStates: options.RequestStates, + SessionDuration: humanizeDuration(options.SessionDuration), + RefreshFrequency: options.RefreshFrequency, + 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)) +} diff --git a/app/http/pages/render_test.go b/app/http/pages/render_test.go new file mode 100644 index 0000000..c131ea3 --- /dev/null +++ b/app/http/pages/render_test.go @@ -0,0 +1,165 @@ +package pages + +import ( + "bytes" + "fmt" + "testing" + "testing/fstest" + "time" +) + +var requestsStates []RenderOptionsRequestState = []RenderOptionsRequestState{ + { + 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", + RequestStates: requestsStates, + 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", + RequestStates: requestsStates, + 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", + RequestStates: requestsStates, + 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", + RequestStates: requestsStates, + Theme: "shuffle", + SessionDuration: 10 * time.Minute, + RefreshFrequency: 5 * time.Second, + CustomThemes: nil, + Version: "v0.0.0", + }, + }, + wantErr: false, + }, + { + name: "Load non existant theme", + args: args{ + options: RenderOptions{ + DisplayName: "Test", + RequestStates: requestsStates, + Theme: "nonexistant", + 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", + RequestStates: requestsStates, + Theme: "dc-comics.html", + SessionDuration: 10 * time.Minute, + RefreshFrequency: 5 * time.Second, + CustomThemes: fstest.MapFS{ + "marvel.html": {Data: []byte("{{ .DisplayName }}")}, + "dc-comics.html": {Data: []byte("batman")}, + }, + Version: "v0.0.0", + }, + }, + wantErr: false, + }, + { + name: "Load non existant custom theme", + args: args{ + options: RenderOptions{ + DisplayName: "Test", + RequestStates: requestsStates, + Theme: "nonexistant", + SessionDuration: 10 * time.Minute, + RefreshFrequency: 5 * time.Second, + CustomThemes: fstest.MapFS{ + "marvel.html": {Data: []byte("thor")}, + "dc-comics.html": {Data: []byte("batman")}, + }, + 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 + } + }) + } +} diff --git a/app/http/pages/themes/ghost.html b/app/http/pages/themes/ghost.html new file mode 100644 index 0000000..96e21c3 --- /dev/null +++ b/app/http/pages/themes/ghost.html @@ -0,0 +1,71 @@ + + +
+ + ++ +
+Your instance(s) will stop after {{ .SessionDuration }} of inactivity}
+| {{ $request.Name }} | + {{- if $request.Error }} +{{ $request.Error }} | + {{- else }} +{{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }}) | + {{- end}} +
Your instance(s) will stop after {{ .SessionDuration }} of inactivity.
+ {{ range $i, $request := .RequestStates }} +sablier status {{ $request.Name }}
+ {{ if $request.Error }}An error occured: {{ $request.Error }}
{{ $request.Name }} is {{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }})
| {{ $request.Name }} | + {{- if $request.Error }} +{{ $request.Error }} | + {{- else }} +{{ $request.Status }} ({{ $request.CurrentReplicas }}/{{ $request.DesiredReplicas }}) | + {{- end}} +