|
27 | 27 | } |
28 | 28 | }); |
29 | 29 |
|
30 | | - // Highlight search matches |
| 30 | + // Highlight search matches — DOM-only to avoid XSS via innerHTML |
31 | 31 | if (query) { |
32 | 32 | document.querySelectorAll(".log-entry:not(.hidden) .log-msg").forEach((el) => { |
33 | 33 | const text = el.textContent; |
34 | | - el.innerHTML = text.replace( |
35 | | - new RegExp(escapeRegex(query), "gi"), |
36 | | - (m) => `<span class="highlight">${m}</span>` |
37 | | - ); |
| 34 | + while (el.firstChild) el.removeChild(el.firstChild); |
| 35 | + const regex = new RegExp(escapeRegex(query), "gi"); |
| 36 | + let last = 0, match; |
| 37 | + while ((match = regex.exec(text)) !== null) { |
| 38 | + if (match.index > last) el.appendChild(document.createTextNode(text.slice(last, match.index))); |
| 39 | + const span = document.createElement("span"); |
| 40 | + span.className = "highlight"; |
| 41 | + span.textContent = match[0]; |
| 42 | + el.appendChild(span); |
| 43 | + last = match.index + match[0].length; |
| 44 | + } |
| 45 | + if (last < text.length) el.appendChild(document.createTextNode(text.slice(last))); |
38 | 46 | }); |
39 | 47 | } else { |
40 | | - document.querySelectorAll(".log-msg .highlight").forEach((el) => { |
41 | | - el.outerHTML = el.textContent; |
| 48 | + document.querySelectorAll(".log-msg .highlight").forEach((span) => { |
| 49 | + span.replaceWith(document.createTextNode(span.textContent)); |
42 | 50 | }); |
43 | 51 | } |
44 | 52 |
|
|
136 | 144 | const project = sessionStatus.dataset.project; |
137 | 145 | const sessionId = sessionStatus.dataset.session; |
138 | 146 |
|
| 147 | + function createEntryEl(entry) { |
| 148 | + const row = document.createElement("div"); |
| 149 | + row.className = `log-entry level-${entry.level} kind-${entry.kind}`; |
| 150 | + row.dataset.level = entry.level; |
| 151 | + row.dataset.kind = entry.kind; |
| 152 | + |
| 153 | + const ts = document.createElement("span"); |
| 154 | + ts.className = "log-ts"; |
| 155 | + ts.textContent = entry.ts; |
| 156 | + |
| 157 | + const kindWrap = document.createElement("span"); |
| 158 | + kindWrap.className = "log-kind"; |
| 159 | + const kindBadge = document.createElement("span"); |
| 160 | + kindBadge.className = `badge badge-${entry.kind}`; |
| 161 | + kindBadge.style.fontSize = "10px"; |
| 162 | + kindBadge.textContent = entry.kind; |
| 163 | + kindWrap.appendChild(kindBadge); |
| 164 | + |
| 165 | + const levelWrap = document.createElement("span"); |
| 166 | + levelWrap.className = "log-level"; |
| 167 | + const levelBadge = document.createElement("span"); |
| 168 | + levelBadge.className = `badge badge-${entry.level}`; |
| 169 | + levelBadge.style.fontSize = "10px"; |
| 170 | + levelBadge.textContent = entry.level; |
| 171 | + levelWrap.appendChild(levelBadge); |
| 172 | + |
| 173 | + const msg = document.createElement("span"); |
| 174 | + msg.className = "log-msg"; |
| 175 | + msg.textContent = entry.message; |
| 176 | + |
| 177 | + row.append(ts, kindWrap, levelWrap, msg); |
| 178 | + return row; |
| 179 | + } |
| 180 | + |
139 | 181 | function refresh() { |
140 | 182 | fetch(`/api/session/${encodeURIComponent(project)}/${encodeURIComponent(sessionId)}`) |
141 | 183 | .then((r) => (r.ok ? r.json() : null)) |
142 | 184 | .then((data) => { |
143 | 185 | if (!data) return; |
144 | 186 |
|
145 | | - // Reload if status changed or entry count changed |
146 | | - const currentCount = document.querySelectorAll(".log-entry").length; |
147 | | - if (data.entry_count !== currentCount || data.status !== "running") { |
| 187 | + // Status changed (e.g. running → completed/crashed) — full reload |
| 188 | + // because the header badge and meta row need server-side re-render. |
| 189 | + if (data.status !== "running") { |
148 | 190 | location.reload(); |
| 191 | + return; |
| 192 | + } |
| 193 | + |
| 194 | + // New entries — append without disturbing filters or scroll position. |
| 195 | + const currentCount = document.querySelectorAll(".log-entry").length; |
| 196 | + if (data.entry_count !== currentCount && Array.isArray(data.entries)) { |
| 197 | + const container = document.querySelector(".log-entries"); |
| 198 | + if (!container) return; |
| 199 | + // Remove the "no entries" empty-state placeholder if present. |
| 200 | + const empty = container.querySelector(".empty"); |
| 201 | + if (empty) empty.remove(); |
| 202 | + data.entries.slice(currentCount).forEach((entry) => { |
| 203 | + const row = createEntryEl(entry); |
| 204 | + // Respect current filter state so new rows appear/hide correctly. |
| 205 | + if (levelState[entry.level] === false || kindState[entry.kind] === false) { |
| 206 | + row.classList.add("hidden"); |
| 207 | + } |
| 208 | + container.appendChild(row); |
| 209 | + }); |
| 210 | + updateCount(); |
149 | 211 | } |
150 | 212 | }) |
151 | 213 | .catch(() => {}); |
|
0 commit comments