Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md

This commit is contained in:
2026-09-24 13:07:21 -04:00
commit ea70a9cb5f
38 changed files with 9604 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
{#- Email digest. Inline styles and table layout only: Outlook strips <style>
blocks and ignores flex/grid. Kept deliberately plain. -#}
<!doctype html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#f4f4f2;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background:#f4f4f2;padding:22px 10px;">
<tr><td align="center">
<table role="presentation" width="640" cellpadding="0" cellspacing="0"
style="width:640px;max-width:100%;background:#ffffff;border:1px solid #e0e0da;
border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',
Roboto,Helvetica,Arial,sans-serif;color:#1a1a18;">
<tr><td style="padding:22px 26px 14px;border-bottom:2px solid #1a1a18;">
<div style="font-size:22px;font-weight:700;letter-spacing:-.02em;">
AVWatch weekly briefing</div>
<div style="font-size:12.5px;color:#6b6b66;margin-top:4px;">
{{ generated_local }} &middot; {{ run.items_checked }} items tracked
&middot; verdicts by {{ engine_label }}</div>
</td></tr>
{# ---- headline numbers ---- #}
<tr><td style="padding:16px 26px 4px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
{% for label, value, colour in tiles %}
<td align="center" style="padding:8px 4px;">
<div style="font-size:24px;font-weight:700;color:{{ colour }};line-height:1.1;">{{ value }}</div>
<div style="font-size:10px;text-transform:uppercase;letter-spacing:.06em;
color:#6b6b66;margin-top:2px;">{{ label }}</div>
</td>
{% endfor %}
</tr>
</table>
</td></tr>
{% if summary_line %}
<tr><td style="padding:8px 26px 0;">
<div style="font-size:14px;line-height:1.55;background:#f7f7f5;
border-left:3px solid #2b5c8a;padding:11px 14px;border-radius:4px;">
{{ summary_line }}</div>
</td></tr>
{% endif %}
{% macro section(title, findings, colour) %}
{% if findings %}
<tr><td style="padding:22px 26px 0;">
<div style="font-size:11.5px;text-transform:uppercase;letter-spacing:.09em;
color:#6b6b66;border-bottom:1px solid #e0e0da;padding-bottom:6px;
margin-bottom:12px;">{{ title }} ({{ findings|length }})</div>
{% for f in findings %}
{% set v = f.verdict %}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="margin-bottom:12px;border-left:3px solid {{ colour }};
background:#fbfbf9;border-radius:0 4px 4px 0;">
<tr><td style="padding:11px 14px;">
<div style="font-size:15px;font-weight:650;">
{{ f.item.name }}
<span style="font-weight:400;color:#6b6b66;font-size:12.5px;">
— {{ f.item.vendor }}</span>
{% if f.is_new_since_last_run %}
<span style="font-size:9.5px;font-weight:700;color:#2b5c8a;
border:1px solid #2b5c8a;border-radius:9px;padding:1px 6px;
text-transform:uppercase;letter-spacing:.05em;">new</span>
{% endif %}
</div>
{% if f.check.latest %}
<div style="font-family:Consolas,Menlo,monospace;font-size:12.5px;
color:#6b6b66;margin:5px 0 3px;">
{{ f.item.installed or 'unknown' }} &rarr;
<span style="color:#1a1a18;font-weight:700;">{{ f.check.latest }}</span>
{% if f.check.age_days is not none %}
&nbsp;({{ f.bump }}, {{ f.check.age_days|round(0)|int }} days old)
{% endif %}
</div>
{% endif %}
{% if v %}
{% if v.headline %}
<div style="font-size:13.5px;font-weight:600;margin:6px 0 3px;">
{{ v.headline }}</div>
{% endif %}
{% if v.reasons %}
<ul style="margin:4px 0 0;padding-left:18px;font-size:13px;line-height:1.5;">
{% for r in v.reasons[:4] %}<li style="margin:2px 0;">{{ r }}</li>{% endfor %}
{% if v.suggested_wait_days %}
<li style="margin:2px 0;"><strong>Revisit in about
{{ v.suggested_wait_days }} days.</strong></li>
{% endif %}
</ul>
{% endif %}
<div style="font-size:11px;color:#6b6b66;margin-top:7px;">
risk {{ v.risk_score|round(0)|int }}/100 &middot;
rule-based {{ v.heuristic_score|round(0)|int }}/100
({{ v.heuristic_recommendation|replace('-',' ') }}) &middot;
confidence {{ v.confidence }}
{% if v.heuristic_recommendation != v.recommendation
and v.heuristic_recommendation != 'unknown' %}
&middot; <strong style="color:#8a6100;">engines disagree</strong>
{% endif %}
</div>
{% endif %}
{% if f.evidence %}
<div style="font-size:11.5px;color:#6b6b66;margin-top:8px;
border-top:1px solid #ececE6;padding-top:6px;">
Evidence:
{% for e in f.evidence[:4] %}
<a href="{{ e.url }}" style="color:#2b5c8a;text-decoration:none;">
[{{ e.severity }}] {{ e.title[:70] }}</a>{% if not loop.last %} &middot; {% endif %}
{% endfor %}
</div>
{% endif %}
</td></tr>
</table>
{% endfor %}
</td></tr>
{% endif %}
{% endmacro %}
{% if groups.mismatched %}
<tr><td style="padding:20px 26px 0;">
<div style="font-size:11.5px;text-transform:uppercase;letter-spacing:.09em;
color:#a3231d;border-bottom:2px solid #a3231d;padding-bottom:6px;
margin-bottom:12px;">Version mismatch &mdash; broken now
({{ groups.mismatched|length }})</div>
{% for f in groups.mismatched %}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="margin-bottom:10px;border-left:3px solid #a3231d;
background:#fbe9e7;border-radius:0 4px 4px 0;">
<tr><td style="padding:10px 14px;">
<div style="font-size:15px;font-weight:650;">{{ f.item.name }}</div>
{% for c in f.coupling %}
{% if c.severity == 'blocking' %}
<div style="font-size:13px;margin-top:4px;">{{ c.message }}</div>
{% endif %}
{% endfor %}
</td></tr>
</table>
{% endfor %}
</td></tr>
{% endif %}
{{ section('Do not install', groups.avoid, '#a3231d') }}
{{ section('Hold off for now', groups.wait, '#8a6100') }}
{{ section('Cleared to update', groups.go_behind, '#1f7a3d') }}
{% if groups.no_baseline %}
<tr><td style="padding:22px 26px 0;">
<div style="font-size:11.5px;text-transform:uppercase;letter-spacing:.09em;
color:#6b6b66;border-bottom:1px solid #e0e0da;padding-bottom:6px;
margin-bottom:10px;">No installed version on record
({{ groups.no_baseline|length }})</div>
<div style="font-size:12.5px;color:#6b6b66;line-height:1.6;margin-bottom:8px;">
Current versions were found, but AVWatch does not know what you run, so
it cannot say whether you are behind:
</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="font-size:12.5px;">
{% for f in groups.no_baseline[:15] %}
<tr>
<td style="padding:3px 0;">{{ f.item.name }}</td>
<td style="padding:3px 0;text-align:right;font-family:Consolas,Menlo,monospace;">
<strong>{{ f.check.latest }}</strong></td>
</tr>
{% endfor %}
{% if groups.no_baseline|length > 15 %}
<tr><td colspan="2" style="padding:3px 0;color:#6b6b66;">
&hellip; and {{ groups.no_baseline|length - 15 }} more in the attached report.</td></tr>
{% endif %}
</table>
</td></tr>
{% endif %}
{% if groups.needs_setup %}
<tr><td style="padding:22px 26px 0;">
<div style="font-size:11.5px;text-transform:uppercase;letter-spacing:.09em;
color:#6b6b66;border-bottom:1px solid #e0e0da;padding-bottom:6px;
margin-bottom:10px;">Could not be checked
({{ groups.needs_setup|length }})</div>
<div style="font-size:13px;line-height:1.6;background:#eef3f8;
border-left:3px solid #2b5c8a;padding:11px 14px;
border-radius:4px;">
<strong>These {{ groups.needs_setup|length }} items are tracked</strong>
&mdash; AVWatch could not read their versions this run. This is a
configuration gap, not a gap in the inventory.
{% set by_reason = {} %}
{% for f in groups.needs_setup %}
{% set r = f.check.detail or 'unknown reason' %}
{% if by_reason.update({r: (by_reason.get(r, 0) + 1)}) %}{% endif %}
{% endfor %}
<ul style="margin:8px 0 0;padding-left:18px;">
{% for reason, n in by_reason.items() %}
<li style="margin:2px 0;">{{ reason }} &mdash; {{ n }} item(s)</li>
{% endfor %}
</ul>
</div>
</td></tr>
{% endif %}
{% if groups.manual %}
<tr><td style="padding:18px 26px 0;">
<div style="font-size:12.5px;color:#6b6b66;line-height:1.6;">
<strong>Nothing to track ({{ groups.manual|length }}):</strong>
{% for f in groups.manual[:8] %}{{ f.item.name }}{% if not loop.last %}, {% endif %}{% endfor %}
{%- if groups.manual|length > 8 %} and {{ groups.manual|length - 8 }} others{% endif %}
&mdash; no field-updatable firmware, so these will never produce an update.
</div>
</td></tr>
{% endif %}
{% if groups.errors %}
<tr><td style="padding:18px 26px 0;">
<div style="font-size:12.5px;color:#a3231d;line-height:1.6;">
<strong>{{ groups.errors|length }} source(s) failed this run</strong> —
likely a vendor page change. See the attached report for details.
</div>
</td></tr>
{% endif %}
<tr><td style="padding:24px 26px 26px;">
<div style="border-top:1px solid #e0e0da;padding-top:12px;font-size:11.5px;
color:#6b6b66;line-height:1.65;">
Full dashboard, including the up-to-date list and every evidence link, is
attached as an HTML file{% if report_path %} and saved at
<span style="font-family:Consolas,Menlo,monospace;">{{ report_path }}</span>{% endif %}.<br>
Community posts are unverified writing by strangers; AVWatch ranks and
summarises them, it does not vouch for them. Always have a rollback plan
before touching show-critical gear.<br>
Sent by AVWatch on {{ hostname }}.
</div>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+448
View File
@@ -0,0 +1,448 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AVWatch — {{ generated_local }}</title>
<style>
:root{
--bg:#f7f7f5; --panel:#ffffff; --ink:#1a1a18; --muted:#6b6b66;
--line:#e3e3de; --line-soft:#efefea;
--go:#1f7a3d; --go-bg:#e8f5ec;
--wait:#8a6100; --wait-bg:#fdf3dd;
--avoid:#a3231d; --avoid-bg:#fbe9e7;
--manual:#4a5878; --manual-bg:#eceff5;
--accent:#2b5c8a;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#16181a; --panel:#1e2124; --ink:#e8e8e4; --muted:#9a9a94;
--line:#31353a; --line-soft:#272b2f;
--go:#6ede93; --go-bg:#16301f;
--wait:#f0c46a; --wait-bg:#33280f;
--avoid:#ff8b80; --avoid-bg:#3a1a17;
--manual:#a8b8d8; --manual-bg:#1e2532;
--accent:#7fb2e0;
}
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}
.wrap{max-width:1080px;margin:0 auto;padding:28px 20px 80px}
header{border-bottom:2px solid var(--ink);padding-bottom:16px;margin-bottom:24px}
h1{margin:0 0 4px;font-size:26px;letter-spacing:-.02em}
.sub{color:var(--muted);font-size:13px}
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));
gap:10px;margin:20px 0 28px}
.tile{background:var(--panel);border:1px solid var(--line);border-radius:8px;
padding:12px 14px}
.tile .n{font-size:26px;font-weight:650;letter-spacing:-.02em;line-height:1.1}
.tile .l{font-size:11px;text-transform:uppercase;letter-spacing:.07em;
color:var(--muted);margin-top:3px}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.09em;
color:var(--muted);margin:34px 0 12px;padding-bottom:6px;
border-bottom:1px solid var(--line)}
h2 .count{color:var(--ink);font-weight:600}
.card{background:var(--panel);border:1px solid var(--line);border-radius:9px;
padding:15px 17px;margin-bottom:11px}
.card.avoid{border-left:4px solid var(--avoid)}
.card.wait{border-left:4px solid var(--wait)}
.card.go{border-left:4px solid var(--go)}
.card.manual-check{border-left:4px solid var(--manual)}
.chead{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline}
.name{font-weight:640;font-size:16px;flex:1 1 260px}
.vendor{color:var(--muted);font-weight:400;font-size:13px}
.badge{font-size:11px;font-weight:700;text-transform:uppercase;
letter-spacing:.06em;padding:3px 9px;border-radius:99px;white-space:nowrap}
.badge.go{background:var(--go-bg);color:var(--go)}
.badge.wait{background:var(--wait-bg);color:var(--wait)}
.badge.avoid{background:var(--avoid-bg);color:var(--avoid)}
.badge.manual-check{background:var(--manual-bg);color:var(--manual)}
.badge.unknown{background:var(--manual-bg);color:var(--manual)}
.tag{font-size:10.5px;color:var(--muted);border:1px solid var(--line);
padding:2px 7px;border-radius:99px;text-transform:uppercase;letter-spacing:.05em}
.tag.new{border-color:var(--accent);color:var(--accent);font-weight:650}
.tag.crit{border-color:var(--avoid);color:var(--avoid);font-weight:650}
.vers{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:13px;margin:9px 0 4px}
.vers .from{color:var(--muted)} .vers .arrow{color:var(--muted);margin:0 5px}
.vers .to{font-weight:650}
.headline{margin:8px 0 2px;font-size:14.5px;font-weight:560}
ul.reasons{margin:7px 0 0;padding-left:19px}
ul.reasons li{margin:3px 0;font-size:13.5px}
.scores{display:flex;gap:18px;flex-wrap:wrap;margin:11px 0 0;
font-size:11.5px;color:var(--muted)}
.meter{display:flex;align-items:center;gap:7px}
.bar{width:88px;height:5px;background:var(--line-soft);border-radius:99px;
overflow:hidden}
.bar i{display:block;height:100%;border-radius:99px}
.disagree{color:var(--wait);font-weight:650}
details.ev{margin-top:10px;border-top:1px solid var(--line-soft);padding-top:8px}
details.ev summary{cursor:pointer;font-size:12px;color:var(--muted);
text-transform:uppercase;letter-spacing:.06em}
.evrow{padding:7px 0;border-bottom:1px solid var(--line-soft);font-size:13px}
.evrow:last-child{border-bottom:0}
.evrow a{color:var(--accent);text-decoration:none}
.evrow a:hover{text-decoration:underline}
.evmeta{font-size:11px;color:var(--muted);margin-top:2px}
.sev{font-weight:700;text-transform:uppercase;font-size:10px;
letter-spacing:.05em;padding:1px 5px;border-radius:3px;margin-right:5px}
.sev.brick{background:var(--avoid-bg);color:var(--avoid)}
.sev.crash{background:var(--avoid-bg);color:var(--avoid)}
.sev.regression{background:var(--wait-bg);color:var(--wait)}
.sev.annoyance,.sev.info{background:var(--line-soft);color:var(--muted)}
.snip{color:var(--muted);font-size:12.5px;margin-top:3px}
table{width:100%;border-collapse:collapse;font-size:13.5px;
background:var(--panel);border:1px solid var(--line);border-radius:8px;
overflow:hidden}
th{text-align:left;font-size:11px;text-transform:uppercase;
letter-spacing:.06em;color:var(--muted);padding:9px 12px;
border-bottom:1px solid var(--line);font-weight:600}
td{padding:8px 12px;border-bottom:1px solid var(--line-soft)}
tr:last-child td{border-bottom:0}
td.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:12.5px}
.scroll{overflow-x:auto}
a.dl{color:var(--accent);text-decoration:none;font-size:12.5px}
a.dl:hover{text-decoration:underline}
.empty{color:var(--muted);font-style:italic;padding:10px 0;font-size:13.5px}
footer{margin-top:48px;padding-top:14px;border-top:1px solid var(--line);
color:var(--muted);font-size:11.5px;line-height:1.7}
code{background:var(--line-soft);padding:1px 5px;border-radius:3px;font-size:12px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>AVWatch</h1>
<div class="sub">
Update &amp; risk briefing for the production fleet ·
generated {{ generated_local }} ·
run <code>{{ run.run_id }}</code> ·
verdicts by {{ engine_label }}
</div>
</header>
<div class="tiles">
<div class="tile"><div class="n">{{ run.items_checked }}</div><div class="l">Tracked</div></div>
<div class="tile"><div class="n" style="color:var(--wait)">{{ run.updates_found }}</div><div class="l">Behind</div></div>
<div class="tile"><div class="n" style="color:var(--avoid)">{{ groups.avoid|length }}</div><div class="l">Do not install</div></div>
<div class="tile"><div class="n" style="color:var(--go)">{{ groups.go_behind|length }}</div><div class="l">Cleared to go</div></div>
<div class="tile"><div class="n" style="color:var(--accent)">{{ run.new_since_last }}</div><div class="l">New this run</div></div>
<div class="tile"><div class="n">{{ groups.no_baseline|length }}</div><div class="l">No baseline</div></div>
{% if groups.needs_setup %}
<div class="tile"><div class="n" style="color:var(--accent)">{{ groups.needs_setup|length }}</div><div class="l">Not checked</div></div>
{% endif %}
<div class="tile"><div class="n">{{ groups.manual|length }}</div><div class="l">No firmware</div></div>
<div class="tile"><div class="n" style="color:var(--avoid)">{{ run.errors }}</div><div class="l">Source errors</div></div>
</div>
{% macro card(f) %}
{% set v = f.verdict %}
<div class="card {{ v.recommendation if v else 'unknown' }}">
<div class="chead">
<div class="name">{{ f.item.name }}
<span class="vendor">— {{ f.item.vendor }}</span></div>
{% if f.is_new_since_last_run %}<span class="tag new">new</span>{% endif %}
{% if f.item.is_showstopper %}<span class="tag crit">{{ f.item.criticality }}</span>{% endif %}
<span class="tag">{{ f.item.category }}</span>
<span class="badge {{ v.recommendation if v else 'unknown' }}">
{{ (v.recommendation if v else 'unknown')|replace('-',' ') }}</span>
</div>
{% if f.check.latest %}
<div class="vers">
<span class="from">{{ f.item.installed or 'unknown' }}</span>
<span class="arrow">&rarr;</span>
<span class="to">{{ f.check.latest }}</span>
{% if f.bump not in ('none','unknown') %}
<span class="from">({{ f.bump }}{% if f.check.age_days is not none %},
released {{ f.check.age_days|round(0)|int }}d ago{% endif %})</span>
{% elif f.check.age_days is not none %}
<span class="from">(released {{ f.check.age_days|round(0)|int }}d ago)</span>
{% endif %}
</div>
{% endif %}
{% if v %}
{% if v.headline %}<div class="headline">{{ v.headline }}</div>{% endif %}
{% if v.reasons %}
<ul class="reasons">
{% for r in v.reasons %}<li>{{ r }}</li>{% endfor %}
{% if v.suggested_wait_days %}
<li><strong>Suggested: revisit in about {{ v.suggested_wait_days }} days.</strong></li>
{% endif %}
</ul>
{% endif %}
<div class="scores">
<span class="meter">Risk
<span class="bar"><i style="width:{{ v.risk_score }}%;background:{{
'var(--avoid)' if v.risk_score > 55 else ('var(--wait)' if v.risk_score >= 25 else 'var(--go)')
}}"></i></span>
{{ v.risk_score|round(0)|int }}/100</span>
<span class="meter">Rule-based
<span class="bar"><i style="width:{{ v.heuristic_score }}%;background:var(--muted)"></i></span>
{{ v.heuristic_score|round(0)|int }}/100
&rarr; {{ v.heuristic_recommendation|replace('-',' ') }}</span>
<span>Confidence {{ v.confidence }}</span>
{% if v.heuristic_recommendation != v.recommendation
and v.heuristic_recommendation not in ('unknown',) %}
<span class="disagree">&#9888; engines disagree — read the evidence</span>
{% endif %}
</div>
{% endif %}
{% if f.check.detail and f.check.status != 'ok' %}
<div class="snip" style="margin-top:8px">{{ f.check.detail }}</div>
{% endif %}
{% if f.coupling %}
<div style="margin-top:10px;padding:9px 12px;border-radius:5px;
background:{{ 'var(--avoid-bg)' if f.has_blocking_coupling else 'var(--line-soft)' }};
border-left:3px solid {{ 'var(--avoid)' if f.has_blocking_coupling else 'var(--muted)' }}">
<div style="font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;
color:var(--muted);margin-bottom:5px">Version dependencies
{% if f.item.update_group %} · group “{{ f.item.update_group }}”{% endif %}</div>
{% for c in f.coupling %}
<div style="font-size:13px;margin:3px 0">
<span class="sev {{ 'brick' if c.severity == 'blocking' else 'annoyance' }}"
>{{ c.severity }}</span>{{ c.message }}
</div>
{% endfor %}
</div>
{% endif %}
{% if f.evidence %}
<details class="ev"{% if v and v.recommendation == 'avoid' %} open{% endif %}>
<summary>{{ f.evidence|length }} community post(s) found — the raw evidence</summary>
{% for e in f.evidence %}
<div class="evrow">
<span class="sev {{ e.severity }}">{{ e.severity }}</span>
{% if e.url %}<a href="{{ e.url }}" target="_blank" rel="noopener">{{ e.title }}</a>
{% else %}{{ e.title }}{% endif %}
<div class="evmeta">{{ e.source }}{% if e.published %} ·
{{ e.published.strftime('%d %b %Y') }}{% endif %}{% if e.score is not none %} ·
score {{ e.score }}{% endif %} · keyword read: {{ e.sentiment }}</div>
{% if e.snippet %}<div class="snip">{{ e.snippet[:300] }}</div>{% endif %}
</div>
{% endfor %}
</details>
{% endif %}
{% if f.item.check_url or f.check.release_url %}
<div style="margin-top:9px">
<a class="dl" href="{{ f.check.release_url or f.item.check_url }}"
target="_blank" rel="noopener">Vendor download / release notes &rarr;</a>
</div>
{% endif %}
</div>
{% endmacro %}
{% if groups.mismatched %}
<h2 style="color:var(--avoid);border-bottom-color:var(--avoid)">
Version mismatch &mdash; broken now
<span class="count">({{ groups.mismatched|length }})</span></h2>
<p class="sub" style="margin:-4px 0 12px">
These are running versions that do not match gear they are <em>required</em>
to match. This is a present fault, not a future risk: the pair will not
link, the panel will not connect, or the wall will not light. Fix these
before considering any other update on this page.
</p>
{% for f in groups.mismatched %}{{ card(f) }}{% endfor %}
{% endif %}
{% if groups.avoid %}
<h2>Do not install <span class="count">({{ groups.avoid|length }})</span></h2>
{% for f in groups.avoid %}{{ card(f) }}{% endfor %}
{% endif %}
{% if groups.wait %}
<h2>Hold off for now <span class="count">({{ groups.wait|length }})</span></h2>
{% for f in groups.wait %}{{ card(f) }}{% endfor %}
{% endif %}
{% if groups.go_behind %}
<h2>Cleared to update <span class="count">({{ groups.go_behind|length }})</span></h2>
{% for f in groups.go_behind %}{{ card(f) }}{% endfor %}
{% endif %}
{% if groups.needs_setup %}
<h2>Could not be checked this run <span class="count">({{ groups.needs_setup|length }})</span></h2>
<div class="card manual-check" style="border-left-color:var(--accent)">
<div class="headline">These {{ groups.needs_setup|length }} items are tracked
&mdash; AVWatch just could not read their versions.</div>
<p style="margin:8px 0 0;font-size:13.5px">
This is a configuration gap, not a gap in the inventory. Every item below
is in <code>inventory.yaml</code> with a vendor, a search query and a
criticality already set. Fix the reason and they start reporting versions
on the next run.
</p>
{% set by_reason = {} %}
{% for f in groups.needs_setup %}
{% set r = f.check.detail or 'unknown reason' %}
{% if by_reason.update({r: (by_reason.get(r, []) + [f])}) %}{% endif %}
{% endfor %}
<ul class="reasons" style="margin-top:10px">
{% for reason, fs in by_reason.items() %}
<li><strong>{{ reason }}</strong><br>
<span style="color:var(--muted);font-size:12.5px">affects
{{ fs|length }} item(s)</span></li>
{% endfor %}
</ul>
</div>
<div class="scroll"><table>
<thead><tr><th>Item</th><th>Vendor</th><th>Category</th>
<th>Criticality</th><th>Why not checked</th></tr></thead>
<tbody>
{% for f in groups.needs_setup %}
<tr>
<td>{{ f.item.name }}</td>
<td>{{ f.item.vendor }}</td>
<td>{{ f.item.category }}</td>
<td>{{ f.item.criticality }}</td>
<td style="color:var(--muted)">{{ f.check.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% endif %}
{% if groups.no_baseline %}
<h2>No installed version on record <span class="count">({{ groups.no_baseline|length }})</span></h2>
<p class="sub" style="margin:-4px 0 12px">
AVWatch found the current version for these, but has no record of what you
actually run — so it cannot tell you whether you are behind. Record each one
and it never has to ask again. This is the single highest-value thing to
fix in this report.
</p>
<div class="scroll"><table>
<thead><tr><th>Item</th><th>Latest available</th><th>Released</th>
<th>Record what you run</th></tr></thead>
<tbody>
{% for f in groups.no_baseline %}
<tr>
<td>{{ f.item.name }}<br>
<span class="vendor">{{ f.item.vendor }}</span></td>
<td class="mono"><strong>{{ f.check.latest }}</strong></td>
<td>{% if f.check.released %}{{ f.check.released.strftime('%d %b %Y') }}
{% else %}—{% endif %}</td>
<td><code>avwatch set-installed {{ f.item.id }} &lt;version&gt;</code></td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% endif %}
{% if groups.manual %}
<h2>Nothing to track <span class="count">({{ groups.manual|length }})</span></h2>
<p class="sub" style="margin:-4px 0 12px">
These have no field-updatable firmware or software at all &mdash; passive or
analogue gear. Listed for completeness so the inventory matches the room;
they will never produce an update.
</p>
<div class="scroll"><table>
<thead><tr><th>Item</th><th>Vendor</th><th>We think we run</th>
<th>Criticality</th><th>Where to look</th></tr></thead>
<tbody>
{% for f in groups.manual %}
<tr>
<td>{{ f.item.name }}</td>
<td>{{ f.item.vendor }}</td>
<td class="mono">{{ f.item.installed or '—' }}</td>
<td>{{ f.item.criticality }}</td>
<td>{% if f.item.check_url %}<a class="dl" href="{{ f.item.check_url }}"
target="_blank" rel="noopener">vendor page &rarr;</a>{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% endif %}
{% if groups.errors %}
<h2>Source errors <span class="count">({{ groups.errors|length }})</span></h2>
<p class="sub" style="margin:-4px 0 12px">
A check that used to work and now fails almost always means the vendor
changed their page. Fix the <code>pattern</code> for that item in
<code>inventory.yaml</code>.
</p>
<div class="scroll"><table>
<thead><tr><th>Item</th><th>Source</th><th>What went wrong</th></tr></thead>
<tbody>
{% for f in groups.errors %}
<tr><td>{{ f.item.name }}</td><td class="mono">{{ f.item.source_kind }}</td>
<td>{{ f.check.detail }}</td></tr>
{% endfor %}
</tbody>
</table></div>
{% endif %}
{% if groups.frozen %}
<h2>Frozen by policy <span class="count">({{ groups.frozen|length }})</span></h2>
<p class="sub" style="margin:-4px 0 12px">
Deliberately held at their current version via <code>freeze: true</code>.
Listed so the freeze stays visible, not because anything needs doing.
</p>
<div class="scroll"><table>
<thead><tr><th>Item</th><th>Held at</th><th>Available</th></tr></thead>
<tbody>
{% for f in groups.frozen %}
<tr><td>{{ f.item.name }}</td>
<td class="mono">{{ f.item.installed or '—' }}</td>
<td class="mono">{{ f.check.latest or '—' }}</td></tr>
{% endfor %}
</tbody>
</table></div>
{% endif %}
{% if groups.current %}
<h2>Up to date <span class="count">({{ groups.current|length }})</span></h2>
<div class="scroll"><table>
<thead><tr><th>Item</th><th>Vendor</th><th>Version</th><th>Category</th></tr></thead>
<tbody>
{% for f in groups.current %}
<tr><td>{{ f.item.name }}</td><td>{{ f.item.vendor }}</td>
<td class="mono">{{ f.check.latest }}</td><td>{{ f.item.category }}</td></tr>
{% endfor %}
</tbody>
</table></div>
{% endif %}
{% if groups.ahead %}
<h2>Installed version is ahead of the public release <span class="count">({{ groups.ahead|length }})</span></h2>
<div class="scroll"><table>
<thead><tr><th>Item</th><th>Inventory says</th><th>Newest public</th></tr></thead>
<tbody>
{% for f in groups.ahead %}
<tr><td>{{ f.item.name }}</td><td class="mono">{{ f.item.installed }}</td>
<td class="mono">{{ f.check.latest }}</td></tr>
{% endfor %}
</tbody>
</table></div>
{% endif %}
<footer>
<strong>How to read this.</strong> Every verdict shows two independent
scores: the reasoning verdict and a crude rule-based one. When they
disagree, the evidence links are the tiebreaker — that is what they are
there for.<br>
Community posts are unverified writing by strangers. AVWatch ranks and
summarises them; it does not vouch for them.<br>
Nothing here is a substitute for a rollback plan and a rehearsal before a
service.<br>
Sources this run: {{ run.http_requests }} HTTP requests, {{ run.cache_hits }}
served from cache.
Record what you actually did with
<code>avwatch mark &lt;item-id&gt; installed</code> so future briefings stay
accurate.
</footer>
</div>
</body>
</html>