| 1 | {{define "app_logs"}}{{template "top" .}} |
| 2 | {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| 3 | {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| 4 | {{template "appnav" .}} |
| 5 | {{template "lognav" .}} |
| 6 | |
| 7 | <h2 class="visually-hidden">Logs</h2> |
| 8 | {{if .LogsErr}}<p class="banner error">{{.LogsErr}}</p>{{end}} |
| 9 | <form method="get" class="small" id="logs-filter" data-url="{{.Base}}/apps/{{.AppName}}/logs/data" data-cursor="{{.LogCursor}}" data-follow-error="{{.LogCopy.FollowError}}" data-refreshed="{{.LogCopy.Refreshed}}"> |
| 10 | <label>{{.LogCopy.Instance}} |
| 11 | <select name="host"> |
| 12 | <option value="">{{.LogCopy.AllInstances}}</option> |
| 13 | {{range .LogHosts}}<option value="{{.}}"{{if eq . $.LogHost}} selected{{end}}>{{.}}</option>{{end}} |
| 14 | </select> |
| 15 | </label> |
| 16 | <label>Show |
| 17 | <select name="stream"> |
| 18 | <option value=""{{if eq .LogStream ""}} selected{{end}}>everything</option> |
| 19 | <option value="app"{{if eq .LogStream "app"}} selected{{end}}>app output</option> |
| 20 | <option value="platform"{{if eq .LogStream "platform"}} selected{{end}}>platform events</option> |
| 21 | </select> |
| 22 | </label> |
| 23 | <label>from the last |
| 24 | <select name="since"> |
| 25 | {{if and (ne .LogSince "15m") (ne .LogSince "1h") (ne .LogSince "6h") (ne .LogSince "24h") (ne .LogSince "168h")}}<option value="{{.LogSince}}" selected>{{.LogSince}}</option>{{end}} |
| 26 | <option value="15m"{{if eq .LogSince "15m"}} selected{{end}}>15 minutes</option> |
| 27 | <option value="1h"{{if eq .LogSince "1h"}} selected{{end}}>hour</option> |
| 28 | <option value="6h"{{if eq .LogSince "6h"}} selected{{end}}>6 hours</option> |
| 29 | <option value="24h"{{if eq .LogSince "24h"}} selected{{end}}>day</option> |
| 30 | <option value="168h"{{if eq .LogSince "168h"}} selected{{end}}>week</option> |
| 31 | </select> |
| 32 | </label> |
| 33 | <label>{{.LogCopy.AllLevels}} |
| 34 | <select name="level"> |
| 35 | <option value=""{{if eq .LogLevel ""}} selected{{end}}>{{.LogCopy.AllLevels}}</option> |
| 36 | <option value="debug"{{if eq .LogLevel "debug"}} selected{{end}}>debug</option> |
| 37 | <option value="info"{{if eq .LogLevel "info"}} selected{{end}}>info</option> |
| 38 | <option value="warn"{{if eq .LogLevel "warn"}} selected{{end}}>warn</option> |
| 39 | <option value="error"{{if eq .LogLevel "error"}} selected{{end}}>error</option> |
| 40 | </select> |
| 41 | </label> |
| 42 | <label>{{.LogCopy.Search}} |
| 43 | <input type="search" name="text" value="{{.LogGrep}}"> |
| 44 | </label> |
| 45 | {{if .LogLegacyGrep}}<input type="hidden" name="grep" value="{{.LogLegacyGrep}}">{{end}} |
| 46 | <button type="submit">Update</button> |
| 47 | <button type="button" id="log-follow">{{.LogCopy.Follow}}</button> |
| 48 | <button type="button" id="log-pause" hidden>{{.LogCopy.Pause}}</button> |
| 49 | </form> |
| 50 | |
| 51 | {{if .LogTruncated}}<p class="small">Showing the newest {{len .LogRows}} lines of this window — narrow the window, or use <code>carlos logs</code> for more.</p>{{end}} |
| 52 | <div class="term" id="logterm" role="log" aria-label="Log output" tabindex="0" aria-live="polite"> |
| 53 | {{- range logLines .LogRows -}} |
| 54 | <div class="tl{{if .Tone}} tl-{{.Tone}}{{end}}"><span class="tl-t">{{.Time}}</span> <span class="tl-s">{{.Stream}}</span>{{if .Host}} <span class="tl-h">{{.Host}}</span>{{end}} <span class="tl-m">{{.Msg}}</span>{{if .Fields}}<span class="tl-f">{{.Fields}}</span>{{end}}</div> |
| 55 | {{- end -}} |
| 56 | {{if and (not .LogRows) (not .LogsErr)}}<div id="log-empty">No log lines in this window. <a href="{{.LogWidenURL}}">{{.LogCopy.Widen}}</a></div>{{end}} |
| 57 | </div> |
| 58 | <p class="small" id="log-status" aria-live="polite"></p> |
| 59 | {{if .LogRows}} |
| 60 | <p class="small">Times are UTC. Follow live with <code>carlos logs --app {{.AppName}} -f</code>.</p> |
| 61 | {{else if not .LogsErr}} |
| 62 | <p class="small">An app's output appears here shortly after it runs; platform events |
| 63 | (wakes, restarts, failures) appear even when the app itself printed nothing. A |
| 64 | site served from files alone has no app output.</p> |
| 65 | {{end}} |
| 66 | <script> |
| 67 | (function () { |
| 68 | var form = document.getElementById("logs-filter"); |
| 69 | var term = document.getElementById("logterm"); |
| 70 | var follow = document.getElementById("log-follow"); |
| 71 | var pause = document.getElementById("log-pause"); |
| 72 | var status = document.getElementById("log-status"); |
| 73 | if (!form || !term || !follow || !pause || !status) return; |
| 74 | var cursor = form.dataset.cursor || ""; |
| 75 | var timer = 0; |
| 76 | var request = null; |
| 77 | var fetchTimeout = 0; |
| 78 | var timedOut = false; |
| 79 | var following = false; |
| 80 | var generation = 0; |
| 81 | var dirty = false; |
| 82 | var activeQuery = new URLSearchParams(new FormData(form)); |
| 83 | |
| 84 | function setFollowing(on) { |
| 85 | var moveFocus = document.activeElement === (on ? follow : pause); |
| 86 | following = on; |
| 87 | follow.hidden = on; |
| 88 | pause.hidden = !on; |
| 89 | var q = new URLSearchParams(activeQuery); |
| 90 | if (on) q.set("follow", "1"); else q.delete("follow"); |
| 91 | history.replaceState(null, "", location.pathname + "?" + q.toString()); |
| 92 | if (moveFocus) (on ? pause : follow).focus(); |
| 93 | } |
| 94 | function addSpan(row, cls, value, prefix) { |
| 95 | if (!value) return; |
| 96 | if (prefix) row.appendChild(document.createTextNode(prefix)); |
| 97 | var span = document.createElement("span"); |
| 98 | span.className = cls; |
| 99 | span.textContent = value; |
| 100 | row.appendChild(span); |
| 101 | } |
| 102 | function append(line) { |
| 103 | var empty = document.getElementById("log-empty"); |
| 104 | if (empty) empty.remove(); |
| 105 | var row = document.createElement("div"); |
| 106 | row.className = "tl" + (line.tone ? " tl-" + line.tone : ""); |
| 107 | addSpan(row, "tl-t", line.time, ""); |
| 108 | addSpan(row, "tl-s", line.stream, " "); |
| 109 | addSpan(row, "tl-h", line.host, " "); |
| 110 | addSpan(row, "tl-m", line.msg, " "); |
| 111 | addSpan(row, "tl-f", line.fields, ""); |
| 112 | term.appendChild(row); |
| 113 | while (term.querySelectorAll(".tl").length > 200) term.querySelector(".tl").remove(); |
| 114 | term.scrollTop = term.scrollHeight; |
| 115 | } |
| 116 | function clearLines() { |
| 117 | term.querySelectorAll(".tl").forEach(function (row) { row.remove(); }); |
| 118 | } |
| 119 | function stop() { |
| 120 | generation++; |
| 121 | following = false; |
| 122 | if (timer) window.clearTimeout(timer); |
| 123 | timer = 0; |
| 124 | if (request) request.abort(); |
| 125 | request = null; |
| 126 | if (fetchTimeout) window.clearTimeout(fetchTimeout); |
| 127 | fetchTimeout = 0; |
| 128 | timedOut = false; |
| 129 | term.setAttribute("aria-busy", "false"); |
| 130 | setFollowing(false); |
| 131 | } |
| 132 | function poll() { |
| 133 | if (!following) return; |
| 134 | var thisGeneration = generation; |
| 135 | var q = new URLSearchParams(activeQuery); |
| 136 | var hadCursor = Boolean(cursor); |
| 137 | q.set("limit", "200"); |
| 138 | if (cursor) { |
| 139 | q.set("cursor", cursor); |
| 140 | q.delete("since"); |
| 141 | q.delete("until"); |
| 142 | } |
| 143 | request = new AbortController(); |
| 144 | timedOut = false; |
| 145 | var thisRequest = request; |
| 146 | fetchTimeout = window.setTimeout(function () { |
| 147 | if (following && thisGeneration === generation && request === thisRequest) { |
| 148 | timedOut = true; |
| 149 | thisRequest.abort(); |
| 150 | } |
| 151 | }, 20000); |
| 152 | term.setAttribute("aria-busy", "true"); |
| 153 | fetch(form.dataset.url + "?" + q.toString(), {signal: request.signal, headers: {"Accept": "application/json"}}) |
| 154 | .then(function (r) { if (!r.ok) throw new Error(String(r.status)); return r.json(); }) |
| 155 | .then(function (data) { |
| 156 | if (!following || thisGeneration !== generation) return; |
| 157 | window.clearTimeout(fetchTimeout); |
| 158 | fetchTimeout = 0; |
| 159 | if (data.gap) { |
| 160 | cursor = ""; |
| 161 | stop(); |
| 162 | status.textContent = form.dataset.followError; |
| 163 | return; |
| 164 | } |
| 165 | if (!hadCursor) clearLines(); |
| 166 | data.lines.forEach(append); |
| 167 | cursor = data.cursor || cursor; |
| 168 | status.textContent = form.dataset.refreshed + " " + new Date().toLocaleTimeString(); |
| 169 | term.setAttribute("aria-busy", "false"); |
| 170 | timer = window.setTimeout(poll, 2000); |
| 171 | }) |
| 172 | .catch(function (err) { |
| 173 | window.clearTimeout(fetchTimeout); |
| 174 | fetchTimeout = 0; |
| 175 | if (err.name === "AbortError" && !timedOut) return; |
| 176 | if (!following || thisGeneration !== generation) return; |
| 177 | stop(); |
| 178 | status.textContent = form.dataset.followError; |
| 179 | }); |
| 180 | } |
| 181 | form.addEventListener("input", function () { dirty = true; if (following) stop(); }); |
| 182 | form.addEventListener("change", function () { dirty = true; if (following) stop(); }); |
| 183 | follow.addEventListener("click", function () { |
| 184 | if (dirty) { |
| 185 | var q = new URLSearchParams(new FormData(form)); |
| 186 | q.set("follow", "1"); |
| 187 | location.assign(location.pathname + "?" + q.toString()); |
| 188 | return; |
| 189 | } |
| 190 | setFollowing(true); |
| 191 | poll(); |
| 192 | }); |
| 193 | pause.addEventListener("click", stop); |
| 194 | term.scrollTop = term.scrollHeight; |
| 195 | {{if .LogFollow}}setFollowing(true); poll();{{end}} |
| 196 | })(); |
| 197 | </script> |
| 198 | {{template "bottom" .}}{{end}} |
| 199 | |