300 lines
11 KiB
Python
300 lines
11 KiB
Python
"""HTML views for the read-only restart console (#667).
|
|
|
|
Every interpolated value passes through :func:`_esc`. Values that can carry a
|
|
filesystem path or free-form operator text additionally pass through
|
|
:func:`webui.inventory.scrub_text`, which redacts credential-shaped tokens
|
|
*inside* a string rather than only at its start.
|
|
|
|
The page renders state and never offers a control that would mutate anything:
|
|
the approval and break-glass panels report authorization and availability, and
|
|
there is no form, button, or endpoint behind them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
|
|
from webui.inventory import scrub_text
|
|
from webui.restart_console import RestartConsoleSnapshot, SourceStatus
|
|
|
|
|
|
def _esc(value: object) -> str:
|
|
"""Escape any value for HTML text or a quoted attribute."""
|
|
if value is None:
|
|
return ""
|
|
return html.escape(str(value), quote=True)
|
|
|
|
|
|
def _esc_text(value: object) -> str:
|
|
"""Escape free-form text after redacting secrets embedded inside it."""
|
|
if value is None:
|
|
return ""
|
|
return _esc(scrub_text(str(value)))
|
|
|
|
|
|
def _bool_badge(
|
|
value: bool, *, true_label: str = "yes", false_label: str = "no"
|
|
) -> str:
|
|
css = "badge-ok" if value else "badge-blocked"
|
|
label = true_label if value else false_label
|
|
return f'<span class="badge {css}">{_esc(label)}</span>'
|
|
|
|
|
|
def _source_badge(source: SourceStatus) -> str:
|
|
css = "badge-ok" if source.available else "badge-blocked"
|
|
badge = f'<span class="badge {css}">{_esc(source.status)}</span>'
|
|
if source.detail:
|
|
badge += f' <span class="muted">{_esc_text(source.detail)}</span>'
|
|
return badge
|
|
|
|
|
|
def _notes_block(snapshot: RestartConsoleSnapshot) -> str:
|
|
if not snapshot.notes:
|
|
return ""
|
|
items = "".join(f"<li>{_esc_text(note)}</li>" for note in snapshot.notes)
|
|
return f"<ul class='reasons'>{items}</ul>"
|
|
|
|
|
|
def _impact_section(snapshot: RestartConsoleSnapshot) -> str:
|
|
head = (
|
|
"<section class='health-card'>"
|
|
f"<h3>Impact preview {_source_badge(snapshot.impact_source)}</h3>"
|
|
)
|
|
impact = snapshot.impact
|
|
if impact is None:
|
|
return (
|
|
head
|
|
+ "<p class='muted'>No impact preview is available, so the blast "
|
|
"radius of a restart is unknown. Treat this as unsafe.</p></section>"
|
|
)
|
|
|
|
counts = impact.get("counts") or {}
|
|
verdict = str(impact.get("verdict") or "unknown")
|
|
verdict_css = "badge-ok" if verdict == "safe" else "badge-blocked"
|
|
rows = "".join(
|
|
f"<tr><th>{_esc(key.replace('_', ' '))}</th><td>{_esc(value)}</td></tr>"
|
|
for key, value in sorted(counts.items())
|
|
)
|
|
reasons = "".join(
|
|
f"<li>{_esc_text(reason)}</li>" for reason in (impact.get("reasons") or [])
|
|
)
|
|
incomplete = ""
|
|
if not impact.get("inventory_complete", False):
|
|
detail = "; ".join(str(r) for r in (impact.get("incomplete_reasons") or []))
|
|
incomplete = (
|
|
"<p class='error'><strong>Inventory incomplete:</strong> "
|
|
f"{_esc_text(detail or 'unspecified')}. The coordinator fails "
|
|
"closed on an incomplete inventory.</p>"
|
|
)
|
|
|
|
sessions = impact.get("affected_sessions") or []
|
|
session_rows = "".join(
|
|
"<tr>"
|
|
f"<td><code>{_esc(s.get('session_id'))}</code></td>"
|
|
f"<td>{_esc(s.get('role'))}</td>"
|
|
f"<td>{_esc(s.get('pid'))}</td>"
|
|
f"<td>{_bool_badge(bool(s.get('live')), true_label='live', false_label='idle')}</td>"
|
|
f"<td>{_bool_badge(not s.get('heartbeat_stale'), true_label='fresh', false_label='stale')}</td>"
|
|
"</tr>"
|
|
for s in sessions[:50]
|
|
)
|
|
session_table = (
|
|
"<h4>Sessions a restart would terminate</h4>"
|
|
"<div class='table-scroll'><table class='registry'><thead><tr>"
|
|
"<th>Session</th><th>Role</th><th>PID</th><th>State</th>"
|
|
"<th>Heartbeat</th></tr></thead><tbody>"
|
|
f"{session_rows}</tbody></table></div>"
|
|
if session_rows
|
|
else "<p class='muted'>No affected sessions reported.</p>"
|
|
)
|
|
truncated = (
|
|
f"<p class='muted'>Showing the first 50 of {_esc(len(sessions))} "
|
|
"affected sessions.</p>"
|
|
if len(sessions) > 50
|
|
else ""
|
|
)
|
|
|
|
return (
|
|
head
|
|
+ "<p class='health-headline'>Verdict "
|
|
f"<span class='badge {verdict_css}'>{_esc(verdict)}</span> · "
|
|
f"blast radius <code>{_esc(impact.get('blast_radius'))}</code> · "
|
|
f"class <code>{_esc(impact.get('restart_class'))}</code></p>"
|
|
+ incomplete
|
|
+ (f"<ul class='reasons'>{reasons}</ul>" if reasons else "")
|
|
+ (f"<table class='registry'><tbody>{rows}</tbody></table>" if rows else "")
|
|
+ session_table
|
|
+ truncated
|
|
+ "</section>"
|
|
)
|
|
|
|
|
|
def _drain_section(snapshot: RestartConsoleSnapshot) -> str:
|
|
head = (
|
|
"<section class='health-card'>"
|
|
f"<h3>Drain proof {_source_badge(snapshot.drain_source)}</h3>"
|
|
)
|
|
drain = snapshot.drain
|
|
if drain is None:
|
|
return (
|
|
head
|
|
+ "<p class='muted'>No drain proof has been presented to this view. "
|
|
"The #661 gate authorizes a restart only against a valid, unexpired, "
|
|
"clean proof, so the absence of one is a denial, not a pass.</p>"
|
|
"</section>"
|
|
)
|
|
reasons = "".join(
|
|
f"<li>{_esc_text(reason)}</li>" for reason in (drain.get("reasons") or [])
|
|
)
|
|
return (
|
|
head
|
|
+ "<table class='registry'><tbody>"
|
|
f"<tr><th>Valid</th><td>{_bool_badge(bool(drain.get('valid')))}</td></tr>"
|
|
f"<tr><th>Clean</th><td>{_bool_badge(bool(drain.get('clean')))}</td></tr>"
|
|
f"<tr><th>Expired</th><td>{_bool_badge(not drain.get('expired'), true_label='no', false_label='yes')}</td></tr>"
|
|
f"<tr><th>Tampered</th><td>{_bool_badge(not drain.get('tampered'), true_label='no', false_label='yes')}</td></tr>"
|
|
f"<tr><th>Proof id</th><td><code>{_esc(drain.get('proof_id'))}</code></td></tr>"
|
|
"</tbody></table>"
|
|
+ (f"<ul class='reasons'>{reasons}</ul>" if reasons else "")
|
|
+ "</section>"
|
|
)
|
|
|
|
|
|
def _reconcile_section(snapshot: RestartConsoleSnapshot) -> str:
|
|
head = (
|
|
"<section class='health-card'>"
|
|
f"<h3>Post-restart reconcile {_source_badge(snapshot.reconcile_source)}</h3>"
|
|
)
|
|
proof = snapshot.reconcile
|
|
if proof is None:
|
|
return (
|
|
head
|
|
+ "<p class='muted'>No post-restart completion proof is recorded. "
|
|
"Until one is, the last restart's recovery state is unproven.</p>"
|
|
"</section>"
|
|
)
|
|
items = "".join(
|
|
"<tr>"
|
|
f"<td>{_esc(item.get('dimension'))}</td>"
|
|
f"<td>{_esc(item.get('status'))}</td>"
|
|
f"<td>{_esc_text(item.get('summary'))}</td>"
|
|
f"<td>{_bool_badge(not item.get('follow_up_required'), true_label='no', false_label='yes')}</td>"
|
|
"</tr>"
|
|
for item in (proof.get("items") or [])
|
|
)
|
|
return (
|
|
head
|
|
+ "<p class='health-headline'>Status "
|
|
f"<code>{_esc(proof.get('overall_status'))}</code> · mode "
|
|
f"<code>{_esc(proof.get('mode'))}</code> · resolved "
|
|
f"{_esc(proof.get('resolved_count'))} · unresolved "
|
|
f"{_esc(proof.get('unresolved_count'))}</p>"
|
|
+ (
|
|
"<div class='table-scroll'><table class='registry'><thead><tr>"
|
|
"<th>Dimension</th><th>Status</th><th>Summary</th>"
|
|
"<th>Follow-up required</th></tr></thead><tbody>"
|
|
f"{items}</tbody></table></div>"
|
|
if items
|
|
else "<p class='muted'>No reconcile dimensions reported.</p>"
|
|
)
|
|
+ "</section>"
|
|
)
|
|
|
|
|
|
def _class_matrix_section(snapshot: RestartConsoleSnapshot) -> str:
|
|
rows = "".join(
|
|
"<tr>"
|
|
f"<td><code>{_esc(view.restart_class)}</code></td>"
|
|
f"<td><code>{_esc(view.required_permission)}</code></td>"
|
|
f"<td>{_esc(view.expected_blast_radius)}</td>"
|
|
f"<td>{_esc(view.drain_requirement)}</td>"
|
|
f"<td>{_esc(view.approval_requirement)}</td>"
|
|
f"<td>{_bool_badge(view.viewer_may_request)}</td>"
|
|
f"<td>{_bool_badge(view.viewer_may_execute)}</td>"
|
|
"</tr>"
|
|
for view in snapshot.restart_classes
|
|
)
|
|
return (
|
|
"<section class='health-card'>"
|
|
"<h3>Restart classes</h3>"
|
|
"<p class='muted'>The least-privilege matrix each restart request is "
|
|
"resolved against. “You may request” and “you may "
|
|
"execute” are computed for the current viewer role, not for a "
|
|
"generic operator.</p>"
|
|
"<div class='table-scroll'><table class='registry'><thead><tr>"
|
|
"<th>Class</th><th>Permission</th><th>Blast radius</th>"
|
|
"<th>Drain</th><th>Approval</th><th>You may request</th>"
|
|
"<th>You may execute</th></tr></thead><tbody>"
|
|
f"{rows}</tbody></table></div>"
|
|
"</section>"
|
|
)
|
|
|
|
|
|
def _approval_section(snapshot: RestartConsoleSnapshot) -> str:
|
|
rows = "".join(
|
|
"<tr>"
|
|
f"<td><code>{_esc(a.action_id)}</code></td>"
|
|
f"<td>{_esc(a.required_role)}</td>"
|
|
f"<td>{_bool_badge(a.allowed)}</td>"
|
|
f"<td>{_bool_badge(a.execution_enabled)}</td>"
|
|
f"<td><code>{_esc(a.reason_code)}</code></td>"
|
|
f"<td>{_esc_text(a.detail)}</td>"
|
|
"</tr>"
|
|
for a in snapshot.authorizations
|
|
)
|
|
return (
|
|
"<section class='health-card'>"
|
|
"<h3>Approval controls</h3>"
|
|
"<p class='muted'>Authorization is probed the way execution would probe "
|
|
"it, so “execution enabled” answers whether the action would "
|
|
"actually run — not merely whether this role outranks the requirement. "
|
|
"No control on this page performs the action.</p>"
|
|
"<div class='table-scroll'><table class='registry'><thead><tr>"
|
|
"<th>Action</th><th>Required role</th><th>Authorized</th>"
|
|
"<th>Execution enabled</th><th>Reason</th><th>Detail</th>"
|
|
"</tr></thead><tbody>"
|
|
f"{rows}</tbody></table></div>"
|
|
"</section>"
|
|
)
|
|
|
|
|
|
def _break_glass_section(snapshot: RestartConsoleSnapshot) -> str:
|
|
bg = snapshot.break_glass
|
|
if not bg.viewer_is_privileged:
|
|
return (
|
|
"<section class='health-card'>"
|
|
"<h3>Break-glass</h3>"
|
|
"<p class='muted'>Break-glass status is visible to operator-class "
|
|
"roles only. Your role does not carry that authority, so no "
|
|
"emergency surface is shown.</p>"
|
|
"</section>"
|
|
)
|
|
return (
|
|
"<section class='health-card'>"
|
|
"<h3>Break-glass "
|
|
f"{_bool_badge(bg.available, true_label='available', false_label='unavailable')}"
|
|
"</h3>"
|
|
f"<p class='muted'>{_esc_text(bg.reason)}</p>"
|
|
f"<p class='meta'>Tracked by issue #{_esc(bg.issue)}.</p>"
|
|
"</section>"
|
|
)
|
|
|
|
|
|
def render_restart_console_page(snapshot: RestartConsoleSnapshot) -> str:
|
|
"""Render the whole read-only restart console body."""
|
|
|
|
return (
|
|
"<h2>Restart status and impact</h2>"
|
|
f"<p class='meta'>Generated <code>{_esc(snapshot.generated_at)}</code> · "
|
|
f"viewer role <code>{_esc(snapshot.viewer_role)}</code> · "
|
|
f"authenticated {_bool_badge(snapshot.viewer_authenticated)} · "
|
|
f"read-only {_bool_badge(snapshot.read_only)}</p>"
|
|
+ _notes_block(snapshot)
|
|
+ _impact_section(snapshot)
|
|
+ _drain_section(snapshot)
|
|
+ _reconcile_section(snapshot)
|
|
+ _class_matrix_section(snapshot)
|
|
+ _approval_section(snapshot)
|
|
+ _break_glass_section(snapshot)
|
|
)
|