From 9180e9558e33600aed840b8ac9078d3f369ca1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Mon, 2 Oct 2023 16:11:04 +0200 Subject: [PATCH] feat(docs): add template preview (#1777) --- .github/workflows/publish-docs.yml | 6 + .gitignore | 5 +- docs/template-preview.md | 219 ++++++++++++++++++ pkg/notifications/json.go | 10 - pkg/notifications/preview/data/data.go | 143 ++++++++++++ pkg/notifications/preview/data/logs.go | 56 +++++ .../preview/data/preview_strings.go | 178 ++++++++++++++ pkg/notifications/preview/data/report.go | 110 +++++++++ pkg/notifications/preview/data/status.go | 44 ++++ pkg/notifications/preview/tplprev.go | 36 +++ pkg/notifications/shoutrrr.go | 12 +- pkg/notifications/templates/funcs.go | 27 +++ scripts/build-tplprev.sh | 7 + tplprev/main.go | 49 ++++ tplprev/main_wasm.go | 62 +++++ 15 files changed, 944 insertions(+), 20 deletions(-) create mode 100644 docs/template-preview.md create mode 100644 pkg/notifications/preview/data/data.go create mode 100644 pkg/notifications/preview/data/logs.go create mode 100644 pkg/notifications/preview/data/preview_strings.go create mode 100644 pkg/notifications/preview/data/report.go create mode 100644 pkg/notifications/preview/data/status.go create mode 100644 pkg/notifications/preview/tplprev.go create mode 100644 pkg/notifications/templates/funcs.go create mode 100755 scripts/build-tplprev.sh create mode 100644 tplprev/main.go create mode 100644 tplprev/main_wasm.go diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 95ea170..9204541 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -17,6 +17,12 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.18.x + - name: Build tplprev + run: scripts/build-tplprev.sh - name: Setup python uses: actions/setup-python@v4 with: diff --git a/.gitignore b/.gitignore index c371f41..9519257 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ dist .DS_Store /site coverage.out -*.coverprofile \ No newline at end of file +*.coverprofile + +docs/assets/wasm_exec.js +docs/assets/*.wasm \ No newline at end of file diff --git a/docs/template-preview.md b/docs/template-preview.md new file mode 100644 index 0000000..3ae4321 --- /dev/null +++ b/docs/template-preview.md @@ -0,0 +1,219 @@ + + + +
+
loading wasm...
+
+ +
+
+
+ + + + + + + +
+
+ + + + + +
+ +
+
+

+
+
\ No newline at end of file diff --git a/pkg/notifications/json.go b/pkg/notifications/json.go index 1bd304a..20da92b 100644 --- a/pkg/notifications/json.go +++ b/pkg/notifications/json.go @@ -59,13 +59,3 @@ func marshalReports(reports []t.ContainerReport) []jsonMap { } var _ json.Marshaler = &Data{} - -func toJSON(v interface{}) string { - var bytes []byte - var err error - if bytes, err = json.MarshalIndent(v, "", " "); err != nil { - LocalLog.Errorf("failed to marshal JSON in notification template: %v", err) - return "" - } - return string(bytes) -} diff --git a/pkg/notifications/preview/data/data.go b/pkg/notifications/preview/data/data.go new file mode 100644 index 0000000..4a002ed --- /dev/null +++ b/pkg/notifications/preview/data/data.go @@ -0,0 +1,143 @@ +package data + +import ( + "encoding/hex" + "errors" + "math/rand" + "strconv" + "time" + + "github.com/containrrr/watchtower/pkg/types" +) + +type previewData struct { + rand *rand.Rand + lastTime time.Time + report *report + containerCount int + Entries []*logEntry + StaticData staticData +} + +type staticData struct { + Title string + Host string +} + +// New initializes a new preview data struct +func New() *previewData { + return &previewData{ + rand: rand.New(rand.NewSource(1)), + lastTime: time.Now().Add(-30 * time.Minute), + report: nil, + containerCount: 0, + Entries: []*logEntry{}, + StaticData: staticData{ + Title: "Title", + Host: "Host", + }, + } +} + +// AddFromState adds a container status entry to the report with the given state +func (pb *previewData) AddFromState(state State) { + cid := types.ContainerID(pb.generateID()) + old := types.ImageID(pb.generateID()) + new := types.ImageID(pb.generateID()) + name := pb.generateName() + image := pb.generateImageName(name) + var err error + if state == FailedState { + err = errors.New(pb.randomEntry(errorMessages)) + } else if state == SkippedState { + err = errors.New(pb.randomEntry(skippedMessages)) + } + pb.addContainer(containerStatus{ + containerID: cid, + oldImage: old, + newImage: new, + containerName: name, + imageName: image, + error: err, + state: state, + }) +} + +func (pb *previewData) addContainer(c containerStatus) { + if pb.report == nil { + pb.report = &report{} + } + switch c.state { + case ScannedState: + pb.report.scanned = append(pb.report.scanned, &c) + case UpdatedState: + pb.report.updated = append(pb.report.updated, &c) + case FailedState: + pb.report.failed = append(pb.report.failed, &c) + case SkippedState: + pb.report.skipped = append(pb.report.skipped, &c) + case StaleState: + pb.report.stale = append(pb.report.stale, &c) + case FreshState: + pb.report.fresh = append(pb.report.fresh, &c) + default: + return + } + pb.containerCount += 1 +} + +// AddLogEntry adds a preview log entry of the given level +func (pd *previewData) AddLogEntry(level LogLevel) { + var msg string + switch level { + case FatalLevel: + fallthrough + case ErrorLevel: + fallthrough + case WarnLevel: + msg = pd.randomEntry(logErrors) + default: + msg = pd.randomEntry(logMessages) + } + pd.Entries = append(pd.Entries, &logEntry{ + Message: msg, + Data: map[string]any{}, + Time: pd.generateTime(), + Level: level, + }) +} + +// Report returns a preview report +func (pb *previewData) Report() types.Report { + return pb.report +} + +func (pb *previewData) generateID() string { + buf := make([]byte, 32) + _, _ = pb.rand.Read(buf) + return hex.EncodeToString(buf) +} + +func (pb *previewData) generateTime() time.Time { + pb.lastTime = pb.lastTime.Add(time.Duration(pb.rand.Intn(30)) * time.Second) + return pb.lastTime +} + +func (pb *previewData) randomEntry(arr []string) string { + return arr[pb.rand.Intn(len(arr))] +} + +func (pb *previewData) generateName() string { + index := pb.containerCount + if index <= len(containerNames) { + return "/" + containerNames[index] + } + suffix := index / len(containerNames) + index %= len(containerNames) + return "/" + containerNames[index] + strconv.FormatInt(int64(suffix), 10) +} + +func (pb *previewData) generateImageName(name string) string { + index := pb.containerCount % len(organizationNames) + return organizationNames[index] + name + ":latest" +} diff --git a/pkg/notifications/preview/data/logs.go b/pkg/notifications/preview/data/logs.go new file mode 100644 index 0000000..3ca7710 --- /dev/null +++ b/pkg/notifications/preview/data/logs.go @@ -0,0 +1,56 @@ +package data + +import ( + "time" +) + +type logEntry struct { + Message string + Data map[string]any + Time time.Time + Level LogLevel +} + +// LogLevel is the analog of logrus.Level +type LogLevel string + +const ( + TraceLevel LogLevel = "trace" + DebugLevel LogLevel = "debug" + InfoLevel LogLevel = "info" + WarnLevel LogLevel = "warning" + ErrorLevel LogLevel = "error" + FatalLevel LogLevel = "fatal" + PanicLevel LogLevel = "panic" +) + +// LevelsFromString parses a string of level characters and returns a slice of the corresponding log levels +func LevelsFromString(str string) []LogLevel { + levels := make([]LogLevel, 0, len(str)) + for _, c := range str { + switch c { + case 'p': + levels = append(levels, PanicLevel) + case 'f': + levels = append(levels, FatalLevel) + case 'e': + levels = append(levels, ErrorLevel) + case 'w': + levels = append(levels, WarnLevel) + case 'i': + levels = append(levels, InfoLevel) + case 'd': + levels = append(levels, DebugLevel) + case 't': + levels = append(levels, TraceLevel) + default: + continue + } + } + return levels +} + +// String returns the log level as a string +func (level LogLevel) String() string { + return string(level) +} diff --git a/pkg/notifications/preview/data/preview_strings.go b/pkg/notifications/preview/data/preview_strings.go new file mode 100644 index 0000000..9212a71 --- /dev/null +++ b/pkg/notifications/preview/data/preview_strings.go @@ -0,0 +1,178 @@ +package data + +var containerNames = []string{ + "cyberscribe", + "datamatrix", + "nexasync", + "quantumquill", + "aerosphere", + "virtuos", + "fusionflow", + "neuralink", + "pixelpulse", + "synthwave", + "codecraft", + "zapzone", + "robologic", + "dreamstream", + "infinisync", + "megamesh", + "novalink", + "xenogenius", + "ecosim", + "innovault", + "techtracer", + "fusionforge", + "quantumquest", + "neuronest", + "codefusion", + "datadyno", + "pixelpioneer", + "vortexvision", + "cybercraft", + "synthsphere", + "infinitescript", + "roborhythm", + "dreamengine", + "aquasync", + "geniusgrid", + "megamind", + "novasync-pro", + "xenonwave", + "ecologic", + "innoscan", +} + +var organizationNames = []string{ + "techwave", + "codecrafters", + "innotechlabs", + "fusionsoft", + "cyberpulse", + "quantumscribe", + "datadynamo", + "neuralink", + "pixelpro", + "synthwizards", + "virtucorplabs", + "robologic", + "dreamstream", + "novanest", + "megamind", + "xenonwave", + "ecologic", + "innosync", + "techgenius", + "nexasoft", + "codewave", + "zapzone", + "techsphere", + "aquatech", + "quantumcraft", + "neuronest", + "datafusion", + "pixelpioneer", + "synthsphere", + "infinitescribe", + "roborhythm", + "dreamengine", + "vortexvision", + "geniusgrid", + "megamesh", + "novasync", + "xenogeniuslabs", + "ecosim", + "innovault", +} + +var errorMessages = []string{ + "Error 404: Resource not found", + "Critical Error: System meltdown imminent", + "Error 500: Internal server error", + "Invalid input: Please check your data", + "Access denied: Unauthorized access detected", + "Network connection lost: Please check your connection", + "Error 403: Forbidden access", + "Fatal error: System crash imminent", + "File not found: Check the file path", + "Invalid credentials: Authentication failed", + "Error 502: Bad Gateway", + "Database connection failed: Please try again later", + "Security breach detected: Take immediate action", + "Error 400: Bad request", + "Out of memory: Close unnecessary applications", + "Invalid configuration: Check your settings", + "Error 503: Service unavailable", + "File is read-only: Cannot modify", + "Data corruption detected: Backup your data", + "Error 401: Unauthorized", + "Disk space full: Free up disk space", + "Connection timeout: Retry your request", + "Error 504: Gateway timeout", + "File access denied: Permission denied", + "Unexpected error: Please contact support", + "Error 429: Too many requests", + "Invalid URL: Check the URL format", + "Database query failed: Try again later", + "Error 408: Request timeout", + "File is in use: Close the file and try again", + "Invalid parameter: Check your input", + "Error 502: Proxy error", + "Database connection lost: Reconnect and try again", + "File size exceeds limit: Reduce the file size", + "Error 503: Overloaded server", + "Operation aborted: Try again", + "Invalid API key: Check your API key", + "Error 507: Insufficient storage", + "Database deadlock: Retry your transaction", + "Error 405: Method not allowed", + "File format not supported: Choose a different format", + "Unknown error: Contact system administrator", +} + +var skippedMessages = []string{ + "Fear of introducing new bugs", + "Don't have time for the update process", + "Current version works fine for my needs", + "Concerns about compatibility with other software", + "Limited bandwidth for downloading updates", + "Worries about losing custom settings or configurations", + "Lack of trust in the software developer's updates", + "Dislike changes to the user interface", + "Avoiding potential subscription fees", + "Suspicion of hidden data collection in updates", + "Apprehension about changes in privacy policies", + "Prefer the older version's features or design", + "Worry about software becoming more resource-intensive", + "Avoiding potential changes in licensing terms", + "Waiting for initial bugs to be resolved in the update", + "Concerns about update breaking third-party plugins or extensions", + "Belief that the software is already secure enough", + "Don't want to relearn how to use the software", + "Fear of losing access to older file formats", + "Avoiding the hassle of having to update multiple devices", +} + +var logMessages = []string{ + "Checking for available updates...", + "Downloading update package...", + "Verifying update integrity...", + "Preparing to install update...", + "Backing up existing configuration...", + "Installing update...", + "Update installation complete.", + "Applying configuration settings...", + "Cleaning up temporary files...", + "Update successful! Software is now up-to-date.", + "Restarting the application...", + "Restart complete. Enjoy the latest features!", + "Update rollback complete. Your software remains at the previous version.", +} + +var logErrors = []string{ + "Unable to check for updates. Please check your internet connection.", + "Update package download failed. Try again later.", + "Update verification failed. Please contact support.", + "Update installation failed. Rolling back to the previous version...", + "Your configuration settings may have been reset to defaults.", +} diff --git a/pkg/notifications/preview/data/report.go b/pkg/notifications/preview/data/report.go new file mode 100644 index 0000000..2c8627f --- /dev/null +++ b/pkg/notifications/preview/data/report.go @@ -0,0 +1,110 @@ +package data + +import ( + "sort" + + "github.com/containrrr/watchtower/pkg/types" +) + +// State is the outcome of a container in a session report +type State string + +const ( + ScannedState State = "scanned" + UpdatedState State = "updated" + FailedState State = "failed" + SkippedState State = "skipped" + StaleState State = "stale" + FreshState State = "fresh" +) + +// StatesFromString parses a string of state characters and returns a slice of the corresponding report states +func StatesFromString(str string) []State { + states := make([]State, 0, len(str)) + for _, c := range str { + switch c { + case 'c': + states = append(states, ScannedState) + case 'u': + states = append(states, UpdatedState) + case 'e': + states = append(states, FailedState) + case 'k': + states = append(states, SkippedState) + case 't': + states = append(states, StaleState) + case 'f': + states = append(states, FreshState) + default: + continue + } + } + return states +} + +type report struct { + scanned []types.ContainerReport + updated []types.ContainerReport + failed []types.ContainerReport + skipped []types.ContainerReport + stale []types.ContainerReport + fresh []types.ContainerReport +} + +func (r *report) Scanned() []types.ContainerReport { + return r.scanned +} +func (r *report) Updated() []types.ContainerReport { + return r.updated +} +func (r *report) Failed() []types.ContainerReport { + return r.failed +} +func (r *report) Skipped() []types.ContainerReport { + return r.skipped +} +func (r *report) Stale() []types.ContainerReport { + return r.stale +} +func (r *report) Fresh() []types.ContainerReport { + return r.fresh +} + +func (r *report) All() []types.ContainerReport { + allLen := len(r.scanned) + len(r.updated) + len(r.failed) + len(r.skipped) + len(r.stale) + len(r.fresh) + all := make([]types.ContainerReport, 0, allLen) + + presentIds := map[types.ContainerID][]string{} + + appendUnique := func(reports []types.ContainerReport) { + for _, cr := range reports { + if _, found := presentIds[cr.ID()]; found { + continue + } + all = append(all, cr) + presentIds[cr.ID()] = nil + } + } + + appendUnique(r.updated) + appendUnique(r.failed) + appendUnique(r.skipped) + appendUnique(r.stale) + appendUnique(r.fresh) + appendUnique(r.scanned) + + sort.Sort(sortableContainers(all)) + + return all +} + +type sortableContainers []types.ContainerReport + +// Len implements sort.Interface.Len +func (s sortableContainers) Len() int { return len(s) } + +// Less implements sort.Interface.Less +func (s sortableContainers) Less(i, j int) bool { return s[i].ID() < s[j].ID() } + +// Swap implements sort.Interface.Swap +func (s sortableContainers) Swap(i, j int) { s[i], s[j] = s[j], s[i] } diff --git a/pkg/notifications/preview/data/status.go b/pkg/notifications/preview/data/status.go new file mode 100644 index 0000000..33f9bec --- /dev/null +++ b/pkg/notifications/preview/data/status.go @@ -0,0 +1,44 @@ +package data + +import wt "github.com/containrrr/watchtower/pkg/types" + +type containerStatus struct { + containerID wt.ContainerID + oldImage wt.ImageID + newImage wt.ImageID + containerName string + imageName string + error + state State +} + +func (u *containerStatus) ID() wt.ContainerID { + return u.containerID +} + +func (u *containerStatus) Name() string { + return u.containerName +} + +func (u *containerStatus) CurrentImageID() wt.ImageID { + return u.oldImage +} + +func (u *containerStatus) LatestImageID() wt.ImageID { + return u.newImage +} + +func (u *containerStatus) ImageName() string { + return u.imageName +} + +func (u *containerStatus) Error() string { + if u.error == nil { + return "" + } + return u.error.Error() +} + +func (u *containerStatus) State() string { + return string(u.state) +} diff --git a/pkg/notifications/preview/tplprev.go b/pkg/notifications/preview/tplprev.go new file mode 100644 index 0000000..db324d8 --- /dev/null +++ b/pkg/notifications/preview/tplprev.go @@ -0,0 +1,36 @@ +package preview + +import ( + "fmt" + "strings" + "text/template" + + "github.com/containrrr/watchtower/pkg/notifications/preview/data" + "github.com/containrrr/watchtower/pkg/notifications/templates" +) + +func Render(input string, states []data.State, loglevels []data.LogLevel) (string, error) { + + data := data.New() + + tpl, err := template.New("").Funcs(templates.Funcs).Parse(input) + if err != nil { + return "", fmt.Errorf("failed to parse template: %e", err) + } + + for _, state := range states { + data.AddFromState(state) + } + + for _, level := range loglevels { + data.AddLogEntry(level) + } + + var buf strings.Builder + err = tpl.Execute(&buf, data) + if err != nil { + return "", fmt.Errorf("failed to execute template: %e", err) + } + + return buf.String(), nil +} diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go index c7e59e9..de59d62 100644 --- a/pkg/notifications/shoutrrr.go +++ b/pkg/notifications/shoutrrr.go @@ -10,10 +10,9 @@ import ( "github.com/containrrr/shoutrrr" "github.com/containrrr/shoutrrr/pkg/types" + "github.com/containrrr/watchtower/pkg/notifications/templates" t "github.com/containrrr/watchtower/pkg/types" log "github.com/sirupsen/logrus" - "golang.org/x/text/cases" - "golang.org/x/text/language" ) // LocalLog is a logrus logger that does not send entries as notifications @@ -208,13 +207,8 @@ func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error { } func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template, err error) { - funcs := template.FuncMap{ - "ToUpper": strings.ToUpper, - "ToLower": strings.ToLower, - "ToJSON": toJSON, - "Title": cases.Title(language.AmericanEnglish).String, - } - tplBase := template.New("").Funcs(funcs) + + tplBase := template.New("").Funcs(templates.Funcs) if builtin, found := commonTemplates[tplString]; found { log.WithField(`template`, tplString).Debug(`Using common template`) diff --git a/pkg/notifications/templates/funcs.go b/pkg/notifications/templates/funcs.go new file mode 100644 index 0000000..6958c1a --- /dev/null +++ b/pkg/notifications/templates/funcs.go @@ -0,0 +1,27 @@ +package templates + +import ( + "encoding/json" + "fmt" + "strings" + "text/template" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var Funcs = template.FuncMap{ + "ToUpper": strings.ToUpper, + "ToLower": strings.ToLower, + "ToJSON": toJSON, + "Title": cases.Title(language.AmericanEnglish).String, +} + +func toJSON(v interface{}) string { + var bytes []byte + var err error + if bytes, err = json.MarshalIndent(v, "", " "); err != nil { + return fmt.Sprintf("failed to marshal JSON in notification template: %v", err) + } + return string(bytes) +} diff --git a/scripts/build-tplprev.sh b/scripts/build-tplprev.sh new file mode 100755 index 0000000..293710c --- /dev/null +++ b/scripts/build-tplprev.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +cd $(git rev-parse --show-toplevel) + +cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./docs/assets/ + +GOARCH=wasm GOOS=js go build -o ./docs/assets/tplprev.wasm ./tplprev \ No newline at end of file diff --git a/tplprev/main.go b/tplprev/main.go new file mode 100644 index 0000000..120f968 --- /dev/null +++ b/tplprev/main.go @@ -0,0 +1,49 @@ +//go:build !wasm + +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/containrrr/watchtower/internal/meta" + "github.com/containrrr/watchtower/pkg/notifications/preview" + "github.com/containrrr/watchtower/pkg/notifications/preview/data" +) + +func main() { + fmt.Fprintf(os.Stderr, "watchtower/tplprev %v\n\n", meta.Version) + + var states string + var entries string + + flag.StringVar(&states, "states", "cccuuueeekkktttfff", "sCanned, Updated, failEd, sKipped, sTale, Fresh") + flag.StringVar(&entries, "entries", "ewwiiidddd", "Fatal,Error,Warn,Info,Debug,Trace") + + flag.Parse() + + if len(flag.Args()) < 1 { + fmt.Fprintln(os.Stderr, "Missing required argument TEMPLATE") + flag.Usage() + os.Exit(1) + return + } + + input, err := os.ReadFile(flag.Arg(0)) + if err != nil { + + fmt.Fprintf(os.Stderr, "Failed to read template file %q: %v\n", flag.Arg(0), err) + os.Exit(1) + return + } + + result, err := preview.Render(string(input), data.StatesFromString(states), data.LevelsFromString(entries)) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to read template file %q: %v\n", flag.Arg(0), err) + os.Exit(1) + return + } + + fmt.Println(result) +} diff --git a/tplprev/main_wasm.go b/tplprev/main_wasm.go new file mode 100644 index 0000000..5e2ce6a --- /dev/null +++ b/tplprev/main_wasm.go @@ -0,0 +1,62 @@ +//go:build wasm + +package main + +import ( + "fmt" + + "github.com/containrrr/watchtower/internal/meta" + "github.com/containrrr/watchtower/pkg/notifications/preview" + "github.com/containrrr/watchtower/pkg/notifications/preview/data" + + "syscall/js" +) + +func main() { + fmt.Println("watchtower/tplprev v" + meta.Version) + + js.Global().Set("WATCHTOWER", js.ValueOf(map[string]any{ + "tplprev": js.FuncOf(jsTplPrev), + })) + <-make(chan bool) + +} + +func jsTplPrev(this js.Value, args []js.Value) any { + + if len(args) < 3 { + return "Requires 3 arguments passed" + } + + input := args[0].String() + + statesArg := args[1] + var states []data.State + + if statesArg.Type() == js.TypeString { + states = data.StatesFromString(statesArg.String()) + } else { + for i := 0; i < statesArg.Length(); i++ { + state := data.State(statesArg.Index(i).String()) + states = append(states, state) + } + } + + levelsArg := args[2] + var levels []data.LogLevel + + if levelsArg.Type() == js.TypeString { + levels = data.LevelsFromString(statesArg.String()) + } else { + for i := 0; i < levelsArg.Length(); i++ { + level := data.LogLevel(levelsArg.Index(i).String()) + levels = append(levels, level) + } + } + + result, err := preview.Render(input, states, levels) + if err != nil { + return "Error: " + err.Error() + } + return result +}