You've already forked watchtower
							
							
				mirror of
				https://github.com/containrrr/watchtower.git
				synced 2025-10-31 00:17:44 +02:00 
			
		
		
		
	feat(docs): add template preview (#1777)
This commit is contained in:
		
							
								
								
									
										6
									
								
								.github/workflows/publish-docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/publish-docs.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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: | ||||
|   | ||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -7,4 +7,7 @@ dist | ||||
| .DS_Store | ||||
| /site | ||||
| coverage.out | ||||
| *.coverprofile | ||||
| *.coverprofile | ||||
|  | ||||
| docs/assets/wasm_exec.js | ||||
| docs/assets/*.wasm | ||||
							
								
								
									
										219
									
								
								docs/template-preview.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								docs/template-preview.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,219 @@ | ||||
| <style> | ||||
|     #tplprev { | ||||
|         margin: 0; | ||||
|         display: flex;  | ||||
|         flex-direction: column;  | ||||
|         row-gap: 1rem;  | ||||
|         box-sizing: border-box;  | ||||
|         position: relative;  | ||||
|         margin-right: -13.3rem | ||||
|     } | ||||
|     #tplprev textarea { | ||||
|         box-decoration-break: slice; | ||||
|         overflow: auto; | ||||
|         padding: 0.77em 1.18em; | ||||
|         scrollbar-color: var(--md-default-fg-color--lighter) transparent; | ||||
|         scrollbar-width: thin; | ||||
|         touch-action: auto; | ||||
|         word-break: normal; | ||||
|         height: 420px; | ||||
|         flex: 1; | ||||
|     } | ||||
|     #tplprev .controls { | ||||
|         display: flex;  | ||||
|         flex-direction: row;  | ||||
|         column-gap: 0.5rem | ||||
|     } | ||||
|     #tplprev textarea, #tplprev input { | ||||
|         background-color: var(--md-code-bg-color); | ||||
|         border-width: 0; | ||||
|         border-radius: 0.1rem; | ||||
|         color: var(--md-code-fg-color); | ||||
|         font-feature-settings: "kern"; | ||||
|         font-family: var(--md-code-font-family); | ||||
|     } | ||||
|     .numfield { | ||||
|         font-size: .7rem; | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         justify-content: space-between; | ||||
|     } | ||||
|     #tplprev button { | ||||
|         border-radius: 0.1rem; | ||||
|         color: var(--md-typeset-color); | ||||
|         background-color: var(--md-primary-fg-color); | ||||
|         flex:1;  | ||||
|         min-width: 12ch;  | ||||
|         padding: 0.5rem | ||||
|     } | ||||
|     #tplprev button:hover { | ||||
|         background-color: var(--md-accent-fg-color); | ||||
|     } | ||||
|     #tplprev input[type="number"] { width: 5ch; flex: 1; font-size: 1rem; } | ||||
|     #tplprev fieldset { | ||||
|         margin-top: -0.5rem; | ||||
|         display: flex; | ||||
|         flex: 1; | ||||
|         column-gap: 0.5rem; | ||||
|     } | ||||
|     #tplprev .template-wrapper { | ||||
|         display: flex;  | ||||
|         flex:1;  | ||||
|         column-gap: 1rem; | ||||
|     } | ||||
|     #tplprev .result-wrapper { | ||||
|         flex: 1;  | ||||
|         display: flex | ||||
|     } | ||||
|     #result { | ||||
|         font-size: 0.7rem; | ||||
|         background-color: var(--md-code-bg-color); | ||||
|         scrollbar-color: var(--md-default-fg-color--lighter) transparent; | ||||
|         scrollbar-width: thin; | ||||
|         touch-action: auto; | ||||
|         overflow: auto; | ||||
|         padding: 0.77em 1.18em; | ||||
|         margin:0; | ||||
|         height: 540px; | ||||
|         flex:1;  | ||||
|         width:100% | ||||
|     } | ||||
|     #tplprev .loading { | ||||
|         position: absolute;  | ||||
|         inset: 0;  | ||||
|         display: flex;  | ||||
|         padding: 1rem;  | ||||
|         box-sizing: border-box;  | ||||
|         background: var(--md-code-bg-color);  | ||||
|         margin-top: 0 | ||||
|     } | ||||
| </style> | ||||
| <script src="../assets/wasm_exec.js"></script> | ||||
| <script> | ||||
|     const updatePreview = () => { | ||||
|         const form = document.querySelector('#tplprev'); | ||||
|         const input = form.template.value; | ||||
|         console.log('Input: %o', input); | ||||
|         const arrFromCount = (key) => Array.from(Array(form[key]?.valueAsNumber ?? 0), () => key); | ||||
|         const states = form.enablereport.checked ? [ | ||||
|             ...arrFromCount("skipped"), | ||||
|             ...arrFromCount("scanned"), | ||||
|             ...arrFromCount("updated"), | ||||
|             ...arrFromCount("failed" ), | ||||
|             ...arrFromCount("fresh"  ), | ||||
|             ...arrFromCount("stale"  ), | ||||
|         ] : []; | ||||
|         console.log("States: %o", states); | ||||
|         const levels = form.enablelog.checked ? [ | ||||
|             ...arrFromCount("error"), | ||||
|             ...arrFromCount("warning"), | ||||
|             ...arrFromCount("info"), | ||||
|             ...arrFromCount("debug"), | ||||
|         ] : []; | ||||
|         console.log("Levels: %o", levels); | ||||
|         const output = WATCHTOWER.tplprev(input, states, levels); | ||||
|         console.log('Output: \n%o', output); | ||||
|         if (output.length) { | ||||
|             document.querySelector('#result').innerText = output; | ||||
|         } else { | ||||
|             document.querySelector('#result').innerHTML = '<i>empty (would not be sent as a notification)</i>'; | ||||
|         } | ||||
|     } | ||||
|     const formSubmitted = (e) => { | ||||
|         e.preventDefault(); | ||||
|         updatePreview(); | ||||
|     } | ||||
|     let debounce; | ||||
|     const inputUpdated = () => { | ||||
|         if(debounce) clearTimeout(debounce); | ||||
|         debounce = setTimeout(() => updatePreview(), 400); | ||||
|     } | ||||
|     const formChanged = (e) =>  { | ||||
|         console.log('form changed: %o', e); | ||||
|     } | ||||
|     const go = new Go(); | ||||
|     WebAssembly.instantiateStreaming(fetch("../assets/tplprev.wasm"), go.importObject).then((result) => { | ||||
|         document.querySelector('#tplprev .loading').style.display = "none"; | ||||
|         go.run(result.instance); | ||||
|         updatePreview(); | ||||
|     }); | ||||
| </script> | ||||
| <form id="tplprev" onchange="updatePreview()" onsubmit="formSubmitted(event)"> | ||||
| <pre class="loading">loading wasm...</pre> | ||||
| <div class="template-wrapper"> | ||||
| <textarea name="template" type="text" style="flex: 1" onkeyup="inputUpdated()">{{- with .Report -}} | ||||
|   {{- if ( or .Updated .Failed ) -}} | ||||
| {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed | ||||
|     {{- range .Updated}} | ||||
| - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}} | ||||
|     {{- end -}} | ||||
|     {{- range .Fresh}} | ||||
| - {{.Name}} ({{.ImageName}}): {{.State}} | ||||
|     {{- end -}} | ||||
|     {{- range .Skipped}} | ||||
| - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} | ||||
|     {{- end -}} | ||||
|     {{- range .Failed}} | ||||
| - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}} | ||||
|       {{- end -}} | ||||
|   {{- end -}} | ||||
| {{- end -}} | ||||
| {{- if (and .Entries .Report) }} | ||||
|  | ||||
| Logs: | ||||
| {{ end -}} | ||||
| {{range .Entries -}}{{.Time.Format "2006-01-02T15:04:05Z07:00"}} [{{.Level}}] {{.Message}}{{"\n"}}{{- end -}}</textarea> | ||||
| </div> | ||||
| <div class="controls"> | ||||
| <fieldset> | ||||
|     <legend><label><input type="checkbox" name="enablereport" checked /> Container report</label></legend> | ||||
|     <label class="numfield"> | ||||
|         Skipped: | ||||
|         <input type="number" name="skipped" value="3" /> | ||||
|     </label> | ||||
|     <label class="numfield"> | ||||
|         Scanned: | ||||
|         <input type="number" name="scanned" value="3" /> | ||||
|     </label> | ||||
|     <label class="numfield"> | ||||
|         Updated: | ||||
|         <input type="number" name="updated" value="3" /> | ||||
|     </label> | ||||
|     <label class="numfield"> | ||||
|         Failed: | ||||
|         <input type="number" name="failed" value="3" /> | ||||
|     </label> | ||||
|     <label class="numfield"> | ||||
|         Fresh: | ||||
|         <input type="number" name="fresh" value="3" /> | ||||
|     </label> | ||||
|     <label class="numfield"> | ||||
|         Stale: | ||||
|         <input type="number" name="stale" value="3" /> | ||||
|     </label> | ||||
| </fieldset> | ||||
| <fieldset> | ||||
|     <legend><label><input type="checkbox" name="enablelog" checked /> Log entries</label></legend> | ||||
|     <label class="numfield"> | ||||
|         Error:  | ||||
|         <input type="number" name="error" value="1" /> | ||||
|     </label> | ||||
|     <label class="numfield"> | ||||
|         Warning: | ||||
|         <input type="number" name="warning" value="2" /> | ||||
|     </label> | ||||
|     <label class="numfield"> | ||||
|         Info: | ||||
|         <input type="number" name="info" value="3" /> | ||||
|     </label> | ||||
|     <label class="numfield"> | ||||
|         Debug: | ||||
|         <input type="number" name="debug" value="4" /> | ||||
|     </label> | ||||
| </fieldset> | ||||
| <button type="submit">Update preview</button> | ||||
| </div> | ||||
| <div style="result-wrapper"> | ||||
|     <pre id="result"></pre> | ||||
| </div> | ||||
| </form> | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
							
								
								
									
										143
									
								
								pkg/notifications/preview/data/data.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								pkg/notifications/preview/data/data.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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" | ||||
| } | ||||
							
								
								
									
										56
									
								
								pkg/notifications/preview/data/logs.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								pkg/notifications/preview/data/logs.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										178
									
								
								pkg/notifications/preview/data/preview_strings.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								pkg/notifications/preview/data/preview_strings.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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.", | ||||
| } | ||||
							
								
								
									
										110
									
								
								pkg/notifications/preview/data/report.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								pkg/notifications/preview/data/report.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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] } | ||||
							
								
								
									
										44
									
								
								pkg/notifications/preview/data/status.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								pkg/notifications/preview/data/status.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										36
									
								
								pkg/notifications/preview/tplprev.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								pkg/notifications/preview/tplprev.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
| @@ -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`) | ||||
|   | ||||
							
								
								
									
										27
									
								
								pkg/notifications/templates/funcs.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								pkg/notifications/templates/funcs.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										7
									
								
								scripts/build-tplprev.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										7
									
								
								scripts/build-tplprev.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -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 | ||||
							
								
								
									
										49
									
								
								tplprev/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								tplprev/main.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										62
									
								
								tplprev/main_wasm.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								tplprev/main_wasm.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
		Reference in New Issue
	
	Block a user