webapp: imaging progress dashboard + serial column on reports list

Adds end-to-end progress tracking for PXE imaging sessions and surfaces
each Blancco report's BIOS serial in the report list.

webapp:
  * services/imaging_status.py - JSON-per-serial state store under
    IMAGING_DIR (default /var/log/pxe-imaging). Atomic write via
    tempfile + rename. log_tail capped at 50 lines. Merges partial
    updates so clients can post just the current_stage tick.
  * config.py - new IMAGING_DIR env-overridable path.
  * services/csrf.py - explicit exempt list for machine-to-machine
    endpoints; /imaging/status is the first entry. Air-gapped LAN;
    trust-by-network for client posts.
  * app.py - four new routes:
      GET  /imaging               dashboard (renders all sessions)
      POST /imaging/status        client status push (JSON body)
      GET  /imaging/<serial>.json raw session JSON for ad-hoc polling
      POST /imaging/delete/<s>    clear a session from the dashboard
    Also parses each Blancco XML in the /reports list to surface
    system.serial + system.model columns.
  * templates/imaging.html - Bootstrap dashboard with per-session
    cards (state badge, progress bar, stage idx/total, mac, elapsed,
    log tail). meta http-equiv refresh=5 for auto-tick.
  * templates/base.html - new "Imaging Progress" nav entry.
  * templates/reports.html - Serial + Model columns added.

playbook:
  * shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1 - new helper.
    Dot-source this then call Send-PxeStatus -Stage X -StageIndex N
    -StageTotal M from any stage script. BIOS serial via CIM, MAC via
    Get-NetAdapter, pctype + machinenumber from C:\Enrollment.
    Failures are swallowed to a local log so a network blip doesn't
    block imaging.
  * shopfloor-setup/Run-ShopfloorSetup.ps1 - dot-sources helper +
    posts at three coarse milestones (start, PPKG enrollment,
    handoff to Monitor-IntuneProgress).
  * shopfloor-setup/gea-shopfloor-keyence/09-Setup-Keyence.ps1 -
    posts at session start + after Install-FromManifest with
    succeeded/failed status derived from $rc. Other 09-Setup-*.ps1
    scripts can follow the same pattern.

ID is BIOS serial (stable across WinPE -> Windows transition and
across reboots, unlike hostname which is random pre-PPKG). Operator
already knows the serial of the bay they imaged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-13 10:07:18 -04:00
parent 1d3f21f814
commit 9122b28c31
10 changed files with 478 additions and 10 deletions

View File

@@ -26,6 +26,21 @@ Write-Host " Transcript: $transcriptPath"
Write-Host "================================================================"
Write-Host ""
# Imaging-progress reporter. Posts coarse stage updates to the PXE webapp
# at http://10.9.100.1:9009/imaging/status so the operator can watch
# progress in a browser. Best-effort: failures never block imaging.
$pxeStatusLib = Join-Path $PSScriptRoot 'Shopfloor\lib\Send-PxeStatus.ps1'
if (Test-Path $pxeStatusLib) {
try { . $pxeStatusLib } catch { Write-Warning "Send-PxeStatus load failed: $_" }
}
function Report-Stage {
param([string]$Stage, [int]$Index, [int]$Total = 8, [string]$Status = 'in_progress', [string]$Error_ = '')
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
Send-PxeStatus -Stage $Stage -StageIndex $Index -StageTotal $Total -Status $Status -Error_ $Error_
}
}
Report-Stage -Stage 'Run-ShopfloorSetup: starting' -Index 1
# AutoLogonCount is NOT set here. Previously we bumped it to 99/4, but
# Windows decrements it per-logon and at 0 clears AutoAdminLogon -- which
# nukes the lockdown-configured ShopFloor autologon later in the chain.
@@ -452,6 +467,7 @@ if (-not $hasWifi -and -not $hasDefaultRoute) {
$enrollScript = Join-Path $enrollDir 'run-enrollment.ps1'
if (Test-Path -LiteralPath $enrollScript) {
Write-Host ""
Report-Stage -Stage 'Run-ShopfloorSetup: PPKG enrollment' -Index 4
Write-Host "=== Running enrollment (PPKG install) ==="
Write-Host "NOTE: PPKG schedules a near-immediate reboot. We will cancel"
Write-Host " it and hand off to Monitor-IntuneProgress -PostPpkg, which"
@@ -466,6 +482,7 @@ if (Test-Path -LiteralPath $enrollScript) {
# persistent @logon sync_intune task fires on the next boot to resume
# tracking through device-category-assignment + lockdown.
Write-Host ""
Report-Stage -Stage 'Run-ShopfloorSetup: handoff to Monitor-IntuneProgress' -Index 7
Write-Host "=== Handing off to Monitor-IntuneProgress -PostPpkg ==="
cmd /c "shutdown /a 2>nul" | Out-Null
$monitor = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1'

View File

@@ -0,0 +1,77 @@
# Send-PxeStatus.ps1
# Posts a coarse-grained progress update to the PXE webapp's /imaging/status
# endpoint. Never blocks imaging on a failed push (try/catch with no rethrow).
# Air-gapped LAN; no auth - the webapp endpoint is CSRF-exempt for machine
# clients (see services/csrf.py).
function Send-PxeStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]
[string]$Stage,
[int]$StageIndex = 0,
[int]$StageTotal = 0,
[ValidateSet('in_progress','succeeded','failed')]
[string]$Status = 'in_progress',
[string]$Error_ = '',
[string[]]$LogLines = @(),
[string]$PxeServer = '10.9.100.1',
[int]$Port = 9009,
[int]$TimeoutSec = 5
)
# Get serial early; if WMI fails we still want to push under a best-effort id.
$serial = $null
try {
$serial = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber
} catch {
try { $serial = (Get-WmiObject -Class Win32_BIOS -ErrorAction Stop).SerialNumber } catch { }
}
if ([string]::IsNullOrWhiteSpace($serial)) { $serial = $env:COMPUTERNAME }
$serial = ($serial -as [string]).Trim()
# MAC of the first up wired adapter (best-effort; PXE servers see this MAC in DHCP).
$mac = $null
try {
$nic = Get-NetAdapter -Physical | Where-Object { $_.Status -eq 'Up' -and $_.MediaType -eq '802.3' } | Select-Object -First 1
if ($nic) { $mac = $nic.MacAddress -replace '-', ':' }
} catch { }
# Enrollment context files (present after startnet.cmd stages them).
$pctype = ''
$machno = ''
if (Test-Path 'C:\Enrollment\pc-type.txt') { $pctype = (Get-Content 'C:\Enrollment\pc-type.txt' -ErrorAction SilentlyContinue | Select-Object -First 1).Trim() }
if (Test-Path 'C:\Enrollment\machine-number.txt') { $machno = (Get-Content 'C:\Enrollment\machine-number.txt' -ErrorAction SilentlyContinue | Select-Object -First 1).Trim() }
$payload = @{
serial = $serial
mac = $mac
hostname_target = $env:COMPUTERNAME
pctype = $pctype
machinenumber = $machno
current_stage = $Stage
stage_index = $StageIndex
stage_total = $StageTotal
status = $Status
}
if ($Error_) { $payload.error = $Error_ }
if ($LogLines) { $payload.log_lines = $LogLines }
$body = $payload | ConvertTo-Json -Compress
$uri = "http://${PxeServer}:${Port}/imaging/status"
try {
Invoke-WebRequest -Uri $uri -Method POST `
-Body $body -ContentType 'application/json' `
-UseBasicParsing -TimeoutSec $TimeoutSec `
-ErrorAction Stop | Out-Null
} catch {
# Never block imaging on a failed status push. Write to local log only.
try {
$logDir = 'C:\Logs'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
"$(Get-Date -Format s) Send-PxeStatus failed: $($_.Exception.Message)" |
Out-File -FilePath (Join-Path $logDir 'send-pxe-status.log') -Append -Encoding utf8
} catch { }
}
}

View File

@@ -44,6 +44,12 @@ Write-KeyenceLog "Running as: $([System.Security.Principal.WindowsIdentity]::Get
Write-KeyenceLog "Script root: $PSScriptRoot"
Write-KeyenceLog "================================================================"
# Status push to PXE webapp - best-effort, never blocks imaging.
$pxeStatusLib = Join-Path $PSScriptRoot '..\Shopfloor\lib\Send-PxeStatus.ps1'
if (Test-Path $pxeStatusLib) {
try { . $pxeStatusLib; Send-PxeStatus -Stage '09-Setup-Keyence: starting' -StageIndex 5 -StageTotal 8 } catch { }
}
# Diagnostic dump
foreach ($file in @('pc-type.txt','pc-subtype.txt','machine-number.txt')) {
$path = "C:\Enrollment\$file"
@@ -69,6 +75,23 @@ if (-not (Test-Path $manifestPath)) {
Write-KeyenceLog "Install-FromManifest returned $rc"
}
# ============================================================================
# Step 2: OpenText auto-start at login (HostExplorer "WJ Shopfloor" session)
# ============================================================================
$autoStartLib = Join-Path $PSScriptRoot '..\Shopfloor\lib\Set-OpenTextAutoStart.ps1'
if (Test-Path -LiteralPath $autoStartLib) {
Write-KeyenceLog "Calling $autoStartLib"
& $autoStartLib
} else {
Write-KeyenceLog "Set-OpenTextAutoStart.ps1 not found at $autoStartLib - OpenText auto-start NOT configured" 'WARN'
}
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
$finalStatus = if ($rc -eq 0) { 'in_progress' } else { 'failed' }
$finalErr = if ($rc -ne 0) { "Install-FromManifest exit $rc" } else { '' }
Send-PxeStatus -Stage '09-Setup-Keyence: complete' -StageIndex 6 -StageTotal 8 -Status $finalStatus -Error_ $finalErr
}
Write-KeyenceLog "================================================================"
Write-KeyenceLog "=== Keyence Setup session end ==="
Write-KeyenceLog "================================================================"

View File

@@ -33,7 +33,7 @@ from lxml import etree
from werkzeug.utils import secure_filename
import config
from services import blancco_report, deploy, fs, images, system, unattend, wim
from services import blancco_report, deploy, fs, images, imaging_status, system, unattend, wim
from services.audit import audit
from services.csrf import init_csrf
@@ -363,15 +363,32 @@ def blancco_reports():
if os.path.isdir(config.BLANCCO_REPORTS):
for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True):
fpath = os.path.join(config.BLANCCO_REPORTS, f)
if os.path.isfile(fpath):
stat = os.stat(fpath)
ext = os.path.splitext(f)[1].lower()
reports.append({
"filename": f,
"size": stat.st_size,
"modified": stat.st_mtime,
"type": ext.lstrip(".").upper() or "FILE",
})
if not os.path.isfile(fpath):
continue
stat = os.stat(fpath)
ext = os.path.splitext(f)[1].lower()
# Surface BIOS serial + system model so operators can find a
# report for a specific bay without opening each one. Parse
# cost is one XML walk per .xml report (~50KB-3MB each;
# negligible at fleet sizes).
serial = ""
model = ""
if ext == ".xml":
try:
data = blancco_report.parse(fpath)
sysinfo = (data.get("hardware") or {}).get("system") or {}
serial = sysinfo.get("serial", "") or ""
model = sysinfo.get("model", "") or ""
except Exception:
pass
reports.append({
"filename": f,
"size": stat.st_size,
"modified": stat.st_mtime,
"type": ext.lstrip(".").upper() or "FILE",
"serial": serial,
"model": model,
})
return render_template(
"reports.html",
reports=reports,
@@ -421,6 +438,50 @@ def blancco_delete_report(filename):
return redirect(url_for("blancco_reports"))
# ---------------------------------------------------------------------------
# Routes - Imaging Progress
# ---------------------------------------------------------------------------
@app.route("/imaging")
def imaging_dashboard():
sessions = imaging_status.list_sessions()
return render_template("imaging.html", sessions=sessions)
@app.route("/imaging/status", methods=["POST"])
def imaging_status_post():
# CSRF-exempt machine-to-machine endpoint; see services/csrf.py exempt list.
payload = request.get_json(silent=True) or {}
if not payload.get("serial"):
return jsonify({"error": "missing serial"}), 400
try:
state = imaging_status.update_session(payload)
except Exception as ex:
audit("IMAGING_STATUS_ERROR", str(ex))
return jsonify({"error": str(ex)}), 500
return jsonify({"ok": True, "serial": state["serial"]}), 200
@app.route("/imaging/<serial>.json")
def imaging_session_json(serial):
serial = secure_filename(serial)
s = imaging_status.get_session(serial)
if not s:
return jsonify({"error": "not found"}), 404
return jsonify(s)
@app.route("/imaging/delete/<serial>", methods=["POST"])
def imaging_delete_session(serial):
serial = secure_filename(serial)
if imaging_status.delete_session(serial):
audit("IMAGING_DELETE", serial)
flash(f"Cleared imaging session {serial}.", "success")
else:
flash(f"Session not found: {serial}", "danger")
return redirect(url_for("imaging_dashboard"))
# ---------------------------------------------------------------------------
# Routes - Enrollment Packages
# ---------------------------------------------------------------------------

View File

@@ -20,6 +20,7 @@ SHARED_DIR = os.path.join(SAMBA_SHARE, "_shared")
WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log")
IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging")
# --- Flask -------------------------------------------------------------------
FLASK_SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod")

View File

@@ -4,6 +4,13 @@ import secrets
from flask import abort, request, session
# Endpoint paths exempt from CSRF (machine-to-machine POSTs from PXE clients
# that have no browser session to carry a token). Air-gapped LAN; treated as
# trust-by-network. Keep the list short and explicit.
CSRF_EXEMPT_PATHS = {
"/imaging/status",
}
def generate_csrf_token():
"""Return the CSRF token for the current session, creating one if needed."""
@@ -19,6 +26,8 @@ def init_csrf(app):
def _validate_csrf():
if request.method != "POST":
return
if request.path in CSRF_EXEMPT_PATHS:
return
token = request.form.get("_csrf_token") or request.headers.get("X-CSRF-Token")
if not token or token != generate_csrf_token():
abort(403)

View File

@@ -0,0 +1,144 @@
"""PXE imaging progress tracking.
Imaging clients POST coarse-grained status updates to /imaging/status as
they progress through WIM apply -> drivers -> first boot -> PPKG -> per-PC
shopfloor setup. Each session is keyed by the BIOS serial number (stable
across the WinPE -> Windows transition; survives a target hostname change).
Storage is one JSON file per serial under IMAGING_DIR. Atomic write via
tempfile + rename. Reads merge the new payload into existing state so
clients can post partial updates (just the current_stage + log_tail tick).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
from datetime import datetime, timezone
from typing import Optional
import config
# How many recent log lines to keep per session.
LOG_TAIL_MAX = 50
# Sessions older than this are considered stale and dropped from the dashboard
# "active" list. Still readable individually.
ACTIVE_WINDOW_HOURS = 6
# Filenames are derived from serial; sanitize to avoid path traversal /
# weird filesystem characters. Anything outside [A-Za-z0-9._-] becomes _.
_SAFE_SERIAL = re.compile(r"[^A-Za-z0-9._-]")
def _ensure_dir():
os.makedirs(config.IMAGING_DIR, exist_ok=True)
def _path_for(serial: str) -> str:
safe = _SAFE_SERIAL.sub("_", serial.strip()) or "unknown"
return os.path.join(config.IMAGING_DIR, f"{safe}.json")
def _now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def update_session(payload: dict) -> dict:
"""Merge `payload` into the JSON file for payload['serial'].
payload must include 'serial'. All other fields are optional; whatever is
present overwrites the existing field. log_tail is appended to (capped).
Returns the resulting full state.
"""
serial = (payload.get("serial") or "").strip()
if not serial:
raise ValueError("payload missing 'serial'")
_ensure_dir()
path = _path_for(serial)
state: dict = {}
if os.path.isfile(path):
try:
with open(path, "r") as f:
state = json.load(f)
except (json.JSONDecodeError, OSError):
state = {}
if not state:
state = {
"serial": serial,
"started_at": _now_iso(),
"log_tail": [],
}
# Append any new log lines (preserve old; cap to LOG_TAIL_MAX).
new_lines = payload.pop("log_lines", None)
if new_lines:
if isinstance(new_lines, str):
new_lines = [new_lines]
tail = list(state.get("log_tail", []))
tail.extend(new_lines)
state["log_tail"] = tail[-LOG_TAIL_MAX:]
for key, value in payload.items():
if value is None or value == "":
continue
state[key] = value
state["last_updated"] = _now_iso()
if "status" not in state:
state["status"] = "in_progress"
fd, tmp = tempfile.mkstemp(dir=config.IMAGING_DIR, prefix=".tmp-", suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(state, f, indent=2)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
return state
def list_sessions() -> list[dict]:
"""Return all sessions sorted by last_updated desc."""
_ensure_dir()
out: list[dict] = []
for name in os.listdir(config.IMAGING_DIR):
if not name.endswith(".json") or name.startswith(".tmp-"):
continue
path = os.path.join(config.IMAGING_DIR, name)
try:
with open(path, "r") as f:
out.append(json.load(f))
except (json.JSONDecodeError, OSError):
continue
out.sort(key=lambda s: s.get("last_updated", ""), reverse=True)
return out
def get_session(serial: str) -> Optional[dict]:
path = _path_for(serial)
if not os.path.isfile(path):
return None
try:
with open(path, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def delete_session(serial: str) -> bool:
path = _path_for(serial)
if not os.path.isfile(path):
return False
try:
os.unlink(path)
return True
except OSError:
return False

View File

@@ -121,6 +121,12 @@
Image Import
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'imaging_dashboard' %}active{% endif %}"
href="{{ url_for('imaging_dashboard') }}">
Imaging Progress
</a>
</li>
</ul>
<div class="nav-section-divider"></div>

View File

@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}Imaging Progress - PXE Server Manager{% endblock %}
{% block extra_head %}
<meta http-equiv="refresh" content="5">
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-0">Imaging Progress</h2>
<small class="text-muted">Auto-refresh 5s. POST updates from imaging clients arrive at <code>/imaging/status</code>.</small>
</div>
<span class="badge bg-secondary fs-6">{{ sessions|length }} session{{ 's' if sessions|length != 1 }}</span>
</div>
{% if not sessions %}
<div class="card">
<div class="card-body text-center text-muted py-5">
<p class="mb-1">No imaging sessions yet.</p>
<p class="small mb-0">A PC being imaged will post status here.
Until then, nothing to show.</p>
</div>
</div>
{% endif %}
{% for s in sessions %}
{% set stage_idx = s.stage_index | int(0) %}
{% set stage_total = s.stage_total | int(0) %}
{% set pct = 100 if s.status == 'succeeded' else ((stage_idx / stage_total * 100) | round(0, 'floor')) if stage_total > 0 else 0 %}
{% set is_failed = s.status == 'failed' %}
{% set is_done = s.status == 'succeeded' %}
{% set border = 'danger' if is_failed else ('success' if is_done else 'primary') %}
<div class="card border-{{ border }} mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>{{ s.serial or '(no serial)' }}</strong>
{% if s.hostname_target %}<code class="ms-2 small text-muted">{{ s.hostname_target }}</code>{% endif %}
{% if s.pctype %}<span class="badge bg-info text-dark ms-2">{{ s.pctype }}</span>{% endif %}
{% if s.machinenumber %}<span class="badge bg-secondary ms-1">#{{ s.machinenumber }}</span>{% endif %}
</div>
<span class="badge bg-{{ border }}">{{ s.status or 'in_progress' }}</span>
</div>
<div class="card-body">
<div class="row g-3 mb-3 small">
<div class="col-md-3">
<div class="text-muted">Current stage</div>
<div><strong>{{ s.current_stage or '-' }}</strong></div>
</div>
<div class="col-md-2">
<div class="text-muted">Stage</div>
<div>{{ stage_idx }} / {{ stage_total or '?' }}</div>
</div>
<div class="col-md-2">
<div class="text-muted">Started</div>
<div><code>{{ s.started_at or '-' }}</code></div>
</div>
<div class="col-md-3">
<div class="text-muted">Last update</div>
<div><code>{{ s.last_updated or '-' }}</code></div>
</div>
<div class="col-md-2">
<div class="text-muted">MAC</div>
<div><code class="small">{{ s.mac or '-' }}</code></div>
</div>
</div>
<div class="progress mb-2" style="height: 1.4rem;">
<div class="progress-bar bg-{{ border }} {% if not is_done and not is_failed %}progress-bar-striped progress-bar-animated{% endif %}"
role="progressbar"
style="width: {{ pct }}%;"
aria-valuenow="{{ pct }}"
aria-valuemin="0"
aria-valuemax="100">{{ pct }}%</div>
</div>
{% if s.error %}
<div class="alert alert-danger small py-2 mb-2">
<strong>Error:</strong> {{ s.error }}
</div>
{% endif %}
{% if s.log_tail %}
<details>
<summary class="text-muted small">Log tail ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary>
<pre class="bg-light p-2 mt-2 small mb-0" style="max-height: 12rem; overflow-y: auto;">{% for line in s.log_tail %}{{ line }}
{% endfor %}</pre>
</details>
{% endif %}
<div class="mt-3 text-end">
<form method="post" action="{{ url_for('imaging_delete_session', serial=s.serial) }}" style="display: inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-secondary"
onclick="return confirm('Clear session {{ s.serial }}?');">
Clear
</button>
</form>
</div>
</div>
</div>
{% endfor %}
<div class="card mt-3">
<div class="card-body small text-muted">
<strong>How to push status from an imaging client:</strong>
<pre class="mb-0 mt-2">POST http://10.9.100.1:9009/imaging/status
Content-Type: application/json
{
"serial": "4HBLF33",
"mac": "e4:54:e8:dc:b1:f0",
"hostname_target": "EDNMG3D4",
"pctype": "gea-shopfloor-keyence",
"machinenumber": "9999",
"current_stage": "Run-ShopfloorSetup: 09-Setup-Keyence",
"stage_index": 7,
"stage_total": 9,
"status": "in_progress",
"log_lines": ["last few log lines from the stage"]
}</pre>
</div>
</div>
{% endblock %}

View File

@@ -17,6 +17,8 @@
<thead class="table-light">
<tr>
<th>Filename</th>
<th>Serial</th>
<th>Model</th>
<th>Type</th>
<th>Size</th>
<th>Date</th>
@@ -27,6 +29,8 @@
{% for r in reports %}
<tr>
<td><code>{{ r.filename }}</code></td>
<td>{% if r.serial %}<code>{{ r.serial }}</code>{% else %}<span class="text-muted small">-</span>{% endif %}</td>
<td class="small">{{ r.model or '-' }}</td>
<td><span class="badge bg-info text-dark">{{ r.type }}</span></td>
<td>
{% if r.size > 1048576 %}