| 1 |
|
| 2 |
|
| 3 |
use axum::extract::State as AxumState; |
| 4 |
use axum::response::{Html, IntoResponse}; |
| 5 |
|
| 6 |
use crate::api::ApiState; |
| 7 |
|
| 8 |
|
| 9 |
pub async fn dashboard_handler(AxumState(state): AxumState<ApiState>) -> impl IntoResponse { |
| 10 |
let instance_name = state.config.instance_name(); |
| 11 |
let version = env!("CARGO_PKG_VERSION"); |
| 12 |
let api_token = state.config.serve.api_token.as_deref().unwrap_or(""); |
| 13 |
let has_mesh = state.mesh.is_some(); |
| 14 |
Html(render_dashboard(&instance_name, version, api_token, has_mesh)) |
| 15 |
} |
| 16 |
|
| 17 |
|
| 18 |
pub(crate) fn escape_js(s: &str) -> String { |
| 19 |
let mut out = String::with_capacity(s.len()); |
| 20 |
for ch in s.chars() { |
| 21 |
match ch { |
| 22 |
'\\' => out.push_str("\\\\"), |
| 23 |
'"' => out.push_str("\\\""), |
| 24 |
'\n' => out.push_str("\\n"), |
| 25 |
'\r' => out.push_str("\\r"), |
| 26 |
'<' => out.push_str("\\x3c"), |
| 27 |
'\0' => out.push_str("\\0"), |
| 28 |
_ => out.push(ch), |
| 29 |
} |
| 30 |
} |
| 31 |
out |
| 32 |
} |
| 33 |
|
| 34 |
fn render_dashboard(instance_name: &str, version: &str, api_token: &str, has_mesh: bool) -> String { |
| 35 |
let js = format!( |
| 36 |
"const API_TOKEN = \"{token}\";\nconst HAS_MESH = {has_mesh};\n{JS}", |
| 37 |
token = escape_js(api_token), |
| 38 |
has_mesh = has_mesh, |
| 39 |
JS = JS, |
| 40 |
); |
| 41 |
format!( |
| 42 |
r##"<!DOCTYPE html> |
| 43 |
<html lang="en"> |
| 44 |
<head> |
| 45 |
<meta charset="utf-8"> |
| 46 |
<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 47 |
<title>PoM — {instance_name}</title> |
| 48 |
<link rel="preconnect" href="https://fonts.googleapis.com"> |
| 49 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| 50 |
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Lato:wght@400;700&display=swap" rel="stylesheet"> |
| 51 |
<style>{CSS}</style> |
| 52 |
</head> |
| 53 |
<body> |
| 54 |
<div class="health-container"> |
| 55 |
<div class="summary-bar"> |
| 56 |
<div class="summary-left"> |
| 57 |
<span class="summary-dot" id="global-dot"></span> |
| 58 |
<span class="summary-title">PoM</span> |
| 59 |
<span class="summary-instance">{instance_name}</span> |
| 60 |
</div> |
| 61 |
<div class="summary-right"> |
| 62 |
<span class="summary-version">v{version}</span> |
| 63 |
<span class="summary-refresh" id="refresh-timer">Refresh: 30s</span> |
| 64 |
<span class="summary-updated" id="last-updated"></span> |
| 65 |
</div> |
| 66 |
</div> |
| 67 |
<div class="health-grid" id="target-grid"></div> |
| 68 |
<div id="mesh-section"></div> |
| 69 |
<div id="details-section"></div> |
| 70 |
</div> |
| 71 |
<script> |
| 72 |
{js} |
| 73 |
</script> |
| 74 |
</body> |
| 75 |
</html>"##, |
| 76 |
instance_name = instance_name, |
| 77 |
CSS = CSS, |
| 78 |
js = js, |
| 79 |
) |
| 80 |
} |
| 81 |
|
| 82 |
const CSS: &str = r##" |
| 83 |
:root { |
| 84 |
--background: #ede8e1; |
| 85 |
--text: #3d3530; |
| 86 |
--surface-muted: #ddd7c5; |
| 87 |
--light-background: #f4f0eb; |
| 88 |
--border: #d0cbb8; |
| 89 |
--ok: #22c55e; |
| 90 |
--warn: #f59e0b; |
| 91 |
--error: #ef4444; |
| 92 |
--unknown: #9ca3af; |
| 93 |
} |
| 94 |
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| 95 |
body { |
| 96 |
font-family: 'Lato', sans-serif; |
| 97 |
background: var(--background); |
| 98 |
color: var(--text); |
| 99 |
line-height: 1.5; |
| 100 |
} |
| 101 |
.health-container { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } |
| 102 |
.summary-bar { |
| 103 |
display: flex; justify-content: space-between; align-items: center; |
| 104 |
margin-bottom: 1.5rem; padding: 0.75rem 1rem; |
| 105 |
background: var(--surface-muted); border-radius: 6px; |
| 106 |
} |
| 107 |
.summary-left, .summary-right { display: flex; align-items: center; gap: 0.75rem; } |
| 108 |
.summary-dot { |
| 109 |
width: 10px; height: 10px; border-radius: 50%; |
| 110 |
background: var(--unknown); display: inline-block; flex-shrink: 0; |
| 111 |
} |
| 112 |
.summary-title { font-family: 'IBM Plex Mono', monospace; font-weight: 500; font-size: 1.1rem; } |
| 113 |
.summary-instance { font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem; opacity: 0.6; } |
| 114 |
.summary-version { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; } |
| 115 |
.summary-refresh { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; } |
| 116 |
.summary-updated { font-family: 'IBM Plex Mono', monospace; font-size: 0.8rem; opacity: 0.7; } |
| 117 |
.health-grid { |
| 118 |
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); |
| 119 |
gap: 1.5rem; margin-bottom: 2rem; |
| 120 |
} |
| 121 |
.health-card { |
| 122 |
background: var(--surface-muted); border-radius: 6px; padding: 1.25rem; |
| 123 |
} |
| 124 |
.health-card.incident { border-left: 3px solid var(--error); } |
| 125 |
.health-card.stale { border-left: 3px solid var(--warn); } |
| 126 |
.card-header { |
| 127 |
display: flex; align-items: center; gap: 0.5rem; |
| 128 |
font-family: 'IBM Plex Mono', monospace; font-size: 0.9rem; font-weight: 500; |
| 129 |
margin-bottom: 0.75rem; |
| 130 |
} |
| 131 |
.status-dot { |
| 132 |
width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; |
| 133 |
} |
| 134 |
.dot-ok { background: var(--ok); } |
| 135 |
.dot-warn { background: var(--warn); } |
| 136 |
.dot-error { background: var(--error); } |
| 137 |
.dot-unknown { background: var(--unknown); } |
| 138 |
dl { |
| 139 |
display: grid; grid-template-columns: auto 1fr; |
| 140 |
gap: 0.25rem 0.75rem; font-size: 0.85rem; |
| 141 |
} |
| 142 |
dt { opacity: 0.7; } |
| 143 |
dd { text-align: right; font-family: 'IBM Plex Mono', monospace; } |
| 144 |
.section-title { |
| 145 |
font-family: 'IBM Plex Mono', monospace; font-size: 1rem; font-weight: 500; |
| 146 |
border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; margin: 1.5rem 0 1rem; |
| 147 |
} |
| 148 |
details { margin-bottom: 0.75rem; } |
| 149 |
details summary { |
| 150 |
font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem; |
| 151 |
cursor: pointer; padding: 0.5rem; background: var(--light-background); |
| 152 |
border-radius: 4px; list-style: none; |
| 153 |
} |
| 154 |
details summary::before { content: '\25b6 '; font-size: 0.7rem; } |
| 155 |
details[open] summary::before { content: '\25bc '; } |
| 156 |
details .detail-content { padding: 0.5rem; font-size: 0.85rem; } |
| 157 |
.incident-line { |
| 158 |
display: flex; justify-content: space-between; align-items: center; |
| 159 |
padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; |
| 160 |
} |
| 161 |
.incident-line:last-child { border-bottom: none; } |
| 162 |
.uptime-ok { color: var(--ok); } |
| 163 |
.uptime-warn { color: var(--warn); } |
| 164 |
.uptime-danger { color: var(--error); } |
| 165 |
.alert-bar { |
| 166 |
padding: 0.4rem 0.6rem; font-size: 0.8rem; border-radius: 4px; |
| 167 |
margin-top: 0.5rem; font-family: 'IBM Plex Mono', monospace; |
| 168 |
} |
| 169 |
.alert-bar.incident-bar { background: rgba(239,68,68,0.1); border-left: 3px solid var(--error); } |
| 170 |
.alert-bar.stale-bar { background: rgba(245,158,11,0.1); border-left: 3px solid var(--warn); } |
| 171 |
@media (max-width: 600px) { |
| 172 |
.summary-bar { flex-direction: column; gap: 0.5rem; } |
| 173 |
.health-grid { grid-template-columns: 1fr; } |
| 174 |
} |
| 175 |
"##; |
| 176 |
|
| 177 |
const JS: &str = r##" |
| 178 |
let countdown = 30; |
| 179 |
let timer = null; |
| 180 |
|
| 181 |
function headers() { |
| 182 |
const h = {}; |
| 183 |
if (API_TOKEN) h['Authorization'] = 'Bearer ' + API_TOKEN; |
| 184 |
return h; |
| 185 |
} |
| 186 |
|
| 187 |
function dotClass(status) { |
| 188 |
if (!status) return 'dot-unknown'; |
| 189 |
const s = status.toLowerCase(); |
| 190 |
if (s === 'operational') return 'dot-ok'; |
| 191 |
if (s === 'degraded') return 'dot-warn'; |
| 192 |
if (s === 'error' || s === 'unreachable') return 'dot-error'; |
| 193 |
return 'dot-unknown'; |
| 194 |
} |
| 195 |
|
| 196 |
function uptimeClass(pct) { |
| 197 |
if (pct >= 99) return 'uptime-ok'; |
| 198 |
if (pct >= 95) return 'uptime-warn'; |
| 199 |
return 'uptime-danger'; |
| 200 |
} |
| 201 |
|
| 202 |
function fmtPct(v) { |
| 203 |
return v != null ? v.toFixed(2) + '%' : 'N/A'; |
| 204 |
} |
| 205 |
|
| 206 |
function renderCard(name, t) { |
| 207 |
const status = t.latest ? t.latest.status : 'unknown'; |
| 208 |
const dc = dotClass(status); |
| 209 |
let cls = 'health-card'; |
| 210 |
if (t.current_incident) cls += ' incident'; |
| 211 |
else if (t.test_staleness && t.test_staleness.stale) cls += ' stale'; |
| 212 |
|
| 213 |
let html = '<div class="' + cls + '">'; |
| 214 |
html += '<div class="card-header"><span class="status-dot ' + dc + '"></span>' + esc(t.label) + '</div>'; |
| 215 |
html += '<dl>'; |
| 216 |
html += '<dt>Status</dt><dd>' + esc(status) + '</dd>'; |
| 217 |
if (t.latest) html += '<dt>Response</dt><dd>' + t.latest.response_time_ms + 'ms</dd>'; |
| 218 |
if (t.uptime_24h != null) { |
| 219 |
const c24 = uptimeClass(t.uptime_24h); |
| 220 |
html += '<dt>Uptime 24h</dt><dd class="' + c24 + '">' + fmtPct(t.uptime_24h) + '</dd>'; |
| 221 |
} |
| 222 |
if (t.uptime_7d != null) { |
| 223 |
const c7 = uptimeClass(t.uptime_7d); |
| 224 |
html += '<dt>Uptime 7d</dt><dd class="' + c7 + '">' + fmtPct(t.uptime_7d) + '</dd>'; |
| 225 |
} |
| 226 |
html += '</dl>'; |
| 227 |
|
| 228 |
if (t.latency_24h) { |
| 229 |
html += '<dl>'; |
| 230 |
html += '<dt>Avg</dt><dd>' + t.latency_24h.avg_ms.toFixed(0) + 'ms</dd>'; |
| 231 |
html += '<dt>P95</dt><dd>' + t.latency_24h.p95_ms + 'ms</dd>'; |
| 232 |
html += '<dt>Min/Max</dt><dd>' + t.latency_24h.min_ms + '/' + t.latency_24h.max_ms + 'ms</dd>'; |
| 233 |
html += '</dl>'; |
| 234 |
} |
| 235 |
|
| 236 |
if (t.tls) { |
| 237 |
html += '<dl>'; |
| 238 |
html += '<dt>TLS</dt><dd>' + (t.tls.valid ? 'valid' : 'invalid') + '</dd>'; |
| 239 |
const dc2 = t.tls.days_remaining > 14 ? 'uptime-ok' : t.tls.days_remaining > 7 ? 'uptime-warn' : 'uptime-danger'; |
| 240 |
html += '<dt>Expires</dt><dd class="' + dc2 + '">' + t.tls.days_remaining + 'd</dd>'; |
| 241 |
html += '</dl>'; |
| 242 |
} |
| 243 |
|
| 244 |
if (t.whois) { |
| 245 |
html += '<dl>'; |
| 246 |
const wd = t.whois.days_remaining; |
| 247 |
const wc = wd > 30 ? 'uptime-ok' : wd > 14 ? 'uptime-warn' : 'uptime-danger'; |
| 248 |
html += '<dt>Domain</dt><dd class="' + wc + '">' + (wd != null ? wd + 'd' : 'N/A') + '</dd>'; |
| 249 |
if (t.whois.registrar) html += '<dt>Registrar</dt><dd>' + esc(t.whois.registrar) + '</dd>'; |
| 250 |
html += '</dl>'; |
| 251 |
} |
| 252 |
|
| 253 |
if (t.dns_status && t.dns_status.length > 0) { |
| 254 |
const m = t.dns_status.filter(function(d) { return d.matches; }).length; |
| 255 |
html += '<dl><dt>DNS</dt><dd>' + m + '/' + t.dns_status.length + ' match</dd></dl>'; |
| 256 |
} |
| 257 |
if (t.route_status && t.route_status.length > 0) { |
| 258 |
const ok = t.route_status.filter(function(r) { return r.ok; }).length; |
| 259 |
html += '<dl><dt>Routes</dt><dd>' + ok + '/' + t.route_status.length + ' OK</dd></dl>'; |
| 260 |
} |
| 261 |
|
| 262 |
if (t.current_incident) { |
| 263 |
html += '<div class="alert-bar incident-bar">Incident: ' + esc(t.current_incident.from_status) + ' \u2192 ' + esc(t.current_incident.to_status) + '</div>'; |
| 264 |
} |
| 265 |
if (t.test_staleness && t.test_staleness.stale) { |
| 266 |
html += '<div class="alert-bar stale-bar">Tests stale: ' + esc(t.test_staleness.reason) + '</div>'; |
| 267 |
} |
| 268 |
if (t.test_duration_drift) { |
| 269 |
html += '<div class="alert-bar stale-bar">' + esc(t.test_duration_drift) + '</div>'; |
| 270 |
} |
| 271 |
|
| 272 |
html += '</div>'; |
| 273 |
return html; |
| 274 |
} |
| 275 |
|
| 276 |
function esc(s) { |
| 277 |
if (!s) return ''; |
| 278 |
var d = document.createElement('div'); |
| 279 |
d.textContent = s; |
| 280 |
return d.innerHTML; |
| 281 |
} |
| 282 |
|
| 283 |
function renderDetails(targets) { |
| 284 |
let html = ''; |
| 285 |
// Recent incidents |
| 286 |
let hasIncidents = false; |
| 287 |
let incHtml = ''; |
| 288 |
for (const name in targets) { |
| 289 |
const t = targets[name]; |
| 290 |
if (t.incidents && t.incidents.length > 0) { |
| 291 |
hasIncidents = true; |
| 292 |
for (let i = 0; i < t.incidents.length; i++) { |
| 293 |
const inc = t.incidents[i]; |
| 294 |
const dur = inc.duration_secs ? Math.round(inc.duration_secs / 60) + 'm' : 'ongoing'; |
| 295 |
incHtml += '<div class="incident-line"><span>' + esc(t.label) + ': ' + esc(inc.from_status) + ' \u2192 ' + esc(inc.to_status) + '</span><span>' + dur + '</span></div>'; |
| 296 |
} |
| 297 |
} |
| 298 |
} |
| 299 |
if (hasIncidents) { |
| 300 |
html += '<details><summary>Recent Incidents</summary><div class="detail-content">' + incHtml + '</div></details>'; |
| 301 |
} |
| 302 |
|
| 303 |
// DNS details |
| 304 |
let hasDns = false; |
| 305 |
let dnsHtml = ''; |
| 306 |
for (const name in targets) { |
| 307 |
const t = targets[name]; |
| 308 |
if (t.dns_status && t.dns_status.length > 0) { |
| 309 |
hasDns = true; |
| 310 |
for (let i = 0; i < t.dns_status.length; i++) { |
| 311 |
const d = t.dns_status[i]; |
| 312 |
const mc = d.matches ? 'dot-ok' : 'dot-error'; |
| 313 |
dnsHtml += '<div class="incident-line"><span><span class="status-dot ' + mc + '" style="display:inline-block;vertical-align:middle;margin-right:4px"></span>' + esc(d.name) + ' ' + esc(d.record_type) + '</span><span>' + esc(d.actual.join(', ')) + '</span></div>'; |
| 314 |
} |
| 315 |
} |
| 316 |
} |
| 317 |
if (hasDns) { |
| 318 |
html += '<details><summary>DNS Details</summary><div class="detail-content">' + dnsHtml + '</div></details>'; |
| 319 |
} |
| 320 |
|
| 321 |
// Route details |
| 322 |
let hasRoutes = false; |
| 323 |
let routeHtml = ''; |
| 324 |
for (const name in targets) { |
| 325 |
const t = targets[name]; |
| 326 |
if (t.route_status && t.route_status.length > 0) { |
| 327 |
hasRoutes = true; |
| 328 |
for (let i = 0; i < t.route_status.length; i++) { |
| 329 |
const r = t.route_status[i]; |
| 330 |
const rc = r.ok ? 'dot-ok' : 'dot-error'; |
| 331 |
routeHtml += '<div class="incident-line"><span><span class="status-dot ' + rc + '" style="display:inline-block;vertical-align:middle;margin-right:4px"></span>' + esc(t.label) + ' ' + esc(r.path) + '</span><span>' + r.status_code + ' (' + r.response_time_ms + 'ms)</span></div>'; |
| 332 |
} |
| 333 |
} |
| 334 |
} |
| 335 |
if (hasRoutes) { |
| 336 |
html += '<details><summary>Route Details</summary><div class="detail-content">' + routeHtml + '</div></details>'; |
| 337 |
} |
| 338 |
|
| 339 |
return html; |
| 340 |
} |
| 341 |
|
| 342 |
function renderMesh(data) { |
| 343 |
if (!data || !data.instances) return ''; |
| 344 |
let html = '<div class="section-title">Peer Mesh</div>'; |
| 345 |
html += '<div class="health-grid">'; |
| 346 |
for (const name in data.instances) { |
| 347 |
const inst = data.instances[name]; |
| 348 |
html += '<div class="health-card"><div class="card-header">' + esc(name) + '</div>'; |
| 349 |
if (inst.instance) { |
| 350 |
html += '<dl><dt>Version</dt><dd>' + esc(inst.instance.version) + '</dd></dl>'; |
| 351 |
} |
| 352 |
if (inst.targets) { |
| 353 |
html += '<dl>'; |
| 354 |
for (const tn in inst.targets) { |
| 355 |
const tt = inst.targets[tn]; |
| 356 |
const dc = dotClass(tt.status); |
| 357 |
html += '<dt>' + esc(tt.label || tn) + '</dt><dd><span class="status-dot ' + dc + '" style="display:inline-block;vertical-align:middle"></span></dd>'; |
| 358 |
} |
| 359 |
html += '</dl>'; |
| 360 |
} |
| 361 |
html += '</div>'; |
| 362 |
} |
| 363 |
html += '</div>'; |
| 364 |
return html; |
| 365 |
} |
| 366 |
|
| 367 |
async function refresh() { |
| 368 |
try { |
| 369 |
const resp = await fetch('/api/status', { headers: headers() }); |
| 370 |
if (!resp.ok) return; |
| 371 |
const data = await resp.json(); |
| 372 |
const targets = data.targets || {}; |
| 373 |
|
| 374 |
// Global dot |
| 375 |
let worst = 'operational'; |
| 376 |
for (const name in targets) { |
| 377 |
const s = targets[name].latest ? targets[name].latest.status : 'unknown'; |
| 378 |
if (s === 'error' || s === 'unreachable') worst = 'error'; |
| 379 |
else if (s === 'degraded' && worst !== 'error') worst = 'degraded'; |
| 380 |
else if (s === 'unknown' && worst === 'operational') worst = 'unknown'; |
| 381 |
} |
| 382 |
document.getElementById('global-dot').className = 'summary-dot ' + dotClass(worst).replace('dot-', 'dot-'); |
| 383 |
|
| 384 |
// Sort target names |
| 385 |
const names = Object.keys(targets).sort(); |
| 386 |
let gridHtml = ''; |
| 387 |
for (let i = 0; i < names.length; i++) { |
| 388 |
gridHtml += renderCard(names[i], targets[names[i]]); |
| 389 |
} |
| 390 |
document.getElementById('target-grid').innerHTML = gridHtml; |
| 391 |
|
| 392 |
// Details |
| 393 |
document.getElementById('details-section').innerHTML = renderDetails(targets); |
| 394 |
|
| 395 |
// Update timestamp |
| 396 |
document.getElementById('last-updated').textContent = 'Updated ' + new Date().toLocaleTimeString(); |
| 397 |
|
| 398 |
// Mesh |
| 399 |
if (HAS_MESH) { |
| 400 |
try { |
| 401 |
const mr = await fetch('/api/mesh', { headers: headers() }); |
| 402 |
if (mr.ok) { |
| 403 |
const md = await mr.json(); |
| 404 |
document.getElementById('mesh-section').innerHTML = renderMesh(md); |
| 405 |
} |
| 406 |
} catch(e) {} |
| 407 |
} |
| 408 |
} catch(e) { |
| 409 |
console.error('Dashboard refresh failed:', e); |
| 410 |
} |
| 411 |
} |
| 412 |
|
| 413 |
function tick() { |
| 414 |
countdown--; |
| 415 |
if (countdown <= 0) { countdown = 30; refresh(); } |
| 416 |
document.getElementById('refresh-timer').textContent = 'Refresh: ' + countdown + 's'; |
| 417 |
} |
| 418 |
|
| 419 |
refresh(); |
| 420 |
timer = setInterval(tick, 1000); |
| 421 |
"##; |
| 422 |
|
| 423 |
#[cfg(test)] |
| 424 |
mod tests { |
| 425 |
use super::*; |
| 426 |
|
| 427 |
#[test] |
| 428 |
fn escape_js_backslash() { |
| 429 |
assert_eq!(escape_js(r"a\b"), r"a\\b"); |
| 430 |
} |
| 431 |
|
| 432 |
#[test] |
| 433 |
fn escape_js_double_quote() { |
| 434 |
assert_eq!(escape_js(r#"a"b"#), r#"a\"b"#); |
| 435 |
} |
| 436 |
|
| 437 |
#[test] |
| 438 |
fn escape_js_both() { |
| 439 |
assert_eq!(escape_js(r#"a\"b"#), r#"a\\\"b"#); |
| 440 |
} |
| 441 |
|
| 442 |
#[test] |
| 443 |
fn escape_js_clean_string() { |
| 444 |
assert_eq!(escape_js("hello"), "hello"); |
| 445 |
} |
| 446 |
|
| 447 |
#[test] |
| 448 |
fn escape_js_empty() { |
| 449 |
assert_eq!(escape_js(""), ""); |
| 450 |
} |
| 451 |
|
| 452 |
#[test] |
| 453 |
fn escape_js_newline() { |
| 454 |
assert_eq!(escape_js("a\nb"), "a\\nb"); |
| 455 |
} |
| 456 |
|
| 457 |
#[test] |
| 458 |
fn escape_js_carriage_return() { |
| 459 |
assert_eq!(escape_js("a\rb"), "a\\rb"); |
| 460 |
} |
| 461 |
|
| 462 |
#[test] |
| 463 |
fn escape_js_script_close_tag() { |
| 464 |
assert_eq!(escape_js("</script>"), "\\x3c/script>"); |
| 465 |
} |
| 466 |
|
| 467 |
#[test] |
| 468 |
fn escape_js_null() { |
| 469 |
assert_eq!(escape_js("a\0b"), "a\\0b"); |
| 470 |
} |
| 471 |
} |
| 472 |
|