heartwood every commit a ring

Redesign dashboard with warm-earth palette and Monaspace Argon

2d8c04d0 by Isaac Bythewood ยท 25 days ago

Redesign dashboard with warm-earth palette and Monaspace Argon

Mossy forest green + amber on deep charcoal, aligned to blog.bythewood.me.
Rework property page with live-signals tiles, Lighthouse score cards, styled
Chart.js panels, Lighthouse performance breakdown, and compact insight tables.
Add recent-uptime bar strips and rolling uptime to the properties dashboard.
Swap ๐Ÿš€ favicon for an EKG-waveform SVG matching the in-app logo.
Add printable property report template.

Fix OG screenshot rendering at narrow mobile-like viewport โ€” Chromium's
--window-size uses comma, not x, so 1280x720 was silently falling back to
a default narrow viewport.
modified accounts/templates/accounts/profile.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -14,36 +14,28 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>    </div>  </div>    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">operator ยท {{ request.user.username }}</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Account identifiers and alert channels. Changes save on submit.</p>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <form method="POST">        {% csrf_token %}        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" name="username" id="id_username" placeholder="Username" value="{{ form.username.value }}" />          <label for="id_username" class="form-label">Username</label>          <label for="id_username">Username</label>        </div>        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="Email" value="{{ form.email.value }}" />          <label for="id_email" class="form-label">Email</label>          <input type="email" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="Email" value="{{ form.email.value }}" />          <label for="id_email">Email</label>        </div>        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.discord_webhook_url.errors %}is-invalid{% endif %}" name="discord_webhook_url" id="id_discord_webhook_url" placeholder="Discord webhook url" value="{{ form.discord_webhook_url.value|default:"" }}" />          <label for="id_discord_webhook_url" class="form-label">Discord webhook url</label>          <input type="text" class="form-control {% if form.discord_webhook_url.errors %}is-invalid{% endif %}" name="discord_webhook_url" id="id_discord_webhook_url" placeholder="Discord webhook URL" value="{{ form.discord_webhook_url.value|default:'' }}" />          <label for="id_discord_webhook_url">Discord webhook URL</label>        </div>        <div class="row">          <div class="col">            <button type="submit" class="btn btn-primary">              Update profile            </button>            <a href="{% url 'password_change' %}" class="btn btn-secondary">              Password change            </a>          </div>        <div class="d-flex gap-2">          <button type="submit" class="btn btn-primary">Save โ†’</button>          <a href="{% url 'password_change' %}" class="btn btn-outline-light">Change password</a>        </div>      </form>    </div>
modified bun.lock
@@ -4,6 +4,7 @@  "workspaces": {    "": {      "dependencies": {        "@fontsource/monaspace-argon": "^5.2.5",        "@popperjs/core": "^2.11.5",        "bootstrap": "^5.1.3",        "chart.js": "^3.7.1",
@@ -72,6 +73,8 @@    "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],    "@fontsource/monaspace-argon": ["@fontsource/monaspace-argon@5.2.5", "", {}, "sha512-EJ+jq1Smm3BB+8RK/gwB1uzjrSKdycZkKm8OZGCYvqJiTChKcGe7b2lmj0PmQbb/+lAEdCBIAcFLlPaWhtRANA=="],    "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],    "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
modified package.json
@@ -6,6 +6,7 @@    "build": "vite build"  },  "dependencies": {    "@fontsource/monaspace-argon": "^5.2.5",    "@popperjs/core": "^2.11.5",    "bootstrap": "^5.1.3",    "chart.js": "^3.7.1",
deleted pages/static_src/images/home-hero.webp

binary file

modified pages/static_src/index.js
@@ -1,3 +1 @@import "./styles/pages.css";import "./images/home-hero.webp";import "./styles/pages.scss";
deleted pages/static_src/styles/pages.css
@@ -1,10 +0,0 @@.home-hero {  background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)),    var(--background-image);  background-size: cover;  background-position: center;  background-repeat: no-repeat;  overflow: hidden;  height: calc(100vh - 50px);  max-height: 1080px;}
added pages/static_src/styles/pages.scss
@@ -0,0 +1,154 @@@use "../../../status/static_src/styles/variables" as *;// โ”€โ”€ Home hero โ”€โ”€.hero {  position: relative;  background: #090806;  border-bottom: 1px solid rgba(107, 158, 120, 0.08);  padding: 4rem 0 4rem;  .hero-eyebrow {    display: inline-flex;    align-items: center;    gap: 0.55rem;    font-family: $font-family-monospace;    font-size: 0.72rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: rgba(201, 168, 76, 0.85);    background: rgba(201, 168, 76, 0.06);    border: 1px solid $amber-border;    padding: 0.35rem 0.75rem;    border-radius: 999px;    .eyebrow-dot {      width: 6px; height: 6px; border-radius: 50%;      background: $green;    }  }  .hero-title {    font-size: clamp(2rem, 5vw, 3.2rem);    font-weight: 700;    line-height: 1.1;    color: $white;    letter-spacing: -0.01em;    margin-top: 1.5rem;    .accent { color: $green-bright; }  }  .hero-sub {    color: #a09890;    font-size: 1rem;    line-height: 1.7;    margin-top: 1.25rem;    max-width: 620px;  }  .hero-ctas {    display: flex;    gap: 0.75rem;    flex-wrap: wrap;    margin-top: 1.75rem;    .btn {      font-size: 0.82rem;      letter-spacing: 0.04em;      padding: 0.6rem 1.1rem;    }  }}// โ”€โ”€ Feature tiles โ”€โ”€.feature {  background: #13120e;  border: 1px solid rgba(107, 158, 120, 0.08);  border-radius: $border-radius;  padding: 1.5rem;  height: 100%;  transition: border-color 250ms ease;  &:hover { border-color: rgba(107, 158, 120, 0.22); }  .feature-label {    font-family: $font-family-monospace;    font-size: 0.68rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: rgba(201, 168, 76, 0.75);  }  .feature-title {    color: $white;    font-size: 1.05rem;    font-weight: 600;    margin-top: 0.5rem;  }  .feature-desc {    color: #a09890;    font-size: 0.88rem;    line-height: 1.65;    margin-top: 0.6rem;    margin-bottom: 0;  }}// โ”€โ”€ Stat strip โ”€โ”€.stat-strip {  background: #0e0d0a;  border-top: 1px solid rgba(107, 158, 120, 0.06);  border-bottom: 1px solid rgba(107, 158, 120, 0.06);  padding: 1.5rem 0;  .stat { text-align: center; padding: 0 1rem; }  .stat-label {    font-family: $font-family-monospace;    font-size: 0.66rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: #665f56;  }  .stat-value {    font-size: 1.75rem;    font-weight: 700;    color: $green-bright;    letter-spacing: -0.01em;    line-height: 1.2;    margin-top: 0.2rem;  }}// โ”€โ”€ Changelog โ”€โ”€.changelog-entry {  display: grid;  grid-template-columns: 140px 1fr;  gap: 1.5rem;  padding: 1rem 0;  border-bottom: 1px solid rgba(107, 158, 120, 0.06);  @media (max-width: 575px) {    grid-template-columns: 1fr;    gap: 0.4rem;  }  .changelog-date {    font-family: $font-family-monospace;    font-size: 0.78rem;    color: rgba(201, 168, 76, 0.75);    letter-spacing: 0.04em;  }  ul { margin: 0; padding-left: 1.1rem; color: $gray-200; font-size: 0.9rem; }  li { margin-bottom: 0.3rem; }}
modified pages/templates/pages/changelog.html
@@ -1,4 +1,5 @@{% extends 'base.html' %}{% load static %}{% block extra_head %}
@@ -6,8 +7,13 @@{% endblock %}{% block extra_css %}<link href="{% static 'pages.css' %}" rel="stylesheet">{% endblock %}{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -19,104 +25,128 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>New features and changes to status, sometimes I'll tease something coming soon.</p>    <div class="col-lg-8 offset-lg-2">      <div class="section-label mb-2">release log</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">Shipped changes, incidents, and the occasional tease for something coming.</p>    </div>  </div>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2025-05-31</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Improve alert system with state tracking and recovery notifications</li>          </ul>        </div>  <div class="row mt-4">    <div class="col-lg-8 offset-lg-2">      <div class="changelog-entry">        <div class="changelog-date">2026-04-17</div>        <ul>          <li>Redesign the whole dashboard with a warm-earth palette โ€” mossy forest green and amber on deep charcoal, Monaspace Argon throughout</li>          <li>Rework the property page with live-signals tiles, Lighthouse score cards, styled Chart.js panels, and compact insight tables</li>          <li>Add recent-uptime bar strips and rolling uptime percentage to every property row on the dashboard</li>          <li>Surface the full Lighthouse performance breakdown โ€” weighted metric tiles and top savings opportunities, ranked by impact</li>          <li>Swap the ๐Ÿš€ favicon for a themed SVG tile matching the in-app logo</li>          <li>Update the chromium wrapper to also pick up <code>google-chrome</code> so the webdev container runs locally without extra setup</li>          <li>Fix logout 405 by switching to a POST form + CSRF (Django 5 <code>LogoutView</code> is POST-only)</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2026-04-16</div>        <ul>          <li>Add live crawl/lighthouse status panel with real-time recrawl and rerun buttons</li>          <li>Rewrite the SEO crawler in-process using <code>requests</code> + <code>BeautifulSoup</code> โ€” no more Scrapy subprocess</li>          <li>Harden the scheduler, alert state machine, and crawl pipeline against races and partial failures</li>          <li>Harden the Lighthouse runner and surface failure reasons in the UI</li>          <li>Migrate the frontend build from Webpack/Yarn to Vite/Bun</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2026-04-09</div>        <ul>          <li>Migrate Python package management from Pipenv/pyenv to <code>uv</code></li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-07-16</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Add docker init to handle lighthouse chrome zombies</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2026-04-03</div>        <ul>          <li>Bind the Docker port to localhost only โ€” host should proxy in</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-07-15</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Add check queue to be more resource friendly</li>            <li>Add lighthouse scores</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2025-06-01</div>        <ul>          <li>Refactor property email templates to share a base</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-07-05</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Improve property UI</li>            <li>Improve properties listing UI</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2025-05-31</div>        <ul>          <li>Improve alert system with state tracking and recovery notifications โ€” no more alert spam on flapping sites</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-07-04</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Add properties listing pagination</li>            <li>Add properties listing search</li>            <li>Improve properties listing UI</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-25</div>        <ul>          <li>Add recrawl button</li>          <li>Run crawler weekly on a schedule</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-06-26</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Add HSTS monitoring</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-23</div>        <ul>          <li>Add crawler insights โ€” title, description, canonical, OG tags, H1 extraction per page</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-06-25</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Add uptime chart</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-16</div>        <ul>          <li>Add docker init to handle Lighthouse Chrome zombies</li>        </ul>      </div>      <div class="row border-bottom py-2">        <div class="col-3">          <span class="badge bg-success">2022-06-19</span>        </div>        <div class="col-9">          <ul class="mb-0">            <li>Create status project</li>          </ul>        </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-15</div>        <ul>          <li>Add check queue to be more resource friendly</li>          <li>Add Lighthouse scores</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-07-04</div>        <ul>          <li>Add properties listing pagination</li>          <li>Add properties listing search</li>          <li>Improve properties listing UI</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-06-26</div>        <ul>          <li>Add HSTS monitoring</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-06-25</div>        <ul>          <li>Add uptime chart</li>        </ul>      </div>      <div class="changelog-entry">        <div class="changelog-date">2022-06-19</div>        <ul>          <li>Create status project</li>        </ul>      </div>    </div>  </div></div>
modified pages/templates/pages/home.html
@@ -16,87 +16,145 @@{% block main %}<div class="bg-dark mb-5 home-hero d-flex align-items-center" style="--background-image: url({% static 'images/home-hero.webp' %});"><section class="hero">  <div class="container">    <div class="row">      <div class="col-12 col-lg-10 col-xl-8">        <div class="fs-4 text-light">          <span class="badge bg-warning text-black">Alpha</span> Things are rapidly changing!      <div class="col-12 col-lg-7">        <span class="hero-eyebrow">          <span class="eyebrow-dot" aria-hidden="true"></span>          v0.9 ยท alpha ยท self-hosted        </span>        <h1 class="hero-title">          Website status monitoring for operators who host their <span class="accent">own stack</span>.        </h1>        <p class="hero-sub">          HTTP checks every three minutes. Lighthouse audits. Security header analysis.          Full-site SEO crawl. Discord + email alerts on state transitions. Your data          never leaves your infrastructure.        </p>        <div class="hero-ctas">          <a href="/accounts/login/" class="btn btn-primary">Access dashboard โ†’</a>          <a href="https://github.com/overshard/status" target="_blank" class="btn btn-outline-light">View source</a>        </div>        <h1 class="display-1 mt-4 text-light fw-bolder">Self-hosted website status monitoring</h1>        <p class="mb-0 text-white mt-4 fs-4">Made by <a href="https://isaacbythewood.com/" class="text-white">Isaac Bythewood</a>,          a simple opinionated self-hosted website status monitoring solution.</p>        <div class="mt-4">          <a href="/accounts/login/" class="btn btn-light btn-lg me-3">            Login          </a>          <a href="https://github.com/overshard/status" target="_blank" class="btn btn-outline-light btn-lg">            Source code          </a>      </div>      <div class="col-12 col-lg-5 mt-5 mt-lg-0 d-flex align-items-center">        <div class="terminal-block w-100">          <span class="t-line"><span class="t-comment"># probe cycle</span></span>          <span class="t-line"><span class="t-prompt">probe$</span><span class="t-out">GET https://bythewood.me</span></span>          <span class="t-line"><span class="t-key">status</span>=<span class="t-val">200 OK</span> <span class="t-key">rtt</span>=<span class="t-val">142ms</span></span>          <span class="t-line"><span class="t-key">cert</span>=<span class="t-val">valid ยท 87d</span></span>          <span class="t-line"><span class="t-prompt">probe$</span> <span class="t-cursor"></span></span>        </div>      </div>    </div>  </div></div><div class="container mb-2">  <div class="row justify-content-center">    <div class="col-12 col-md-6 col-lg-4">      <div class="card bg-primary text-white text-center mb-3">        <div class="card-body">          <div class="display-5">{{ total_properties }}</div>          <div class="h5">Total properties</div></section><section class="stat-strip">  <div class="container">    <div class="row g-4">      <div class="col-6 col-md-3">        <div class="stat">          <div class="stat-label">properties</div>          <div class="stat-value">{{ total_properties }}</div>        </div>      </div>      <div class="col-6 col-md-3">        <div class="stat">          <div class="stat-label">checks logged</div>          <div class="stat-value">{{ total_statuses }}</div>        </div>      </div>      <div class="col-6 col-md-3">        <div class="stat">          <div class="stat-label">operators</div>          <div class="stat-value">{{ total_users }}</div>        </div>      </div>      <div class="col-6 col-md-3">        <div class="stat">          <div class="stat-label">online since</div>          <div class="stat-value" style="font-size: 1.05rem;">            {% if first_status_created_at %}{{ first_status_created_at|date:"M j, Y" }}{% else %}โ€”{% endif %}          </div>        </div>      </div>    </div>  </div></section><section class="container my-5 py-4">  <div class="row mb-4">    <div class="col-12">      <div class="section-label mb-2">capabilities</div>      <h2 class="h3 text-white fw-bolder" style="letter-spacing: -0.01em;">        Everything your infrastructure team needs in one dashboard.      </h2>    </div>  </div>  <div class="row g-4">    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">uptime</div>        <div class="feature-title">Live HTTP probes</div>        <p class="feature-desc">          Three-minute intervals, two-strike alert debouncing, SSL and timeout          mapping. Discord + SMTP notifications only fire on real state          transitions.        </p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="card bg-primary text-white text-center mb-3">        <div class="card-body">          <div class="display-5">{{ total_statuses }}</div>          <div class="h5">Total events</div>        </div>      <div class="feature">        <div class="feature-label">performance</div>        <div class="feature-title">Lighthouse audits</div>        <p class="feature-desc">          Daily headless runs surface performance, accessibility, best practices,          and SEO scores with weighted metric breakdowns and opportunity savings.        </p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="card bg-primary text-white text-center mb-3">        <div class="card-body">          <div class="display-5">{{ first_status_created_at|date:"M j, Y" }}</div>          <div class="h5">First event logged</div>        </div>      <div class="feature">        <div class="feature-label">security</div>        <div class="feature-title">Header analysis</div>        <p class="feature-desc">          HTTPS, HSTS, HSTS preload, XSS and content-sniffing protection,          clickjack defence, and server-version leak checks โ€” graded on every          probe.        </p>      </div>    </div>  </div></div><div class="container">  <div class="row justify-content-center">    <div class="col-12 col-md-6 col-lg-4 mb-4">      <div class="card bg-light h-100">        <div class="card-body">          <h2 class="card-title h5">Open source</h2>          <p class="card-text">The source code is available on GitHub and is            published under the simplified BSD license, do what you want with            it.</p>        </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">crawler</div>        <div class="feature-title">Weekly SEO spider</div>        <p class="feature-desc">          Extracts title, description, canonical, OG tags, and H1 per page โ€” every          mismatch becomes a severity-ranked insight you can drill into.        </p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4 mb-4">      <div class="card bg-light h-100">        <div class="card-body">          <h2 class="card-title h5">Privacy friendly</h2>          <p class="card-text">The status data is stored on whatever            hosting service you put it on. You can store as much, or as            little, data as you want.</p>        </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">sharing</div>        <div class="feature-title">Public status pages</div>        <p class="feature-desc">          Toggle a property public and share the URL โ€” no account required,          customers see live status, response graphs, and uptime at a glance.        </p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4 mb-4">      <div class="card bg-light h-100">        <div class="card-body">          <h2 class="card-title h5">First class mobile support</h2>          <p class="card-text">A responsive design that should work across all            platforms.</p>        </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">ownership</div>        <div class="feature-title">BSD, single container</div>        <p class="feature-desc">          Django + SQLite + a scheduler. Ships as a Docker Compose stack. Your          data, your ingress, your retention policy. No SaaS vendor lock-in.        </p>      </div>    </div>  </div></div></section>{% endblock %}
modified pages/views.py
@@ -39,8 +39,16 @@ def changelog(request):def favicon(request):    icon = "๐Ÿš€"    svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="80">{icon}</text></svg>'    # Heartbeat/pulse mark โ€” a single EKG-style waveform sweep, reads as    # "vital signs / liveness" and is distinct from bars, rings, and shells.    svg = (        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'        '<polyline points="2,34 18,34 24,28 30,14 36,52 42,20 48,34 62,34" '        'fill="none" stroke="#6b9e78" stroke-width="6" '        'stroke-linejoin="round" stroke-linecap="round"/>'        '<circle cx="30" cy="14" r="3.5" fill="#c9a84c"/>'        "</svg>"    )    return HttpResponse(svg, content_type="image/svg+xml")
modified properties/forms.py
@@ -20,13 +20,3 @@ class PropertyForm(forms.ModelForm):        except requests.exceptions.RequestException:            raise forms.ValidationError('Invalid URL')        return r.url  # NOTE: requests' "Final URL location of Response."class PropertyImportForm(forms.Form):    csv_file = forms.FileField()    def clean_csv_file(self):        csv_file = self.cleaned_data['csv_file']        if not csv_file.name.endswith('.csv'):            raise forms.ValidationError('Invalid file type')        return csv_file
modified properties/models.py
@@ -450,6 +450,24 @@ class Property(CrawlerMixin, AlertsMixin, SecurityMixin, models.Model):            scores = [score for score in self.lighthouse_scores.values()]            return round(sum(scores) / len(scores))    def recent_tick_stream(self, limit=30):        """Most-recent-first list of 'up' / 'down' strings for the uptime strip."""        codes = list(            self.statuses.order_by("-created_at").values_list("status_code", flat=True)[:limit]        )        return ["up" if c == 200 else "down" for c in reversed(codes)]    @cached_property    def recent_uptime_pct(self):        """Uptime percentage over the most recent 100 checks, rounded to one decimal."""        codes = list(            self.statuses.order_by("-created_at").values_list("status_code", flat=True)[:100]        )        if not codes:            return None        up = sum(1 for c in codes if c == 200)        return round((up / len(codes)) * 100, 1)class Check(models.Model):    property = models.ForeignKey(
modified properties/static_src/scripts/property_graphs.js
@@ -1,19 +1,54 @@import Chart from "chart.js/auto";const accent = {  green: "#6b9e78",  greenBright: "#7db88c",  greenFill: "rgba(107, 158, 120, 0.35)",  amber: "#c9a84c",  amberFill: "rgba(201, 168, 76, 0.35)",  terracotta: "#c47055",  terracottaFill: "rgba(196, 112, 85, 0.35)",  slate: "#7eaab8",  slateFill: "rgba(126, 170, 184, 0.3)",  grid: "rgba(107, 158, 120, 0.08)",  ticks: "#847c72",};const backgroundColors = [  "rgba(13, 110, 253, 0.4)",  "rgba(102, 16, 242, 0.4)",  "rgba(111, 66, 193, 0.4)",  "rgba(214, 51, 132, 0.4)",  "rgba(220, 53, 69, 0.4)",  "rgba(253, 126, 20, 0.4)",  "rgba(255, 193, 7, 0.4)",  "rgba(25, 135, 84, 0.4)",  "rgba(32, 201, 151, 0.4)",  "rgba(13, 202, 240, 0.4)",  accent.greenFill,  accent.terracottaFill,  accent.amberFill,  accent.slateFill,  "rgba(221, 215, 205, 0.18)",  "rgba(160, 152, 144, 0.3)",  "rgba(107, 158, 120, 0.55)",  "rgba(196, 112, 85, 0.55)",  "rgba(201, 168, 76, 0.55)",  "rgba(126, 170, 184, 0.55)",];const borderColors = [  "rgba(107, 158, 120, 0.9)",  "rgba(196, 112, 85, 0.9)",  "rgba(201, 168, 76, 0.9)",  "rgba(126, 170, 184, 0.9)",  "rgba(221, 215, 205, 0.5)",  "rgba(160, 152, 144, 0.7)",  "rgba(107, 158, 120, 1)",  "rgba(196, 112, 85, 1)",  "rgba(201, 168, 76, 1)",  "rgba(126, 170, 184, 1)",];const fontStack = 'Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace';const fontStack = '"Monaspace Argon", Consolas, "Liberation Mono", Monaco, "Courier New", monospace';Chart.defaults.color = accent.ticks;Chart.defaults.borderColor = accent.grid;Chart.defaults.font.family = fontStack;Chart.defaults.font.size = 11;const tickFont = { size: 11, family: fontStack };const legendLabel = { boxWidth: 10, boxHeight: 10, font: tickFont, color: "#d1d8d4" };document.addEventListener("DOMContentLoaded", function () {  const canvas = document.getElementById("chart-response-times");
@@ -22,6 +57,9 @@ document.addEventListener("DOMContentLoaded", function () {    document.getElementById("chart-status-response-times-data").innerHTML  );  const ctx = canvas.getContext("2d");  const gradient = ctx.createLinearGradient(0, 0, 0, 300);  gradient.addColorStop(0, "rgba(107, 158, 120, 0.35)");  gradient.addColorStop(1, "rgba(107, 158, 120, 0)");  const chart = new Chart(ctx, {    type: "line",    data: {
@@ -31,11 +69,15 @@ document.addEventListener("DOMContentLoaded", function () {      }),      datasets: [        {          label: "Response times (ms)",          label: "Response time (ms)",          data: data.map((d) => d.count),          backgroundColor: "rgba(13, 110, 253, 0.4)",          borderColor: "rgba(13, 110, 253, 0.8)",          borderWidth: 3,          backgroundColor: gradient,          borderColor: accent.green,          borderWidth: 2,          pointRadius: 0,          pointHoverRadius: 4,          pointHoverBackgroundColor: accent.greenBright,          tension: 0.25,          fill: true,        },      ],
@@ -43,47 +85,34 @@ document.addEventListener("DOMContentLoaded", function () {    options: {      responsive: true,      maintainAspectRatio: false,      animation: {        duration: 0,      },      animation: { duration: 0 },      plugins: {        tooltip: {          mode: "index",          intersect: false,          backgroundColor: "rgba(9, 8, 6, 0.95)",          titleColor: "#ede8e0",          bodyColor: "#ddd7cd",          borderColor: "rgba(107, 158, 120, 0.2)",          borderWidth: 1,          padding: 10,          titleFont: tickFont,          bodyFont: tickFont,        },        legend: {          position: "top",          labels: {            boxWidth: 10,            boxHeight: 10,            font: {              size: 12,              family: fontStack,            },          },        },        legend: { position: "top", labels: legendLabel },      },      scales: {        xAxes: {          ticks: {            autoSkip: true,            font: {              size: 12,              family: fontStack,            },            maxRotation: 25,          },        x: {          grid: { color: accent.grid, drawBorder: false },          ticks: { autoSkip: true, maxRotation: 25, font: tickFont, color: accent.ticks },        },        yAxes: {        y: {          grid: { color: accent.grid, drawBorder: false },          ticks: {            beginAtZero: true,            font: {              size: 12,              family: fontStack,            },            callback: function(value, index, ticks) {              return `${value} ms`;            }            font: tickFont,            color: accent.ticks,            callback: (value) => `${value} ms`,          },        },      },
@@ -93,56 +122,30 @@ document.addEventListener("DOMContentLoaded", function () {  chart.canvas.parentNode.style.height = "300px";});document.addEventListener("DOMContentLoaded", function () {  const canvas = document.getElementById("chart-status-codes");function buildDoughnut(canvasId, dataId) {  const canvas = document.getElementById(canvasId);  if (!canvas) return;  const data = JSON.parse(    document.getElementById("chart-status-codes-data").innerHTML  );  const data = JSON.parse(document.getElementById(dataId).innerHTML);  const ctx = canvas.getContext("2d");  new Chart(ctx, {    type: "doughnut",    data: {      labels: data.map((d) => d.label),      datasets: [        {          data: data.map((d) => d.count),          backgroundColor: backgroundColors,          borderColor: backgroundColors,          borderWidth: 1,        },      ],    },    options: {      responsive: true,      aspectRatio: 2,      animation: {        animateRotate: false,      },      plugins: {        legend: {          position: "right",          labels: {            boxWidth: 10,            boxHeight: 10,            font: {              size: 12,              family: fontStack,            },          },        },      },    },  // Color rule: status 200 / Uptime = moss green, non-200 / Downtime =  // terracotta, everything else pulls from the earthy palette in order.  const paint = (label) => {    const name = String(label || "").toLowerCase();    if (name === "uptime" || name === "200") return [accent.greenFill, "rgba(107, 158, 120, 0.95)"];    if (name === "downtime") return [accent.terracottaFill, "rgba(196, 112, 85, 0.95)"];    return null;  };  const bg = data.map((d, i) => {    const p = paint(d.label);    return p ? p[0] : backgroundColors[i % backgroundColors.length];  });  const bd = data.map((d, i) => {    const p = paint(d.label);    return p ? p[1] : borderColors[i % borderColors.length];  });});document.addEventListener("DOMContentLoaded", function () {  const canvas = document.getElementById("chart-uptime");  if (!canvas) return;  const data = JSON.parse(    document.getElementById("chart-uptime-data").innerHTML  );  const ctx = canvas.getContext("2d");  new Chart(ctx, {    type: "doughnut",    data: {
@@ -150,31 +153,33 @@ document.addEventListener("DOMContentLoaded", function () {      datasets: [        {          data: data.map((d) => d.count),          backgroundColor: backgroundColors,          borderColor: backgroundColors,          borderWidth: 1,          backgroundColor: bg,          borderColor: bd,          borderWidth: 1.5,        },      ],    },    options: {      responsive: true,      aspectRatio: 2,      animation: {        animateRotate: false,      },      animation: { animateRotate: false },      cutout: "62%",      plugins: {        legend: {          position: "right",          labels: {            boxWidth: 10,            boxHeight: 10,            font: {              size: 12,              family: fontStack,            },          },        legend: { position: "right", labels: legendLabel },        tooltip: {          backgroundColor: "rgba(9, 8, 6, 0.95)",          titleColor: "#ede8e0",          bodyColor: "#ddd7cd",          borderColor: "rgba(107, 158, 120, 0.2)",          borderWidth: 1,          padding: 10,          titleFont: tickFont,          bodyFont: tickFont,        },      },    },  });});}document.addEventListener("DOMContentLoaded", () => buildDoughnut("chart-status-codes", "chart-status-codes-data"));document.addEventListener("DOMContentLoaded", () => buildDoughnut("chart-uptime", "chart-uptime-data"));
modified properties/templates/properties/properties.html
@@ -8,7 +8,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -18,177 +18,196 @@{% block main %}<div class="container">  <div class="row my-3 d-flex align-items-center">    <div class="col-sm-6">      <h1>{{ title }}</h1>      <p>All properties you've created with their current active status and issues.</p><div class="container my-4">  <div class="row align-items-center g-3 mb-4">    <div class="col-md-5">      <div class="section-label mb-1">operator ยท {{ request.user.username }}</div>      <h1 class="fw-bolder text-white mb-0" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted mb-0 small mt-1">Every URL you've registered, current probe state, and audit signals.</p>    </div>    <div class="col-sm-6">      <form method="get" class="d-flex">        <div class="form-floating flex-grow-1 rounded-0 rounded-start">          <input type="text" class="form-control" name="q" id="id_search" placeholder="Search" {% if q %}value="{{ q }}"{% endif %} />          <label for="id_search" class="form-label">Search</label>        </div>        <button type="submit" class="btn btn-secondary px-3 rounded-0 rounded-end">          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">            <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>          </svg>        </button>        <button class="btn btn-primary px-3 ms-4" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePropertyAdd" aria-expanded="false" aria-controls="collapsePropertyAdd">          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-node-plus" viewBox="0 0 16 16">            <path fill-rule="evenodd" d="M11 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM6.025 7.5a5 5 0 1 1 0 1H4A1.5 1.5 0 0 1 2.5 10h-1A1.5 1.5 0 0 1 0 8.5v-1A1.5 1.5 0 0 1 1.5 6h1A1.5 1.5 0 0 1 4 7.5h2.025zM11 5a.5.5 0 0 1 .5.5v2h2a.5.5 0 0 1 0 1h-2v2a.5.5 0 0 1-1 0v-2h-2a.5.5 0 0 1 0-1h2v-2A.5.5 0 0 1 11 5zM1.5 7a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1z"/>          </svg>    <div class="col-md-7">      <div class="dashboard-toolbar">        <form method="get" class="search-form flex-grow-1">          <span class="search-icon">            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">              <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>            </svg>          </span>          <input type="text" class="form-control search-input" name="q" id="id_search" placeholder="grep properties..." {% if q %}value="{{ q }}"{% endif %} />        </form>        <button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePropertyAdd" aria-expanded="false" aria-controls="collapsePropertyAdd">          + new        </button>      </form>      </div>    </div>  </div>  <div class="row mx-0 mb-3 bg-dark py-3 rounded border border-warning collapse" id="collapsePropertyAdd">    <div class="col-sm-6">      <h2 class="text-white">Add a new property</h2>      <p class="text-white">All public URLs are supported.</p>      {{ form.errors }}      <form method="POST" class="d-flex">  <div class="collapse mb-4" id="collapsePropertyAdd">    <div class="bg-surface border-subtle rounded border p-3">      <div class="section-label mb-2">register property</div>      <p class="text-muted small mb-3">Any public URL. Probe begins within 3 minutes.</p>      <form method="POST" class="dashboard-toolbar">        {% csrf_token %}        <div class="flex-grow-1">          <div class="form-floating">            <input type="text" name="url" id="id_url" class="form-control {% if form.url.errors %}is-invalid{% endif %} rounded-0 rounded-start" placeholder="URL" required>            <label for="id_url">URL</label>          </div>        </div>        <button type="submit" class="btn btn-primary px-3 rounded-0 rounded-end">          <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="currentColor" class="bi bi-plus" viewBox="0 0 16 16">            <path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>          </svg>        </button>        <input type="url" name="url" id="id_url" class="form-control flex-grow-1 {% if form.url.errors %}is-invalid{% endif %}" placeholder="https://example.com" required>        <button type="submit" class="btn btn-primary">Add โ†’</button>      </form>      {% if form.errors %}<div class="text-danger small mt-2">{{ form.errors }}</div>{% endif %}    </div>    <div class="col-sm-6">      <h2 class="text-white">Bulk import properties</h2>      <p class="text-white">Add a CSV file with the first column being the URL.</p>      <form method="POST" class="d-flex" enctype="multipart/form-data" action="{% url 'import_properties' %}">        {% csrf_token %}        <input type="file" name="csv_file" id="id_csv_file" class="form-control py-3 px-4 {% if form.file.errors %}is-invalid{% endif %} rounded-0 rounded-start" required>        <button type="submit" class="btn btn-primary px-4 rounded-0 rounded-end">          <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16">            <path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>            <path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>          </svg>        </button>      </form>  </div>  <div class="row g-3 mb-4">    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-green">        <div class="metric-label">total properties</div>        <div class="metric-value">{{ user.total_properties }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile {% if user.total_properties_down %}metric-accent-danger{% else %}metric-accent-green{% endif %}">        <div class="metric-label">currently down</div>        <div class="metric-value">{{ user.total_properties_down }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-green">        <div class="metric-label">checks logged</div>        <div class="metric-value">{{ user.total_checks }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-amber">        <div class="metric-label">probe cycle</div>        <div class="metric-value" style="font-size: 1.2rem;">3m ยท <span class="text-muted">live</span></div>        <div class="metric-sub">last sync: <span class="text-green">online</span></div>      </div>    </div>  </div>  <div class="row">    <div class="col" id="properties">      <div class="card rounded-0 border-0 rounded-top py-1 bg-secondary text-white">        <div class="row g-0">          <div class="col-6 col-md-2 d-flex align-items-center">            <div class="card-body py-1">              <div class="card-title h3 mb-0">{{ user.total_properties }}</div>              <p class="card-text">Properties</p>            </div>  <div class="section-label mb-2">registered properties</div>  {% for property in properties.object_list %}    <div class="property-row {% if property.current_status != 200 %}is-down{% endif %}">      <div class="pr-main">        <div class="pr-title">          <span class="status-dot {% if property.current_status == 200 %}is-up{% else %}is-down{% endif %}" aria-hidden="true"></span>          <a href="{% url 'property' property.id %}" class="text-truncate">{{ property.name }}</a>        </div>        <div class="pr-url">{{ property.url }}</div>        <div class="uptime-strip" aria-label="Recent checks">          {% for tick in property.recent_tick_stream %}            <span class="uptime-tick is-{{ tick }}"></span>          {% empty %}            {% for _ in "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" %}<span class="uptime-tick is-none"></span>{% endfor %}          {% endfor %}        </div>      </div>      <div class="pr-side">        <div class="pr-meta">          <div class="pr-meta-cell">            <span class="pr-meta-k">status</span>            {% if property.current_status == 200 %}              <span class="chip chip-ok">ok ยท 200</span>            {% else %}              <span class="chip chip-down">down ยท {{ property.current_status }}</span>            {% endif %}          </div>          <div class="col-6 offset-md-2 col-md-2 d-flex align-items-center">            <div class="card-body py-1">              <div class="card-title h3 mb-0">{{ user.total_properties_down }}</div>              <p class="card-text">Properties down</p>            </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">uptime</span>            {% if property.recent_uptime_pct is not None %}              {% if property.recent_uptime_pct >= 99 %}                <span class="chip chip-ok">{{ property.recent_uptime_pct }}%</span>              {% elif property.recent_uptime_pct < 95 %}                <span class="chip chip-down">{{ property.recent_uptime_pct }}%</span>              {% else %}                <span class="chip chip-warn">{{ property.recent_uptime_pct }}%</span>              {% endif %}            {% else %}              <span class="chip chip-muted">โ€”</span>            {% endif %}          </div>          <div class="col-6 col-md-2 d-flex align-items-center">            <div class="card-body py-1">              <div class="card-title h3 mb-0">{{ user.total_checks }}</div>              <p class="card-text">Total checks</p>            </div>          <div class="pr-meta-cell">            <span class="pr-meta-k">sec</span>            {% if property.has_security_issue %}              <span class="chip chip-warn">issues</span>            {% else %}              <span class="chip chip-ok">ok</span>            {% endif %}          </div>          {% with insights=property.crawler_insights|length %}          <div class="pr-meta-cell">            <span class="pr-meta-k">seo</span>            {% if insights > 100 %}              <span class="chip chip-down">{{ insights }}</span>            {% elif insights > 25 %}              <span class="chip chip-warn">{{ insights }}</span>            {% else %}              <span class="chip chip-ok">{{ insights }}</span>            {% endif %}          </div>          {% endwith %}          <div class="pr-meta-cell">            <span class="pr-meta-k">lh</span>            {% if property.avg_lighthouse_score %}              {% if property.avg_lighthouse_score >= 90 %}                <span class="chip chip-ok">{{ property.avg_lighthouse_score }}%</span>              {% elif property.avg_lighthouse_score >= 80 %}                <span class="chip chip-warn">{{ property.avg_lighthouse_score }}%</span>              {% else %}                <span class="chip chip-down">{{ property.avg_lighthouse_score }}%</span>              {% endif %}            {% else %}              <span class="chip chip-muted">โ€”</span>            {% endif %}          </div>        </div>      </div>      {% for property in properties.object_list %}        <div class="card rounded-0 border-0">          <div class="row g-0">            <div class="col-12 col-md-4 bg-dark text-white">              <div class="card-body">                <h2 class="card-title h4 text-truncate">{{ property.name }}</h2>                <div class="d-flex">                  <a href="{% url 'property' property.id %}" class="btn btn-sm btn-success me-2 w-25">View</a>                  {% if not property.is_protected %}                  <button type="button" class="btn btn-sm btn-outline-danger w-25" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}">                    Delete                  </button>        <div class="pr-actions">          {% if not property.is_protected %}          <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}">            Delete          </button>          <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">            <div class="modal-dialog">              <div class="modal-content">                <div class="modal-header">                  <h5 class="modal-title text-white" id="delete-modal-{{ property.id }}-label">Confirm delete</h5>                  <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>                </div>                <div class="modal fade text-dark" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">                  <div class="modal-dialog">                    <div class="modal-content">                      <div class="modal-header">                        <h5 class="modal-title" id="delete-modal-{{ property.id }}-label">Confirm property delete</h5>                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>                      </div>                      <div class="modal-body">                        Are you sure you want to delete <strong>{{ property.url }}</strong>?                      </div>                      <div class="modal-footer">                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">I've changed my mind</button>                        <a href="{% url 'property_delete' property.id %}" class="btn btn-danger">Confirm</a>                      </div>                    </div>                  </div>                <div class="modal-body">                  Are you sure you want to delete <strong class="text-white">{{ property.url }}</strong>? All checks and audit data will be removed.                </div>                {% endif %}              </div>            </div>            <div class="col-6 col-md-2 d-flex align-items-center text-white {% if property.current_status == 200 %}bg-success{% else %}bg-danger{% endif %}">              <div class="card-body">                <div class="card-title h4">{% if property.current_status == 200 %}Ok{% else %}Failed{% endif %}</div>                <div class="card-text text-truncate small">Current status</div>              </div>            </div>            <div class="col-6 col-md-2 d-none d-md-flex d-flex align-items-center text-white {% if not property.has_security_issue %}bg-success{% else %}bg-danger{% endif %}">              <div class="card-body">                <div class="card-title h4">{% if not property.has_security_issue %}Ok{% else %}Failed{% endif %}</div>                <div class="card-text text-truncate small">Security</div>              </div>            </div>            {% with property.crawler_insights|length as insights %}            <div class="col-6 col-md-2 d-flex align-items-center text-white {% if insights > 100 %}bg-danger{% elif insights > 25 %}bg-warning{% else %}bg-success{% endif %}">              <div class="card-body">                <div class="card-title h4">{{ insights }}</div>                <div class="card-text text-truncate small">Crawler issues</div>              </div>            </div>            {% endwith %}            <div class="col-6 col-md-2 d-none d-md-flex d-flex align-items-center text-white {% if property.avg_lighthouse_score < 80 %}bg-warning{% else %}bg-success{% endif %}">              <div class="card-body">                <div class="card-title h4">                  {% if property.avg_lighthouse_score %}                    {{ property.avg_lighthouse_score }}%                  {% else %}                    Checking...                  {% endif %}                <div class="modal-footer">                  <button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>                  <a href="{% url 'property_delete' property.id %}" class="btn btn-outline-danger">Delete</a>                </div>                <div class="card-text text-truncate small">Avg. LH score</div>              </div>            </div>          </div>          {% endif %}          <a href="{% url 'property' property.id %}" class="btn btn-sm btn-outline-light">View โ†’</a>        </div>      {% endfor %}      </div>    </div>  </div>  {% empty %}    <div class="text-center py-5 text-muted">      <div class="section-label mb-3" style="justify-content:center;">no properties yet</div>      <p class="mb-0 small">Click <strong>+ new</strong> above to register your first URL.</p>    </div>  {% endfor %}  {% if properties.paginator.num_pages > 1 %}  <div class="row mt-5">  <div class="row mt-4">    <div class="col d-flex justify-content-center">      <nav aria-label="Pagination">        <ul class="pagination">          <li class="page-item {% if not properties.has_previous %}disabled{% endif %}">            <a class="page-link" {% if properties.has_previous %}href="?page={{ properties.previous_page_number }}"{% endif %}>Previous</a>            <a class="page-link" {% if properties.has_previous %}href="?page={{ properties.previous_page_number }}"{% endif %}>โ† Prev</a>          </li>          {% for page_num in properties.paginator.page_range %}            <li class="page-item {% if properties.number == page_num %}active{% endif %}">              <a class="page-link" href="/properties/?page={{ page_num }}">{{ page_num }}</a>            </li>          {% endfor %}          <li class="page-item {% if not properties.has_next %}disabled{% endif %}">            <a class="page-link" {% if properties.has_next %}href="?page={{ properties.next_page_number }}"{% endif %}>Next</a>            <a class="page-link" {% if properties.has_next %}href="?page={{ properties.next_page_number }}"{% endif %}>Next โ†’</a>          </li>        </ul>      </nav>
modified properties/templates/properties/property.html
@@ -16,7 +16,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="/properties/">Properties</a></li>
@@ -27,113 +27,115 @@{% block main %}<div class="bg-dark text-white py-2"><div class="bg-deep border-bottom border-subtle py-4">  <div class="container">    <div class="row">      <div class="col-12 col-lg-5">        <div class="d-flex align-items-center">          <h1 class="me-3 display-5 text-truncate">{{ title }}</h1>        </div>    <div class="row align-items-center g-3">      <div class="col-12 col-lg-6">        <div class="section-label mb-2">property ยท <span class="text-green">{% if property.current_status == 200 %}online{% else %}offline{% endif %}</span></div>        <h1 class="mb-1 text-white fw-bolder d-flex align-items-center gap-3" style="letter-spacing: -0.01em; min-width: 0;">          <span class="text-truncate">{{ property.name }}</span>          <span class="status-dot status-dot-lg {% if property.current_status == 200 %}is-up{% else %}is-down{% endif %}" aria-hidden="true"></span>        </h1>        <a href="{{ property.url }}" target="_blank" class="text-muted small text-decoration-none" style="font-family: var(--bs-font-monospace);">{{ property.url }} โ†—</a>      </div>      <div class="col-12 col-lg-7 d-flex align-items-center justify-content-lg-end">        <div class="d-lg-flex my-lg-0 d-print-none">          {% if user.is_authenticated %}          <form id="is-public-form" method="POST" class="d-flex align-items-center">            {% csrf_token %}            <div class="form-check form-switch my-1">              <input class="form-check-input" type="checkbox" role="switch" name="is_public" id="is-public-switch" {% if property.is_public %}checked{% endif %}>              <label class="form-check-label" for="{{ custom_event.event|slugify }}-switch">                <span class="badge bg-warning text-dark rounded-pill" data-bs-placement="bottom" data-bs-toggle="tooltip" title="Anyone with the URL will have access to this property, you can disable at anytime.">?</span>                Public Property              </label>            </div>          </form>          {% endif %}          {% if user.is_authenticated %}          <a href="{% url 'property' property.id %}?report" target="_blank" class="btn btn-sm btn-primary ms-0 ms-lg-3 my-1">            Report          </a>          {% endif %}          {% if not property.is_protected and user.is_authenticated %}          <button type="button" class="btn btn-sm btn-outline-danger ms-1 ms-lg-3 my-1" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}">            Delete          </button>          <div class="modal fade text-dark" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">            <div class="modal-dialog">              <div class="modal-content">                <div class="modal-header">                  <h5 class="modal-title" id="delete-modal-{{ property.id }}-label">Confirm property delete</h5>                  <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>                </div>                <div class="modal-body">                  Are you sure you want to delete <strong>{{ property.name }}</strong>?                </div>                <div class="modal-footer">                  <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">I've changed my mind</button>                  <a href="{% url 'property_delete' property.id %}" class="btn btn-danger">Confirm</a>                </div>      <div class="col-12 col-lg-6 d-flex flex-wrap gap-2 justify-content-lg-end align-items-center d-print-none">        {% if user.is_authenticated %}        <form id="is-public-form" method="POST" class="d-inline-flex align-items-center gap-2">          {% csrf_token %}          <span class="toggle-label" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Public properties are viewable by anyone with the URL โ€” no login required.">Visibility</span>          <label class="toggle-pill">            <input type="checkbox" name="is_public" id="is-public-switch" {% if property.is_public %}checked{% endif %}>            <span class="toggle-pill-seg toggle-pill-private">Private</span>            <span class="toggle-pill-seg toggle-pill-public">Public</span>          </label>        </form>        <a href="{% url 'property' property.id %}?report" target="_blank" class="btn btn-sm btn-outline-light">Report ยท pdf</a>        <a href="{% url 'property' property.id %}?report=md" target="_blank" class="btn btn-sm btn-outline-light">Report ยท md</a>        {% if not property.is_protected %}        <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}">          Delete        </button>        <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">          <div class="modal-dialog">            <div class="modal-content">              <div class="modal-header">                <h5 class="modal-title text-white" id="delete-modal-{{ property.id }}-label">Confirm delete</h5>                <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>              </div>              <div class="modal-body">                Delete <strong class="text-white">{{ property.name }}</strong>? All probe and audit data will be removed.              </div>              <div class="modal-footer">                <button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>                <a href="{% url 'property_delete' property.id %}" class="btn btn-outline-danger">Delete</a>              </div>            </div>          </div>          {% endif %}        </div>        {% endif %}        {% endif %}      </div>    </div>  </div></div><div class="container-fluid">  <div class="row {% if not property.lighthouse_scores %}mb-4{% endif %}">    <div class="col-6 col-md-3 d-flex align-items-center text-white {% if property.current_status == 200 %}bg-success{% else %}bg-danger{% endif %}">      <div class="card-body text-center py-2">        <div class="card-title h4">{% if property.current_status == 200 %}Ok{% else %}Failed{% endif %}</div>        <div class="card-text text-truncate small">Current status</div><div class="container my-4">  <div class="section-label mb-2">live signals</div>  <div class="row g-3 mb-4">    <div class="col-6 col-md-3">      <div class="metric-tile {% if property.current_status == 200 %}metric-accent-green{% else %}metric-accent-danger{% endif %}">        <div class="metric-label">http status</div>        <div class="metric-value">{% if property.current_status == 200 %}200{% else %}{{ property.current_status }}{% endif %}</div>        <div class="metric-sub">{% if property.current_status == 200 %}OK ยท all clear{% else %}failing{% endif %}</div>      </div>    </div>    <div class="col-6 col-md-3 d-flex align-items-center text-white {% if not property.invalid_cert %}bg-success{% else %}bg-danger{% endif %}">      <div class="card-body text-center py-2">        <div class="card-title h4">{% if not property.invalid_cert %}Ok{% else %}Unhealthy{% endif %}</div>        <div class="card-text text-truncate small">Certificate</div>    <div class="col-6 col-md-3">      <div class="metric-tile {% if property.invalid_cert %}metric-accent-danger{% else %}metric-accent-green{% endif %}">        <div class="metric-label">tls cert</div>        <div class="metric-value" style="font-size: 1.4rem;">{% if property.invalid_cert %}invalid{% else %}valid{% endif %}</div>        <div class="metric-sub">chain + trust</div>      </div>    </div>    <div class="col-6 col-md-3 d-flex align-items-center text-white {% if not property.has_security_issue %}bg-success{% else %}bg-danger{% endif %}">      <div class="card-body text-center py-2">        <div class="card-title h4">{% if not property.has_security_issue %}Ok{% else %}Failed{% endif %}</div>        <div class="card-text text-truncate small">Security</div>    <div class="col-6 col-md-3">      <div class="metric-tile {% if property.has_security_issue %}metric-accent-amber{% else %}metric-accent-green{% endif %}">        <div class="metric-label">security</div>        <div class="metric-value" style="font-size: 1.4rem;">{% if property.has_security_issue %}issues{% else %}pass{% endif %}</div>        <div class="metric-sub">header scan</div>      </div>    </div>    <div class="col-6 col-md-3 d-flex align-items-center text-white {% if property.avg_response_time > 500 %}bg-danger{% else %}bg-success{% endif %}">      <div class="card-body text-center py-2">        <div class="card-title h4">{% if property.avg_response_time > 500 %}Unhealthy{% else %}Ok{% endif %}</div>        <div class="card-text text-truncate small">Response time</div>    <div class="col-6 col-md-3">      <div class="metric-tile {% if property.avg_response_time > 500 %}metric-accent-danger{% else %}metric-accent-green{% endif %}">        <div class="metric-label">response</div>        <div class="metric-value">{{ property.avg_response_time }}<span style="font-size: 0.9rem; color: #89918c;">ms</span></div>        <div class="metric-sub">rolling avg</div>      </div>    </div>  </div></div>{% if property.lighthouse_scores %}<div class="container-fluid">  <div class="row mb-4">  {% if property.lighthouse_scores %}  <div class="section-label mb-2">lighthouse</div>  <div class="row g-3 mb-4">    {% for category, score in property.lighthouse_scores.items %}    <div class="col-6 col-md-3 d-flex align-items-center text-white {% if score >= 80 %}bg-success{% else %}bg-warning{% endif %}">      <div class="card-body text-center py-2">        <div class="card-title h4">{{ score }}%</div>        <div class="card-text text-truncate small">{{ category }}</div>    <div class="col-6 col-md-3">      <div class="metric-tile {% if score >= 90 %}metric-accent-green{% elif score >= 50 %}metric-accent-amber{% else %}metric-accent-danger{% endif %}">        <div class="metric-label">{{ category }}</div>        <div class="metric-value">{{ score }}<span style="font-size: 0.9rem; color: #89918c;">%</span></div>      </div>    </div>    {% endfor %}  </div>  {% endif %}</div>{% endif %}{% if user.is_authenticated %}<form style="display:none">{% csrf_token %}</form><div class="container mt-4 d-print-none" id="monitoring-status" data-property-id="{{ property.id }}" data-status-url="{% url 'property_status' property.id %}" data-recrawl-url="{% url 'property_recrawl' property.id %}" data-rerun-lighthouse-url="{% url 'property_rerun_lighthouse' property.id %}"><div class="container my-4 d-print-none" id="monitoring-status" data-property-id="{{ property.id }}" data-status-url="{% url 'property_status' property.id %}" data-recrawl-url="{% url 'property_recrawl' property.id %}" data-rerun-lighthouse-url="{% url 'property_rerun_lighthouse' property.id %}">  <div class="section-label mb-2">scheduled audits</div>  <div class="row g-3">    <div class="col-12 col-md-6">      <div class="card h-100">        <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">          <strong>Crawler</strong>      <div class="monitor-card h-100">        <div class="monitor-header">          <span class="monitor-title">Crawler</span>          <span class="d-flex align-items-center gap-2">            <span class="badge" data-field="crawler.state_badge">&nbsp;</span>            <button type="button" id="recrawl-btn" class="btn btn-sm btn-outline-light py-0" title="Recrawl now">
@@ -142,40 +144,35 @@            </button>          </span>        </div>        <div class="card-body">          <div class="progress mb-3 d-none" data-field="crawler.progress_wrap" style="height: 6px;">        <div class="monitor-body">          <div class="progress mb-3 d-none" data-field="crawler.progress_wrap" style="height: 4px;">            <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" data-field="crawler.progress_bar" style="width: 0%"></div>          </div>          <div class="alert alert-danger py-2 mb-3 d-none small" data-field="crawler.error_box" role="alert">            <strong>Last crawl failed:</strong>            <code data-field="crawler.error_text"></code>          </div>          <dl class="row small mb-0">            <dt class="col-5 col-sm-5 text-muted">Last success</dt>            <dd class="col-7 col-sm-7 mb-1" data-field="crawler.last_success">โ€”</dd>            <dt class="col-5 col-sm-5 text-muted">Last attempt</dt>            <dd class="col-7 col-sm-7 mb-1" data-field="crawler.last_attempt">โ€”</dd>            <dt class="col-5 col-sm-5 text-muted">Pages crawled</dt>            <dd class="col-7 col-sm-7 mb-1" data-field="crawler.pages">โ€”</dd>            <dt class="col-5 col-sm-5 text-muted">Duration</dt>            <dd class="col-7 col-sm-7 mb-1" data-field="crawler.duration">โ€”</dd>            <dt class="col-5 col-sm-5 text-muted">Issues found</dt>            <dd class="col-7 col-sm-7 mb-1" data-field="crawler.insights">โ€”</dd>            <dt class="col-5 col-sm-5 text-muted">Next run</dt>            <dd class="col-7 col-sm-7 mb-0" data-field="crawler.next_run">โ€”</dd>          <dl>            <dt>Last success</dt>            <dd data-field="crawler.last_success">โ€”</dd>            <dt>Last attempt</dt>            <dd data-field="crawler.last_attempt">โ€”</dd>            <dt>Pages crawled</dt>            <dd data-field="crawler.pages">โ€”</dd>            <dt>Duration</dt>            <dd data-field="crawler.duration">โ€”</dd>            <dt>Issues found</dt>            <dd data-field="crawler.insights">โ€”</dd>            <dt>Next run</dt>            <dd data-field="crawler.next_run">โ€”</dd>          </dl>        </div>      </div>    </div>    <div class="col-12 col-md-6">      <div class="card h-100">        <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">          <strong>Lighthouse</strong>      <div class="monitor-card h-100">        <div class="monitor-header">          <span class="monitor-title">Lighthouse</span>          <span class="d-flex align-items-center gap-2">            <span class="badge" data-field="lighthouse.state_badge">&nbsp;</span>            <button type="button" id="rerun-lighthouse-btn" class="btn btn-sm btn-outline-light py-0" title="Rerun Lighthouse now">
@@ -184,23 +181,20 @@            </button>          </span>        </div>        <div class="card-body">        <div class="monitor-body">          <div class="alert alert-warning py-2 mb-3 d-none small" data-field="lighthouse.error_box" role="alert">            <strong>Last Lighthouse run failed:</strong>            <code data-field="lighthouse.error_text"></code>          </div>          <dl class="row small mb-0">            <dt class="col-5 col-sm-5 text-muted">Last success</dt>            <dd class="col-7 col-sm-7 mb-1" data-field="lighthouse.last_success">โ€”</dd>            <dt class="col-5 col-sm-5 text-muted">Last attempt</dt>            <dd class="col-7 col-sm-7 mb-1" data-field="lighthouse.last_attempt">โ€”</dd>            <dt class="col-5 col-sm-5 text-muted">Duration</dt>            <dd class="col-7 col-sm-7 mb-1" data-field="lighthouse.duration">โ€”</dd>            <dt class="col-5 col-sm-5 text-muted">Next run</dt>            <dd class="col-7 col-sm-7 mb-0" data-field="lighthouse.next_run">โ€”</dd>          <dl>            <dt>Last success</dt>            <dd data-field="lighthouse.last_success">โ€”</dd>            <dt>Last attempt</dt>            <dd data-field="lighthouse.last_attempt">โ€”</dd>            <dt>Duration</dt>            <dd data-field="lighthouse.duration">โ€”</dd>            <dt>Next run</dt>            <dd data-field="lighthouse.next_run">โ€”</dd>          </dl>        </div>      </div>
@@ -209,89 +203,91 @@</div>{% endif %}<div class="container mt-4">  <div class="row"><div class="container my-4">  <div class="row g-3">    <div class="col-12 col-md-8">      <div class="row">        <div class="col-12">          <div class="bg-light mb-4 p-2 rounded">            <canvas id="chart-response-times" height="300" width="100%"></canvas>          </div>      <div class="chart-panel mb-3">        <div class="chart-panel-header">          <span class="chart-panel-title">response time ยท last 31 checks</span>        </div>        <div style="position: relative; height: 300px;">          <canvas id="chart-response-times"></canvas>        </div>      </div>      <div class="row" id="doughnut-graphs">      <div class="row g-3">        <div class="col-12 col-md-6">          <div class="bg-light mb-4 p-2 rounded">          <div class="chart-panel">            <div class="chart-panel-header">              <span class="chart-panel-title">status codes</span>            </div>            <canvas id="chart-status-codes"></canvas>          </div>        </div>        <div class="col-12 col-md-6">          <div class="bg-light mb-4 p-2 rounded">          <div class="chart-panel">            <div class="chart-panel-header">              <span class="chart-panel-title">uptime split</span>            </div>            <canvas id="chart-uptime"></canvas>          </div>        </div>      </div>    </div>    <div class="col-12 col-md-4">      <ul class="list-group mb-4">        <li class="list-group-item bg-dark text-white">          <strong>Security</strong>        </li>        <li class="list-group-item d-flex justify-content-between bg-light border-white border-start-0 border-end-0">          <span>Use HTTPS</span>          <span class="badge {% if property.is_https %}bg-success{% else %}bg-danger{% endif %}">{{ property.is_https }}</span>        </li>        <li class="list-group-item d-flex justify-content-between bg-light border-white border-start-0 border-end-0">          <span>Set MIME types</span>          <span class="badge {% if property.has_mime_type %}bg-success{% else %}bg-danger{% endif %}">{{ property.has_mime_type }}</span>        </li>        <li class="list-group-item d-flex justify-content-between bg-light border-white border-start-0 border-end-0">          <span>Use content sniffing protection</span>          <span class="badge {% if property.has_content_sniffing_protection %}bg-success{% else %}bg-danger{% endif %}">{{ property.has_content_sniffing_protection }}</span>        </li>        <li class="list-group-item d-flex justify-content-between bg-light border-white border-start-0 border-end-0">          <span>Use clickjack protection</span>          <span class="badge {% if property.has_clickjack_protection %}bg-success{% else %}bg-danger{% endif %}">{{ property.has_clickjack_protection }}</span>        </li>        <li class="list-group-item d-flex justify-content-between bg-light border-white border-start-0 border-end-0">          <span>Use XSS protection</span>          <span class="badge {% if property.has_xss_protection %}bg-success{% else %}bg-danger{% endif %}">{{ property.has_xss_protection }}</span>        </li>        <li class="list-group-item d-flex justify-content-between bg-light border-white border-start-0 border-end-0">          <span>Hide server version data</span>          <span class="badge {% if property.hides_server_version %}bg-success{% else %}bg-danger{% endif %}">{{ property.hides_server_version }}</span>        </li>        <li class="list-group-item d-flex justify-content-between bg-light border-white border-start-0 border-end-0">          <span>Use HSTS</span>          <span class="badge {% if property.has_hsts %}bg-success{% else %}bg-danger{% endif %}">{{ property.has_hsts }}</span>        </li>        <li class="list-group-item d-flex justify-content-between bg-light border-white border-start-0 border-end-0">          <span>Use HSTS preload</span>          <span class="badge {% if property.has_hsts_preload %}bg-success{% else %}bg-danger{% endif %}">{{ property.has_hsts_preload }}</span>        </li>      </ul>      <div class="check-list">        <div class="check-list-header">security ยท header scan</div>        <div class="check-list-item">          <span class="check-name">Use HTTPS</span>          <span class="chip {% if property.is_https %}chip-ok{% else %}chip-down{% endif %}">{% if property.is_https %}pass{% else %}fail{% endif %}</span>        </div>        <div class="check-list-item">          <span class="check-name">Set MIME types</span>          <span class="chip {% if property.has_mime_type %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_mime_type %}pass{% else %}fail{% endif %}</span>        </div>        <div class="check-list-item">          <span class="check-name">Content sniff protection</span>          <span class="chip {% if property.has_content_sniffing_protection %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_content_sniffing_protection %}pass{% else %}fail{% endif %}</span>        </div>        <div class="check-list-item">          <span class="check-name">Clickjack protection</span>          <span class="chip {% if property.has_clickjack_protection %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_clickjack_protection %}pass{% else %}fail{% endif %}</span>        </div>        <div class="check-list-item">          <span class="check-name">XSS protection</span>          <span class="chip {% if property.has_xss_protection %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_xss_protection %}pass{% else %}fail{% endif %}</span>        </div>        <div class="check-list-item">          <span class="check-name">Hide server version</span>          <span class="chip {% if property.hides_server_version %}chip-ok{% else %}chip-down{% endif %}">{% if property.hides_server_version %}pass{% else %}fail{% endif %}</span>        </div>        <div class="check-list-item">          <span class="check-name">HSTS enabled</span>          <span class="chip {% if property.has_hsts %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_hsts %}pass{% else %}fail{% endif %}</span>        </div>        <div class="check-list-item">          <span class="check-name">HSTS preload</span>          <span class="chip {% if property.has_hsts_preload %}chip-ok{% else %}chip-down{% endif %}">{% if property.has_hsts_preload %}pass{% else %}fail{% endif %}</span>        </div>      </div>    </div>  </div></div>{% if property.lighthouse_details %}<div class="container mt-4">  <h3 class="mt-4">Performance breakdown</h3><div class="container my-4">  <div class="section-label mb-2">performance breakdown</div>  <p class="text-muted small mb-3">    Lighthouse combines these weighted metrics to produce the Performance score. The opportunities below are the biggest wins โ€” savings are estimated against the audited URL only.    Lighthouse combines these weighted metrics to produce the Performance score. Opportunities below are the biggest wins โ€” savings are estimated against the audited URL only.  </p>  {% if property.lighthouse_details.metrics %}  <div class="row mb-4">  <div class="row g-3 mb-4">    {% for metric in property.lighthouse_details.metrics %}    <div class="col-6 col-md d-flex">      <div class="card w-100 mb-2">        <div class="card-body text-center py-2 border-top border-4 border-{{ metric.score|lh_score_class }}">          <div class="card-title h5 mb-0">{{ metric.display_value|default:"โ€”" }}</div>          <div class="card-text text-truncate small" title="{{ metric.title }}">            {{ metric.acronym }} <span class="text-muted">ยท {{ metric.weight }}%</span>          </div>        </div>      <div class="metric-tile w-100 {% if metric.score >= 0.9 %}metric-accent-green{% elif metric.score >= 0.5 %}metric-accent-amber{% else %}metric-accent-danger{% endif %}">        <div class="metric-label">{{ metric.acronym }} ยท {{ metric.weight }}%</div>        <div class="metric-value" style="font-size: 1.2rem;">{{ metric.display_value|default:"โ€”" }}</div>        <div class="metric-sub" title="{{ metric.title }}">{{ metric.title }}</div>      </div>    </div>    {% endfor %}
@@ -299,64 +295,64 @@  {% endif %}  {% if property.lighthouse_details.opportunities %}  <h4 class="mt-4">Top opportunities</h4>  <div class="row bg-dark text-white py-2 mb-2 rounded rounded-sm fw-bolder">    <div class="col-2 col-md-1">Score</div>    <div class="col-6 col-md-7">Audit</div>    <div class="col-4 col-md-2">Detail</div>    <div class="col-12 col-md-2 text-md-end">Est. savings</div>  <div class="section-label mb-2">top opportunities</div>  <div class="insight-header">    <span>Score</span>    <span>Audit</span>    <span>Detail</span>    <span class="text-end">Est. savings</span>  </div>  {% for opp in property.lighthouse_details.opportunities %}  <div class="row bg-light py-2 mb-2 rounded rounded-sm align-items-center">    <div class="col-2 col-md-1">      <span class="badge bg-{{ opp.score|lh_score_class }}">{{ opp.score|floatformat:"2" }}</span>    </div>    <div class="col-6 col-md-7">{{ opp.title }}</div>    <div class="col-4 col-md-2 text-truncate small text-muted" {% if opp.display_value %}data-bs-toggle="tooltip" data-bs-title="{{ opp.display_value }}"{% endif %}>  <div class="insight-row">    <span>      <span class="chip {% if opp.score >= 0.9 %}chip-ok{% elif opp.score >= 0.5 %}chip-warn{% else %}chip-down{% endif %}">        {{ opp.score|floatformat:"2" }}      </span>    </span>    <span class="insight-col-trunc" title="{{ opp.title }}">{{ opp.title }}</span>    <span class="insight-col-trunc text-muted small" {% if opp.display_value %}data-bs-toggle="tooltip" data-bs-title="{{ opp.display_value }}"{% endif %}>      {{ opp.display_value|default:"โ€”" }}    </div>    <div class="col-12 col-md-2 text-md-end small">    </span>    <span class="text-end small">      {% with saved=opp.savings_ms|format_ms_savings %}        {% if saved %}<strong>{{ saved }}</strong>{% else %}<span class="text-muted">โ€”</span>{% endif %}        {% if saved %}<strong class="text-amber">{{ saved }}</strong>{% else %}<span class="text-muted">โ€”</span>{% endif %}      {% endwith %}    </div>    </span>  </div>  {% endfor %}  {% else %}  <p class="text-success small">No actionable opportunities โ€” everything passing at this URL.</p>  <div class="alert alert-info py-2 small">No actionable opportunities โ€” everything passing at this URL.</div>  {% endif %}</div>{% endif %}{% if property.crawler_insights %}<div class="container mt-4"><div class="container my-4">  {% regroup property.crawler_insights|dictsort:"type" by type as insight_type_groups %}  {% for group in insight_type_groups %}  <h3 class="mt-4 text-capitalize">{{ group.grouper }} <small class="text-muted">({{ group.list|length }})</small></h3>  <div class="row bg-dark text-white py-2 mb-2 rounded rounded-sm fw-bolder">    <div class="col-2">Severity</div>    <div class="col-3">URL</div>    <div class="col-4">Issue</div>    <div class="col-3">Item</div>  <div class="section-label mb-2">{{ group.grouper }} ยท <span class="text-muted">{{ group.list|length }}</span></div>  <div class="insight-header">    <span>Severity</span>    <span>URL</span>    <span>Issue</span>    <span>Item</span>  </div>  {% for insight in group.list|dictsort:"severity" %}  <div class="row bg-light py-2 mb-2 rounded rounded-sm">    <div class="col-md-2 d-flex align-items-center">      <span class="badge {% if insight.severity == 'error' %}bg-danger{% elif insight.severity == 'warning' %}bg-warning text-dark{% else %}bg-info text-dark{% endif %}">  <div class="insight-row">    <span>      <span class="chip {% if insight.severity == 'error' %}chip-down{% elif insight.severity == 'warning' %}chip-warn{% else %}chip-info{% endif %}">        {{ insight.severity|upper }}      </span>    </div>    <div class="col-md-3 text-truncate">      <a href="{{ insight.url }}" target="_blank">        {{ insight.url|url_path }}      </a>    </div>    <div class="col-md-4 text-truncate" {% if insight.issue %}data-bs-toggle="tooltip" data-bs-title="{{ insight.issue }}"{% endif %}>    </span>    <span class="insight-col-trunc">      <a href="{{ insight.url }}" target="_blank" class="text-muted">{{ insight.url|url_path }}</a>    </span>    <span class="insight-col-trunc" {% if insight.issue %}data-bs-toggle="tooltip" data-bs-title="{{ insight.issue }}"{% endif %}>      {{ insight.issue }}    </div>    <div class="col-md-3 text-truncate" {% if insight.item %}data-bs-toggle="tooltip" data-bs-title="{{ insight.item }}"{% endif %}>    </span>    <span class="insight-col-trunc text-muted small" {% if insight.item %}data-bs-toggle="tooltip" data-bs-title="{{ insight.item }}"{% endif %}>      {{ insight.item }}    </div>    </span>  </div>  {% endfor %}  {% endfor %}
added properties/templates/properties/property_report.html
@@ -0,0 +1,341 @@{% load static properties_tags %}<!doctype html><html lang="en"><head>  <meta charset="utf-8">  <title>{{ property.name }} ยท Status Report</title>  <style>    @page { size: letter; margin: 0.6in; }    :root {      --ink: #1a1d1b;      --ink-soft: #4a4a44;      --ink-muted: #7a7a74;      --paper: #ffffff;      --paper-2: #f6f4ef;      --rule: #d8d4c8;      --accent: #4e7a5a;         /* deeper moss on white */      --accent-soft: #e8f0ea;      --amber: #9a7f2e;      --amber-soft: #f6efd7;      --danger: #9f3f2b;      --danger-soft: #f5e3dd;    }    * { box-sizing: border-box; }    html, body {      background: var(--paper);      color: var(--ink);      font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;      font-size: 10pt;      line-height: 1.5;      margin: 0;      padding: 0;    }    h1, h2, h3, h4 {      margin: 0 0 0.4em 0;      font-weight: 700;      color: var(--ink);      letter-spacing: -0.01em;    }    h1 { font-size: 22pt; line-height: 1.15; }    h2 { font-size: 12pt; margin-top: 1.4em; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); border-bottom: 1px solid var(--rule); padding-bottom: 0.25em; }    h3 { font-size: 10.5pt; margin-top: 1em; }    p { margin: 0 0 0.6em 0; }    a { color: var(--accent); text-decoration: none; }    code, .mono {      font-family: ui-monospace, Menlo, Consolas, 'Courier New', monospace;      font-size: 9pt;    }    .doc-header {      display: flex;      justify-content: space-between;      align-items: flex-end;      gap: 1em;      padding-bottom: 0.8em;      border-bottom: 2px solid var(--ink);      margin-bottom: 1em;    }    .doc-header .brand {      font-family: ui-monospace, Menlo, Consolas, monospace;      font-size: 9pt;      letter-spacing: 0.14em;      text-transform: uppercase;      color: var(--ink-muted);    }    .doc-header .brand strong { color: var(--ink); letter-spacing: 0.08em; }    .meta-url {      color: var(--ink-soft);      font-family: ui-monospace, Menlo, Consolas, monospace;      font-size: 9.5pt;    }    .meta-line {      color: var(--ink-muted);      font-size: 9pt;      font-family: ui-monospace, Menlo, Consolas, monospace;    }    /* Summary tiles */    .tile-grid {      display: grid;      grid-template-columns: repeat(4, 1fr);      gap: 0.5em;      margin: 1em 0;    }    .tile {      border: 1px solid var(--rule);      border-left: 3px solid var(--accent);      padding: 0.55em 0.7em;      background: var(--paper);    }    .tile.bad { border-left-color: var(--danger); }    .tile.warn { border-left-color: var(--amber); }    .tile-label {      font-size: 7.5pt;      letter-spacing: 0.1em;      text-transform: uppercase;      color: var(--ink-muted);      font-family: ui-monospace, Menlo, Consolas, monospace;    }    .tile-value {      font-size: 16pt;      font-weight: 700;      color: var(--ink);      line-height: 1.1;      margin-top: 0.15em;    }    .tile-sub { font-size: 8pt; color: var(--ink-muted); margin-top: 0.1em; }    /* Key/value block */    table.kv {      width: 100%;      border-collapse: collapse;      margin: 0.5em 0;    }    table.kv td {      padding: 0.3em 0.5em;      border-bottom: 1px solid var(--rule);      vertical-align: top;    }    table.kv td.k {      width: 35%;      color: var(--ink-muted);      font-size: 9pt;      letter-spacing: 0.04em;    }    /* Data tables */    table.data {      width: 100%;      border-collapse: collapse;      margin: 0.4em 0 1em;      font-size: 9pt;    }    table.data th, table.data td {      text-align: left;      padding: 0.35em 0.55em;      border-bottom: 1px solid var(--rule);      vertical-align: top;    }    table.data th {      background: var(--paper-2);      font-size: 8pt;      letter-spacing: 0.08em;      text-transform: uppercase;      color: var(--ink-muted);      font-weight: 600;      border-bottom: 1.5px solid var(--ink-muted);    }    table.data tbody tr:nth-child(even) td { background: var(--paper-2); }    /* Pass/fail marker used in security + checks */    .mark {      display: inline-block;      padding: 0.1em 0.5em;      font-size: 8pt;      font-family: ui-monospace, Menlo, Consolas, monospace;      letter-spacing: 0.06em;      text-transform: uppercase;      border-radius: 2px;      border: 1px solid transparent;    }    .mark.ok { background: var(--accent-soft); color: var(--accent); border-color: #b9d3c2; }    .mark.warn { background: var(--amber-soft); color: var(--amber); border-color: #e2d39b; }    .mark.fail { background: var(--danger-soft); color: var(--danger); border-color: #dfb2a7; }    .mark.muted { background: #f0ede5; color: var(--ink-muted); border-color: var(--rule); }    /* Security two-column grid */    .sec-grid {      display: grid;      grid-template-columns: 1fr 1fr;      gap: 0 1.2em;      margin: 0.3em 0 1em;    }    .sec-item {      display: flex;      justify-content: space-between;      padding: 0.3em 0;      border-bottom: 1px solid var(--rule);      font-size: 9pt;    }    .footer-note {      margin-top: 1.5em;      padding-top: 0.6em;      border-top: 1px solid var(--rule);      font-size: 8.5pt;      color: var(--ink-muted);    }    .insight-severity {      text-transform: uppercase;      font-size: 7.5pt;      letter-spacing: 0.08em;    }    .col-narrow { width: 90px; }    .col-short { width: 70px; text-align: right; }  </style></head><body>  <div class="doc-header">    <div>      <div class="brand"><strong>Status</strong> ยท property report</div>      <h1>{{ property.name }}</h1>      <a class="meta-url" href="{{ property.url }}">{{ property.url }}</a>    </div>    <div class="meta-line" style="text-align: right;">      generated ยท {% now "Y-m-d H:i" %}<br>      operator ยท {{ property.user.username }}    </div>  </div>  <h2>Live signals</h2>  <div class="tile-grid">    <div class="tile {% if property.current_status != 200 %}bad{% endif %}">      <div class="tile-label">HTTP Status</div>      <div class="tile-value">{{ property.current_status }}</div>      <div class="tile-sub">{% if property.current_status == 200 %}OK ยท all clear{% else %}failing{% endif %}</div>    </div>    <div class="tile {% if property.invalid_cert %}bad{% endif %}">      <div class="tile-label">TLS cert</div>      <div class="tile-value">{% if property.invalid_cert %}Invalid{% else %}Valid{% endif %}</div>      <div class="tile-sub">chain + trust</div>    </div>    <div class="tile {% if property.has_security_issue %}warn{% endif %}">      <div class="tile-label">Security</div>      <div class="tile-value">{% if property.has_security_issue %}Issues{% else %}Pass{% endif %}</div>      <div class="tile-sub">header scan</div>    </div>    <div class="tile {% if property.avg_response_time > 500 %}warn{% endif %}">      <div class="tile-label">Response</div>      <div class="tile-value">{{ property.avg_response_time }} <span style="font-size: 9pt; color: var(--ink-muted); font-weight: 500;">ms</span></div>      <div class="tile-sub">rolling avg ยท 31 checks</div>    </div>  </div>  <table class="kv">    <tr><td class="k">Alert state</td><td>{{ property.alert_state|title }}{% if property.last_alert_sent %} <span class="meta-line">ยท last alert {{ property.last_alert_sent|date:"Y-m-d H:i" }}</span>{% endif %}</td></tr>    <tr><td class="k">Visibility</td><td>{% if property.is_public %}Public{% else %}Private{% endif %}</td></tr>    <tr><td class="k">Registered</td><td>{{ property.created_at|date:"Y-m-d" }}</td></tr>    <tr><td class="k">Total checks</td><td>{{ property.total_checks }}</td></tr>    {% if property.recent_uptime_pct is not None %}    <tr><td class="k">Uptime (last 100)</td><td>{{ property.recent_uptime_pct }}%</td></tr>    {% endif %}  </table>  {% if property.lighthouse_scores %}  <h2>Lighthouse</h2>  <div class="tile-grid">    {% for category, score in property.lighthouse_scores.items %}    <div class="tile {% if score < 50 %}bad{% elif score < 90 %}warn{% endif %}">      <div class="tile-label">{{ category }}</div>      <div class="tile-value">{{ score }}<span style="font-size: 10pt; color: var(--ink-muted);">%</span></div>    </div>    {% endfor %}  </div>  {% endif %}  <h2>Security ยท header scan</h2>  <div class="sec-grid">    <div class="sec-item"><span>Use HTTPS</span><span class="mark {% if property.is_https %}ok{% else %}fail{% endif %}">{% if property.is_https %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>Set MIME types</span><span class="mark {% if property.has_mime_type %}ok{% else %}fail{% endif %}">{% if property.has_mime_type %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>Content sniff protection</span><span class="mark {% if property.has_content_sniffing_protection %}ok{% else %}fail{% endif %}">{% if property.has_content_sniffing_protection %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>Clickjack protection</span><span class="mark {% if property.has_clickjack_protection %}ok{% else %}fail{% endif %}">{% if property.has_clickjack_protection %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>XSS protection</span><span class="mark {% if property.has_xss_protection %}ok{% else %}fail{% endif %}">{% if property.has_xss_protection %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>Hide server version</span><span class="mark {% if property.hides_server_version %}ok{% else %}fail{% endif %}">{% if property.hides_server_version %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>HSTS enabled</span><span class="mark {% if property.has_hsts %}ok{% else %}fail{% endif %}">{% if property.has_hsts %}pass{% else %}fail{% endif %}</span></div>    <div class="sec-item"><span>HSTS preload</span><span class="mark {% if property.has_hsts_preload %}ok{% else %}fail{% endif %}">{% if property.has_hsts_preload %}pass{% else %}fail{% endif %}</span></div>  </div>  {% if property.lighthouse_details.metrics %}  <h2>Performance metrics</h2>  <table class="data">    <thead>      <tr><th>Acronym</th><th>Metric</th><th class="col-narrow">Value</th><th class="col-short">Weight</th><th class="col-short">Score</th></tr>    </thead>    <tbody>      {% for metric in property.lighthouse_details.metrics %}      <tr>        <td><strong>{{ metric.acronym }}</strong></td>        <td>{{ metric.title }}</td>        <td class="mono">{{ metric.display_value|default:"โ€”" }}</td>        <td class="col-short">{{ metric.weight }}%</td>        <td class="col-short"><span class="mark {% if metric.score >= 0.9 %}ok{% elif metric.score >= 0.5 %}warn{% else %}fail{% endif %}">{{ metric.score|floatformat:"2" }}</span></td>      </tr>      {% endfor %}    </tbody>  </table>  {% endif %}  {% if property.lighthouse_details.opportunities %}  <h2>Top opportunities</h2>  <table class="data">    <thead>      <tr><th class="col-short">Score</th><th>Audit</th><th>Detail</th><th class="col-narrow">Est. savings</th></tr>    </thead>    <tbody>      {% for opp in property.lighthouse_details.opportunities %}      <tr>        <td class="col-short"><span class="mark {% if opp.score >= 0.9 %}ok{% elif opp.score >= 0.5 %}warn{% else %}fail{% endif %}">{{ opp.score|floatformat:"2" }}</span></td>        <td>{{ opp.title }}</td>        <td class="mono">{{ opp.display_value|default:"โ€”" }}</td>        <td class="col-narrow">{% with saved=opp.savings_ms|format_ms_savings %}{% if saved %}<strong>{{ saved }}</strong>{% else %}โ€”{% endif %}{% endwith %}</td>      </tr>      {% endfor %}    </tbody>  </table>  {% endif %}  {% if property.crawler_insights %}  <h2>Crawler insights</h2>  {% regroup property.crawler_insights|dictsort:"type" by type as insight_type_groups %}  {% for group in insight_type_groups %}  <h3>{{ group.grouper|capfirst }} <span class="meta-line">({{ group.list|length }})</span></h3>  <table class="data">    <thead>      <tr><th class="col-narrow">Severity</th><th>URL</th><th>Issue</th><th>Item</th></tr>    </thead>    <tbody>      {% for insight in group.list|dictsort:"severity" %}      <tr>        <td><span class="mark {% if insight.severity == 'error' %}fail{% elif insight.severity == 'warning' %}warn{% else %}muted{% endif %} insight-severity">{{ insight.severity }}</span></td>        <td class="mono">{{ insight.url|url_path }}</td>        <td>{{ insight.issue }}</td>        <td class="mono">{{ insight.item|default:"โ€”" }}</td>      </tr>      {% endfor %}    </tbody>  </table>  {% endfor %}  {% endif %}  <div class="footer-note">    Status ยท self-hosted website monitoring. Report generated from live data at the moment above. Metrics refresh every 3 minutes (HTTP), daily (Lighthouse), weekly (crawler).  </div></body></html>
added properties/templates/properties/property_report.md
@@ -0,0 +1,77 @@{% load properties_tags %}# {{ property.name }}**URL:** {{ property.url }}**Generated:** {% now "Y-m-d H:i" %}**Operator:** {{ property.user.username }}---## Live signals| Signal | Value | Status ||---|---|---|| HTTP status | `{{ property.current_status }}` | {% if property.current_status == 200 %}OK{% else %}failing{% endif %} || TLS cert | {% if property.invalid_cert %}invalid{% else %}valid{% endif %} | {% if property.invalid_cert %}fail{% else %}pass{% endif %} || Security headers | {% if property.has_security_issue %}issues{% else %}clean{% endif %} | {% if property.has_security_issue %}warn{% else %}pass{% endif %} || Avg response | `{{ property.avg_response_time }} ms` | {% if property.avg_response_time > 500 %}slow{% else %}ok{% endif %} || Alert state | `{{ property.alert_state }}` | {% if property.last_alert_sent %}last alert {{ property.last_alert_sent|date:"Y-m-d H:i" }}{% endif %} || Visibility | {% if property.is_public %}public{% else %}private{% endif %} | || Registered | {{ property.created_at|date:"Y-m-d" }} | || Total checks | {{ property.total_checks }} | |{% if property.recent_uptime_pct is not None %}| Uptime (last 100) | `{{ property.recent_uptime_pct }}%` | |{% endif %}{% if property.lighthouse_scores %}## Lighthouse scores| Category | Score ||---|---|{% for category, score in property.lighthouse_scores.items %}| {{ category|capfirst }} | `{{ score }}%` |{% endfor %}{% endif %}## Security ยท header scan| Check | Result ||---|---|| Use HTTPS | {% if property.is_https %}pass{% else %}fail{% endif %} || Set MIME types | {% if property.has_mime_type %}pass{% else %}fail{% endif %} || Content sniff protection | {% if property.has_content_sniffing_protection %}pass{% else %}fail{% endif %} || Clickjack protection | {% if property.has_clickjack_protection %}pass{% else %}fail{% endif %} || XSS protection | {% if property.has_xss_protection %}pass{% else %}fail{% endif %} || Hide server version | {% if property.hides_server_version %}pass{% else %}fail{% endif %} || HSTS enabled | {% if property.has_hsts %}pass{% else %}fail{% endif %} || HSTS preload | {% if property.has_hsts_preload %}pass{% else %}fail{% endif %} |{% if property.lighthouse_details.metrics %}## Performance metrics| Acronym | Metric | Value | Weight | Score ||---|---|---|---|---|{% for metric in property.lighthouse_details.metrics %}| **{{ metric.acronym }}** | {{ metric.title }} | `{{ metric.display_value|default:"โ€”" }}` | {{ metric.weight }}% | {{ metric.score|floatformat:"2" }} |{% endfor %}{% endif %}{% if property.lighthouse_details.opportunities %}## Top opportunities| Score | Audit | Detail | Est. savings ||---|---|---|---|{% for opp in property.lighthouse_details.opportunities %}| {{ opp.score|floatformat:"2" }} | {{ opp.title }} | `{{ opp.display_value|default:"โ€”" }}` | {% with saved=opp.savings_ms|format_ms_savings %}{% if saved %}**{{ saved }}**{% else %}โ€”{% endif %}{% endwith %} |{% endfor %}{% endif %}{% if property.crawler_insights %}## Crawler insights{% regroup property.crawler_insights|dictsort:"type" by type as insight_type_groups %}{% for group in insight_type_groups %}### {{ group.grouper|capfirst }} ({{ group.list|length }})| Severity | URL | Issue | Item ||---|---|---|---|{% for insight in group.list|dictsort:"severity" %}| {{ insight.severity }} | `{{ insight.url|url_path }}` | {{ insight.issue }} | `{{ insight.item|default:"โ€”" }}` |{% endfor %}{% endfor %}{% endif %}---_Report generated by Status โ€” self-hosted website monitoring. HTTP checks every 3 minutes, Lighthouse daily, crawler weekly._
modified properties/urls.py
@@ -10,6 +10,5 @@ urlpatterns = [    path('<uuid:property_id>/status/', views.property_status, name='property_status'),    path('<uuid:property_id>/recrawl/', views.property_recrawl, name='property_recrawl'),    path('<uuid:property_id>/rerun-lighthouse/', views.property_rerun_lighthouse, name='property_rerun_lighthouse'),    path('import/', views.import_properties, name='import_properties'),    path('', views.properties, name='properties'),]
modified properties/views.py
@@ -1,9 +1,5 @@import csvimport ioimport threadingimport uuidimport requestsfrom django.conf import settingsfrom django.contrib import messagesfrom django.core.files.storage import default_storage
@@ -139,15 +135,24 @@ def property(request, property_id):        {"label": "Downtime", "count": downtime_pct},    ]    if request.GET.get("report") == "":        context["print"] = True        html = render_to_string("properties/property.html", context)        filename = f"reports/{uuid.uuid4()}.pdf"        generate_pdf_from_html(html, filename)        with open(default_storage.path(filename), "rb") as pdf:            response = HttpResponse(pdf.read(), content_type="application/pdf")            response["Content-Disposition"] = "inline; filename=report.pdf"    # Report formats. `?report` (or `?report=pdf`) returns a printed PDF    # rendered from the light-themed report template; `?report=md` returns a    # plain-text Markdown report suited for piping into an LLM.    if "report" in request.GET:        fmt = request.GET.get("report") or "pdf"        if fmt == "md":            md = render_to_string("properties/property_report.md", context)            response = HttpResponse(md, content_type="text/markdown; charset=utf-8")            response["Content-Disposition"] = f'inline; filename="{property_obj.name}.md"'            return response        if fmt == "pdf":            html = render_to_string("properties/property_report.html", context)            filename = f"reports/{uuid.uuid4()}.pdf"            generate_pdf_from_html(html, filename)            with open(default_storage.path(filename), "rb") as pdf:                response = HttpResponse(pdf.read(), content_type="application/pdf")                response["Content-Disposition"] = "inline; filename=report.pdf"                return response    return render(request, "properties/property.html", context)
@@ -285,37 +290,3 @@ def property_rerun_lighthouse(request, property_id):    return JsonResponse({"ok": True, **_serialize_status(property_obj)})def import_property(request, url):    url = url.lower().strip()    if not url.startswith("http"):        url = "http://" + url    try:        r = requests.get(url)        r.raise_for_status()    except requests.exceptions.RequestException:        return    if not Property.objects.filter(url=r.url).exists():        property_obj = Property(            url=r.url,            user=request.user,        )        property_obj.save()def import_properties(request):    if not request.user.is_authenticated:        return redirect("/")    if request.method == "POST":        file = request.FILES["csv_file"]        reader = csv.reader(io.TextIOWrapper(file.file, encoding="utf-8"))        for row in reader:            try:                url = row[0]                threading.Thread(target=import_property, args=(request, url)).start()            except IndexError:                continue        messages.success(request, "Properties imported successfully.")        return redirect("properties")    return redirect("properties")
modified status/chromium.py
@@ -10,12 +10,13 @@ import uuidfrom django.core.files.storage import default_storage# Get chromium path, it's sometimes chromium and sometimes chromium-browser# Chromium/Chrome binary discovery โ€” distro names vary (Alpine: chromium,# Debian/Ubuntu: chromium-browser, the webdev container ships google-chrome).chromium = Noneif shutil.which("chromium"):    chromium = "chromium"elif shutil.which("chromium-browser"):    chromium = "chromium-browser"for binary in ("chromium", "chromium-browser", "google-chrome"):    if shutil.which(binary):        chromium = binary        breakbase_command = [
@@ -30,7 +31,7 @@ base_command = [    "--disable-extensions",    "--disable-in-process-stack-traces",    "--disable-logging",    "--window-size=1280x720",    "--window-size=1280,720",    "--hide-scrollbars",] if chromium else []
modified status/static_src/index.js
@@ -1,3 +1,6 @@// fontsimport "@fontsource/monaspace-argon";// scriptsimport "./scripts/bootstrap.js";
added status/static_src/styles/_variables.scss
@@ -0,0 +1,129 @@// Status โ€” warm-earth dark theme, aligned to blog.bythewood.me// Mossy forest green + warm amber on a deep earth background. Quieter// than true cyberpunk โ€” feels like a UNIX operator's console.// โ”€โ”€ Typography โ”€โ”€$font-family-sans-serif: 'Monaspace Argon', ui-monospace, 'Cascadia Code', Consolas, 'SF Mono', Menlo, monospace;$font-family-monospace: 'Monaspace Argon', ui-monospace, 'Cascadia Code', Consolas, 'SF Mono', Menlo, monospace;$headings-font-family: null;$headings-font-weight: 700;$font-size-base: 0.92rem;$line-height-base: 1.7;$headings-line-height: 1.3;// โ”€โ”€ Core palette โ€” warm earth darks โ”€โ”€$white: #ede8e0;$gray-100: #ddd7cd;$gray-200: #c4bdb2;$gray-300: #a09890;$gray-400: #847c72;$gray-500: #665f56;$gray-600: #4a443c;$gray-700: #332e28;$gray-800: #211e1a;$gray-900: #161310;$black: #0b0a08;$body-bg: #0e0d0a;$body-color: #ddd7cd;// โ”€โ”€ Accents โ€” muted forest green + warm amber โ”€โ”€$green: #6b9e78;$green-bright: #7db88c;$green-dim: rgba(107, 158, 120, 0.12);$green-border: rgba(107, 158, 120, 0.22);$amber: #c9a84c;$amber-dim: rgba(201, 168, 76, 0.1);$amber-border: rgba(201, 168, 76, 0.22);$terracotta: #c47055;$terracotta-dim: rgba(196, 112, 85, 0.1);$terracotta-border: rgba(196, 112, 85, 0.22);// โ”€โ”€ Theme mapping โ”€โ”€$primary: $green;$secondary: #4a443c;$success: $green;$info: #7eaab8;$warning: $amber;$danger: $terracotta;$light: #211e1a;$dark: #0e0d0a;// โ”€โ”€ Links โ”€โ”€$link-color: $green-bright;$link-hover-color: #95cca2;$link-decoration: none;$link-hover-decoration: underline;// โ”€โ”€ Borders โ”€โ”€$border-color: rgba(221, 215, 205, 0.06);$border-radius: 0.25rem;$border-radius-sm: 0.2rem;$border-radius-lg: 0.375rem;// โ”€โ”€ Cards โ”€โ”€$card-bg: #13120e;$card-border-color: rgba(107, 158, 120, 0.1);$card-color: $body-color;$card-cap-bg: rgba(221, 215, 205, 0.02);// โ”€โ”€ Inputs โ”€โ”€$input-bg: #13120e;$input-color: $body-color;$input-border-color: rgba(107, 158, 120, 0.18);$input-focus-bg: #18160f;$input-focus-color: $body-color;$input-focus-border-color: $green;$input-focus-box-shadow: 0 0 0 2px rgba(107, 158, 120, 0.15);$input-placeholder-color: #665f56;// โ”€โ”€ Navbar โ”€โ”€$navbar-dark-color: rgba(221, 215, 205, 0.6);$navbar-dark-hover-color: $white;$navbar-dark-active-color: $white;// โ”€โ”€ List group โ”€โ”€$list-group-bg: #13120e;$list-group-border-color: rgba(107, 158, 120, 0.08);$list-group-hover-bg: #1a1812;$list-group-action-color: #a09890;$list-group-action-hover-color: $white;$list-group-action-active-bg: #211e1a;$list-group-action-active-color: $white;$list-group-active-bg: $green-dim;$list-group-active-border-color: $green-border;$list-group-active-color: $green-bright;// โ”€โ”€ Breadcrumbs โ”€โ”€$breadcrumb-active-color: $white;$breadcrumb-divider-color: #665f56;// โ”€โ”€ Dropdowns โ”€โ”€$dropdown-bg: #13120e;$dropdown-border-color: rgba(107, 158, 120, 0.12);$dropdown-color: $body-color;$dropdown-link-color: #a09890;$dropdown-link-hover-color: $white;$dropdown-link-hover-bg: #1a1812;// โ”€โ”€ Close button โ”€โ”€$btn-close-color: $body-color;// โ”€โ”€ Modal โ”€โ”€$modal-content-bg: #13120e;$modal-content-border-color: rgba(107, 158, 120, 0.15);$modal-header-border-color: rgba(107, 158, 120, 0.08);$modal-footer-border-color: rgba(107, 158, 120, 0.08);$modal-backdrop-bg: #000;$modal-backdrop-opacity: 0.75;// โ”€โ”€ Progress โ”€โ”€$progress-bg: rgba(221, 215, 205, 0.05);$progress-bar-bg: $green;// โ”€โ”€ Tables โ”€โ”€$table-bg: transparent;$table-color: $body-color;$table-border-color: rgba(107, 158, 120, 0.08);
modified status/static_src/styles/base.scss
@@ -1,44 +1,812 @@@use "variables" as *;// โ”€โ”€ CSS variable overrides โ”€โ”€// Bootstrap 5.3 writes defaults into `:root,[data-bs-theme=light]`. Use// `html:root` so we outrank the default block and enforce the dark palette.html:root {  --bs-body-bg: #{$body-bg};  --bs-body-color: #{$body-color};  --bs-body-bg-rgb: 14, 13, 10;  --bs-body-color-rgb: 221, 215, 205;  --bs-emphasis-color: #{$white};  --bs-secondary-bg: #13120e;  --bs-tertiary-bg: #18160f;  --bs-border-color: rgba(107, 158, 120, 0.12);  --bs-link-color-rgb: 125, 184, 140;  --bs-link-hover-color-rgb: 149, 204, 162;  --bs-heading-color: #{$white};}// โ”€โ”€ Global โ”€โ”€body {    min-height: 100vh;  min-height: 100vh;  display: flex;  flex-direction: column;  -webkit-font-smoothing: antialiased;  -moz-osx-font-smoothing: grayscale;  letter-spacing: 0.01em;  background-color: $body-bg !important;  color: $body-color;}main { flex: 1; }.vh-100 { height: 100vh; }// โ”€โ”€ Logo (uptime-bars mark) โ”€โ”€// Rendered as an inline SVG in the template so it scales cleanly. Matches the// favicon exactly..logo {  display: inline-flex;  align-items: center;  justify-content: center;  width: 28px;  height: 28px;  text-decoration: none !important;  transition: transform 250ms ease;  svg {    width: 100%;    height: 100%;    display: block;  }  &:hover {    transform: translateY(-1px);  }  &.logo-sm {    width: 18px;    height: 18px;  }}// โ”€โ”€ Navbar โ”€โ”€.navbar {  height: 52px;  padding: 0;  background: #090806 !important;  border-bottom: 1px solid rgba(107, 158, 120, 0.06);  .navbar-brand {    display: flex;    flex-direction: column;    align-items: center;    gap: 0.6rem;    color: $white;    font-weight: 600;    letter-spacing: 0.1em;    text-transform: uppercase;    font-size: 0.78rem;    text-decoration: none;    .brand-text { color: $white; }    .brand-dot {      display: inline-block;      width: 6px; height: 6px; border-radius: 50%;      background: $green;    }  }  .nav-link {    font-size: 0.8rem;    font-weight: 500;    letter-spacing: 0.05em;    text-transform: lowercase;    padding-left: 1rem !important;    padding-right: 1rem !important;    color: rgba(221, 215, 205, 0.6);    position: relative;    transition: color 200ms ease;    &::after {      content: '';      position: absolute;      bottom: 2px;      left: 1rem;      right: 1rem;      height: 1px;      background: $green;      transform: scaleX(0);      transition: transform 250ms ease;    }    &:hover,    &.active { color: $white; }    &:hover::after,    &.active::after { transform: scaleX(1); }  }}main {    flex: 1;// โ”€โ”€ Breadcrumb bar โ”€โ”€.breadcrumb-bar {  background: rgba(107, 158, 120, 0.02);  border-bottom: 1px solid rgba(107, 158, 120, 0.04);  .breadcrumb { font-family: $font-family-monospace; }}.vh-100 {    height: 100vh;// โ”€โ”€ Section labels โ€” amber caps, no prompt glyph โ”€โ”€.section-label {  font-size: 0.7rem;  font-weight: 600;  letter-spacing: 0.12em;  text-transform: uppercase;  color: rgba(201, 168, 76, 0.75);  font-family: $font-family-monospace;}.login-hero {  background: var(--background-image);  background-size: cover;  background-position: center;  background-repeat: no-repeat;// โ”€โ”€ Cards (base) โ”€โ”€.card {  background: $card-bg;  border: 1px solid $card-border-color;  transition: border-color 250ms ease;  &:hover { border-color: rgba(107, 158, 120, 0.2); }}.logo {  content: "";  display: block;  background-image: linear-gradient(to right, rgb(14, 63, 244) 0px, rgb(132, 43, 255) 100%);  width: 36px;  height: 36px;  margin-top: auto;  margin-bottom: auto;  transition: transform .2s ease-in-out;// โ”€โ”€ Status dot โ”€โ”€.status-dot {  display: inline-block;  width: 8px; height: 8px;  border-radius: 50%;  margin-right: 0.55rem;  vertical-align: middle;  flex-shrink: 0;  &.status-dot-lg {    width: 14px; height: 14px;    margin-right: 0;  }  &.is-up { background: $green; box-shadow: 0 0 0 3px rgba(107, 158, 120, 0.12); }  &.is-down { background: $terracotta; box-shadow: 0 0 0 3px rgba(196, 112, 85, 0.12); }  &.is-warn { background: $amber; box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.12); }  &.is-idle { background: #4a443c; }}// โ”€โ”€ Visibility toggle (segmented pill) โ”€โ”€// Replaces the Bootstrap form-switch which was near-invisible on the dark// theme. Clear two-state UI โ€” active half highlighted, inactive half dim..toggle-label {  font-family: $font-family-monospace;  font-size: 0.68rem;  font-weight: 600;  letter-spacing: 0.12em;  text-transform: uppercase;  color: #847c72;  cursor: help;}.toggle-pill {  display: inline-flex;  align-items: stretch;  border: 1px solid rgba(107, 158, 120, 0.22);  border-radius: 999px;  overflow: hidden;  cursor: pointer;  background: #0e0d0a;  margin: 0;  user-select: none;  line-height: 1;  input {    position: absolute;    opacity: 0;    pointer-events: none;    width: 0; height: 0;  }  .toggle-pill-seg {    padding: 0.45rem 0.9rem;    font-family: $font-family-monospace;    font-size: 0.7rem;    font-weight: 600;    letter-spacing: 0.08em;    text-transform: uppercase;    color: #665f56;    transition: background 180ms ease, color 180ms ease;  }  // Default (unchecked) = Private is active  input ~ .toggle-pill-private {    background: rgba(201, 168, 76, 0.12);    color: #ddc06a;  }  // Checked = Public is active  input:checked ~ .toggle-pill-private {    background: transparent;    color: #665f56;  }  input:checked ~ .toggle-pill-public {    background: $green-dim;    color: $green-bright;  }  &:hover {    transform: rotate(15deg) scale(1.1);    border-color: rgba(107, 158, 120, 0.35);  }}.navbar {  height: 50px;  padding: 0;// โ”€โ”€ Metric tile โ”€โ”€// Every tile fills its grid cell so a row of them all share one height, even// when content length varies (e.g. one tile has a sub-line, others don't)..metric-tile {  background: $card-bg;  border: 1px solid $card-border-color;  border-radius: $border-radius;  padding: 1rem 1.15rem;  height: 100%;  display: flex;  flex-direction: column;  .metric-label {    font-size: 0.68rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: #847c72;  }  .metric-value {    font-size: 1.75rem;    font-weight: 700;    line-height: 1.1;    color: $white;    margin-top: 0.35rem;    letter-spacing: -0.01em;  }  .metric-sub {    font-size: 0.72rem;    color: #847c72;    margin-top: auto;  // Pins sub to the bottom so all tiles baseline-align    padding-top: 0.35rem;    letter-spacing: 0.02em;  }  &.metric-accent-green {    border-color: $green-border;    .metric-value { color: $green-bright; }  }  &.metric-accent-amber {    border-color: $amber-border;    .metric-value { color: #ddc06a; }  }  &.metric-accent-danger {    border-color: $terracotta-border;    .metric-value { color: #d88870; }  }}// โ”€โ”€ Chips (inline status/score) โ”€โ”€.chip {  display: inline-flex;  align-items: center;  gap: 0.4rem;  font-family: $font-family-monospace;  font-size: 0.7rem;  font-weight: 600;  letter-spacing: 0.05em;  text-transform: uppercase;  padding: 0.3rem 0.6rem;  border-radius: 2px;  border: 1px solid transparent;  line-height: 1;  &.chip-ok {    background: $green-dim;    color: $green-bright;    border-color: $green-border;  }  &.chip-down {    background: $terracotta-dim;    color: #e38871;    border-color: $terracotta-border;  }  &.chip-warn {    background: $amber-dim;    color: #ddc06a;    border-color: $amber-border;  }  &.chip-info {    background: rgba(126, 170, 184, 0.08);    color: #9ec5d2;    border-color: rgba(126, 170, 184, 0.22);  }  &.chip-muted {    background: rgba(221, 215, 205, 0.04);    color: #847c72;    border-color: rgba(221, 215, 205, 0.08);  }}// โ”€โ”€ Footer โ”€โ”€footer {  padding: 6rem 0;  padding-top: 4rem;  padding-bottom: 3rem;  position: relative;  background: #090806;  border-top: 1px solid rgba(107, 158, 120, 0.06);  .h5 {    font-size: 0.7rem;    letter-spacing: 0.12em;    text-transform: uppercase;    color: rgba(201, 168, 76, 0.55);    font-weight: 600;  }  p {    color: rgba(221, 215, 205, 0.5);    font-size: 0.85rem;    line-height: 1.75;    a {      color: rgba(221, 215, 205, 0.75);      text-decoration: underline;      text-underline-offset: 2px;      &:hover { color: $white; }    }  }  .links {    padding-top: 2rem;    padding-bottom: 1rem;  }}.footer-bar {  background: #050403;  border-top: 1px solid rgba(107, 158, 120, 0.04);  padding: 0.8rem 0;  color: rgba(221, 215, 205, 0.3);  font-size: 0.78rem;  small { color: rgba(221, 215, 205, 0.3); }  &-link {    color: rgba(221, 215, 205, 0.3);    transition: color 200ms ease;    &:hover { color: $white; }  }}// โ”€โ”€ Uptime strip โ”€โ”€.uptime-strip {  display: flex;  gap: 2px;  align-items: flex-end;  height: 20px;  .uptime-tick {    flex: 1 1 auto;    min-width: 2px;    height: 100%;    background: #211e1a;    border-radius: 1px;    &.is-up { background: $green; opacity: 0.7; }    &.is-down { background: $terracotta; opacity: 0.85; }    &.is-warn { background: $amber; opacity: 0.7; }    &.is-none { background: #18160f; }  }}// โ”€โ”€ Terminal-style block โ”€โ”€.terminal-block {  background: rgba(9, 8, 6, 0.7);  border: 1px solid rgba(107, 158, 120, 0.15);  border-radius: $border-radius;  font-family: $font-family-monospace;  font-size: 0.8rem;  color: $green-bright;  padding: 1rem 1.2rem;  line-height: 1.8;  .t-line { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }  .t-prompt { color: $amber; margin-right: 0.4rem; }  .t-out { color: $gray-200; }  .t-comment { color: #665f56; }  .t-key { color: $amber; }  .t-val { color: $green-bright; }  .t-cursor {    display: inline-block; width: 7px; height: 0.9em; vertical-align: -2px;    background: $green-bright;    animation: t-blink 1s steps(1) infinite;  }}@keyframes t-blink {  0%, 50% { opacity: 1; }  51%, 100% { opacity: 0; }}// โ”€โ”€ Property row โ”€โ”€.property-row {  display: grid;  grid-template-columns: minmax(0, 1fr) auto;  gap: 1rem 1.5rem;  background: $card-bg;  border: 1px solid $card-border-color;  border-radius: $border-radius;  padding: 1rem 1.15rem;  margin-bottom: 0.75rem;  transition: border-color 200ms ease;  &:hover { border-color: rgba(107, 158, 120, 0.22); }  &.is-down { border-color: rgba(196, 112, 85, 0.3); }  .pr-main {    min-width: 0;    display: flex;    flex-direction: column;    gap: 0.35rem;  }  .pr-title {    font-weight: 600;    color: $white;    display: flex;    align-items: center;    min-width: 0;    a {      color: inherit;      text-decoration: none;      min-width: 0;      overflow: hidden;      text-overflow: ellipsis;      white-space: nowrap;      &:hover { color: $green-bright; }    }  }  .pr-url {    font-size: 0.73rem;    color: #847c72;    overflow: hidden;    text-overflow: ellipsis;    white-space: nowrap;  }  .pr-side {    display: flex;    flex-direction: column;    justify-content: space-between;    align-items: flex-end;    gap: 0.75rem;    min-height: 68px; // Matches the left column's natural height so actions pin to bottom  }  // Uniform stat grid โ€” each cell is label-over-chip so every column aligns  // vertically regardless of whether the value is a pill or a number.  .pr-meta {    display: flex;    gap: 0.85rem;    align-items: flex-end;    font-family: $font-family-monospace;    .pr-meta-cell {      display: flex;      flex-direction: column;      align-items: flex-start;      gap: 0.2rem;      min-width: 0;    }    .pr-meta-k {      color: #665f56;      text-transform: uppercase;      font-size: 0.6rem;      letter-spacing: 0.1em;      line-height: 1;    }  }  .pr-actions {    display: flex;    gap: 0.4rem;  }}@media (max-width: 767px) {  .property-row {    grid-template-columns: 1fr;    .pr-side {      min-height: 0;      align-items: stretch;    }    .pr-meta {      flex-wrap: wrap;      justify-content: flex-start;    }    .pr-actions {      justify-content: flex-end;    }  }}// โ”€โ”€ Search form โ”€โ”€.search-form {  position: relative;  .search-icon {    position: absolute;    left: 0.9rem;    top: 50%;    transform: translateY(-50%);    color: #665f56;    pointer-events: none;    z-index: 2;  }  .search-input {    padding-left: 2.5rem;    font-size: 0.85rem;    border: 1px solid rgba(107, 158, 120, 0.15);    background: rgba(19, 18, 14, 0.7);    color: $body-color;    transition: border-color 200ms ease;    &::placeholder {      color: #665f56;      font-style: normal;    }    &:focus {      border-color: rgba(107, 158, 120, 0.4);      box-shadow: 0 0 0 2px rgba(107, 158, 120, 0.1);      background: #13120e;    }  }}// โ”€โ”€ Dashboard toolbar โ”€โ”€// Input + button sharing one visual line โ€” both get the same height (38px)// so the CTA doesn't read as a different size from the field next to it..dashboard-toolbar {  display: flex;  gap: 0.5rem;  align-items: stretch;  .form-control {    height: 38px;    font-size: 0.85rem;  }  .btn {    height: 38px;    padding: 0 1.1rem;    display: inline-flex;    align-items: center;    white-space: nowrap;  }}// โ”€โ”€ Chart panel โ”€โ”€.chart-panel {  background: $card-bg;  border: 1px solid $card-border-color;  border-radius: $border-radius;  padding: 1rem 1.15rem;  .chart-panel-header {    display: flex;    align-items: center;    justify-content: space-between;    margin-bottom: 0.75rem;  }  .chart-panel-title {    font-size: 0.68rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: rgba(201, 168, 76, 0.75);    font-family: $font-family-monospace;  }}// โ”€โ”€ Security list โ”€โ”€.check-list {  background: $card-bg;  border: 1px solid $card-border-color;  border-radius: $border-radius;  overflow: hidden;  .check-list-header {    background: rgba(107, 158, 120, 0.04);    padding: 0.6rem 1rem;    font-size: 0.68rem;    font-weight: 600;    letter-spacing: 0.12em;    text-transform: uppercase;    color: rgba(201, 168, 76, 0.75);    font-family: $font-family-monospace;    border-bottom: 1px solid rgba(107, 158, 120, 0.1);  }  .check-list-item {    display: flex;    justify-content: space-between;    align-items: center;    padding: 0.55rem 1rem;    font-size: 0.85rem;    border-bottom: 1px solid rgba(107, 158, 120, 0.04);    &:last-child { border-bottom: none; }    .check-name {      color: $gray-300;      display: flex;      align-items: center;      gap: 0.5rem;    }  }}// โ”€โ”€ Monitor card โ”€โ”€// When used inside a flex/grid row, `h-100` class stretches the card to its// cell height. The body grows to fill any extra space so the two cards next// to each other stay visually balanced even with different dl row counts..monitor-card {  background: $card-bg;  border: 1px solid $card-border-color;  border-radius: $border-radius;  overflow: hidden;  display: flex;  flex-direction: column;  .monitor-header {    display: flex;    justify-content: space-between;    align-items: center;    padding: 0.7rem 1rem;    background: rgba(107, 158, 120, 0.04);    border-bottom: 1px solid rgba(107, 158, 120, 0.08);    flex-shrink: 0;    .monitor-title {      font-family: $font-family-monospace;      font-size: 0.78rem;      font-weight: 600;      letter-spacing: 0.06em;      text-transform: uppercase;      color: $white;    }  }  .monitor-body {    padding: 0.9rem 1rem;    flex: 1 1 auto;    dl {      margin: 0;      display: grid;      grid-template-columns: max-content 1fr;      gap: 0.4rem 1rem;    }    dt {      font-size: 0.7rem;      letter-spacing: 0.06em;      text-transform: uppercase;      color: #665f56;      font-weight: 500;    }    dd {      margin: 0;      font-size: 0.8rem;      color: $gray-200;      font-family: $font-family-monospace;    }  }}// โ”€โ”€ Insight row (lighthouse opportunities / crawler issues) โ”€โ”€.insight-header {  display: grid;  grid-template-columns: 110px 1fr 1fr 140px;  gap: 1rem;  padding: 0.55rem 1rem;  background: rgba(107, 158, 120, 0.04);  border: 1px solid rgba(107, 158, 120, 0.08);  border-radius: $border-radius;  font-family: $font-family-monospace;  font-size: 0.68rem;  letter-spacing: 0.08em;  text-transform: uppercase;  color: rgba(201, 168, 76, 0.75);  margin-bottom: 0.6rem;}.insight-row {  display: grid;  grid-template-columns: 110px 1fr 1fr 140px;  gap: 1rem;  padding: 0.65rem 1rem;  background: $card-bg;  border: 1px solid $card-border-color;  border-radius: $border-radius;  margin-bottom: 0.4rem;  font-size: 0.85rem;  align-items: center;  &:hover { border-color: rgba(107, 158, 120, 0.2); }  .insight-col-trunc {    min-width: 0;    overflow: hidden;    text-overflow: ellipsis;    white-space: nowrap;  }}@media (max-width: 767px) {  .insight-header { display: none; }  .insight-row {    grid-template-columns: 1fr;    gap: 0.4rem;  }}// โ”€โ”€ Auth split view โ”€โ”€.auth-shell {  min-height: calc(100vh - 52px);  display: grid;  grid-template-columns: 1fr 1fr;  @media (max-width: 991px) {    grid-template-columns: 1fr;  }  .auth-form-col {    padding: 3rem 2rem;    display: flex;    align-items: center;    justify-content: center;  }  .auth-visual-col {    position: relative;    overflow: hidden;    background: linear-gradient(145deg, #090806 0%, #0e0d0a 60%, #13120e 100%);    border-left: 1px solid rgba(107, 158, 120, 0.08);    @media (max-width: 991px) { display: none; }  }  .auth-form-wrap {    width: 100%;    max-width: 380px;  }}
modified status/static_src/styles/bootstrap.scss
@@ -1,25 +1,151 @@@use "bootstrap/scss/bootstrap" as *;@use "variables" as *;@use "bootstrap/scss/bootstrap" with (  $font-family-sans-serif: $font-family-sans-serif,  $font-family-monospace: $font-family-monospace,  $headings-font-family: $headings-font-family,  $headings-font-weight: $headings-font-weight,  $font-size-base: $font-size-base,  $line-height-base: $line-height-base,  $headings-line-height: $headings-line-height,  $white: $white,  $gray-100: $gray-100,  $gray-200: $gray-200,  $gray-300: $gray-300,  $gray-400: $gray-400,  $gray-500: $gray-500,  $gray-600: $gray-600,  $gray-700: $gray-700,  $gray-800: $gray-800,  $gray-900: $gray-900,  $black: $black,  $body-bg: $body-bg,  $body-color: $body-color,  $primary: $primary,  $secondary: $secondary,  $success: $success,  $info: $info,  $warning: $warning,  $danger: $danger,  $light: $light,  $dark: $dark,  $link-color: $link-color,  $link-hover-color: $link-hover-color,  $link-decoration: $link-decoration,  $link-hover-decoration: $link-hover-decoration,  $border-color: $border-color,  $border-radius: $border-radius,  $border-radius-sm: $border-radius-sm,  $border-radius-lg: $border-radius-lg,  $card-bg: $card-bg,  $card-border-color: $card-border-color,  $card-color: $card-color,  $card-cap-bg: $card-cap-bg,  $input-bg: $input-bg,  $input-color: $input-color,  $input-border-color: $input-border-color,  $input-focus-bg: $input-focus-bg,  $input-focus-color: $input-focus-color,  $input-focus-border-color: $input-focus-border-color,  $input-focus-box-shadow: $input-focus-box-shadow,  $input-placeholder-color: $input-placeholder-color,  $navbar-dark-color: $navbar-dark-color,  $navbar-dark-hover-color: $navbar-dark-hover-color,  $navbar-dark-active-color: $navbar-dark-active-color,  $list-group-bg: $list-group-bg,  $list-group-border-color: $list-group-border-color,  $list-group-hover-bg: $list-group-hover-bg,  $list-group-action-color: $list-group-action-color,  $list-group-action-hover-color: $list-group-action-hover-color,  $list-group-action-active-bg: $list-group-action-active-bg,  $list-group-action-active-color: $list-group-action-active-color,  $list-group-active-bg: $list-group-active-bg,  $list-group-active-border-color: $list-group-active-border-color,  $list-group-active-color: $list-group-active-color,  $breadcrumb-active-color: $breadcrumb-active-color,  $breadcrumb-divider-color: $breadcrumb-divider-color,  $dropdown-bg: $dropdown-bg,  $dropdown-border-color: $dropdown-border-color,  $dropdown-color: $dropdown-color,  $dropdown-link-color: $dropdown-link-color,  $dropdown-link-hover-color: $dropdown-link-hover-color,  $dropdown-link-hover-bg: $dropdown-link-hover-bg,  $btn-close-color: $btn-close-color,  $modal-content-bg: $modal-content-bg,  $modal-content-border-color: $modal-content-border-color,  $modal-header-border-color: $modal-header-border-color,  $modal-footer-border-color: $modal-footer-border-color,  $modal-backdrop-bg: $modal-backdrop-bg,  $modal-backdrop-opacity: $modal-backdrop-opacity,  $progress-bg: $progress-bg,  $progress-bar-bg: $progress-bar-bg,  $table-bg: $table-bg,  $table-color: $table-color,  $table-border-color: $table-border-color,);// โ”€โ”€ Surface utilities โ”€โ”€.bg-surface {  background: #13120e !important;}.bg-surface-2 {  background: #18160f !important;}.bg-deep {  background: #09080626 !important; // very subtle warm black}.bg-deep,.bg-black {  background-color: $black !important;  background-color: #090806 !important;}.bg-teal-900 {  background-color: $teal-900 !important;.border-subtle {  border-color: rgba(107, 158, 120, 0.1) !important;}.bg-teal-800 {  background-color: $teal-800 !important;.text-muted {  color: #9e968a !important;}.text-green {  color: $green-bright !important;}.text-amber {  color: $amber !important;}// Legacy utility aliases used by existing templates.bg-teal-900 { background-color: #090806 !important; }.bg-teal-800 { background-color: #0e0d0a !important; }.link-footer {  color: $gray-500;  color: $gray-400;  text-decoration: none;  transition: color 100ms ease-in-out;  transition: color 200ms ease;  &:hover {    color: $white;    text-decoration: underline;  }}
@@ -27,24 +153,161 @@  display: block;  flex-wrap: nowrap;  white-space: nowrap;  margin-bottom: 0;  .breadcrumb-item {    font-size: 0.9em;    font-size: 0.82em;    white-space: nowrap;    display: inline-block;    a {      color: $gray-500;      color: $gray-400;      text-decoration: none;      transition: color .15s ease-in-out;      transition: color 150ms ease;      &:hover {        color: $gray-200;        color: $green-bright;      }    }    &.active {      color: $white;      color: $gray-200;    }  }}// Buttons โ€” flat, no glow.btn {  letter-spacing: 0.02em;  font-weight: 500;  transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease;}.btn-primary {  color: #0b0a08;  background: $green;  border-color: $green;  &:hover,  &:focus {    color: #0b0a08;    background: $green-bright;    border-color: $green-bright;  }}.btn-success {  color: #0b0a08;  background: $green;  border-color: $green;  &:hover,  &:focus {    color: #0b0a08;    background: $green-bright;    border-color: $green-bright;  }}.btn-outline-light {  border-color: rgba(221, 215, 205, 0.18);  color: $gray-200;  &:hover,  &:focus {    background: rgba(221, 215, 205, 0.05);    border-color: rgba(221, 215, 205, 0.35);    color: $white;  }}.btn-outline-danger {  color: $terracotta;  border-color: $terracotta-border;  &:hover,  &:focus {    background: $terracotta-dim;    color: #d88870;    border-color: rgba(196, 112, 85, 0.4);  }}.btn-link {  color: $green-bright;}// Alerts for dark.alert { border-radius: $border-radius; }.alert-info {  background: rgba(126, 170, 184, 0.08);  border-color: rgba(126, 170, 184, 0.2);  color: #9ec5d2;}.alert-warning {  background: $amber-dim;  border-color: $amber-border;  color: #ddc06a;}.alert-danger {  background: $terracotta-dim;  border-color: $terracotta-border;  color: #e38871;}.alert-success {  background: $green-dim;  border-color: $green-border;  color: $green-bright;}// Forms.form-control,.form-select {  background-color: $input-bg;  border-color: $input-border-color;  color: $input-color;  &:focus {    background-color: $input-focus-bg;  }}.form-floating > label {  color: #665f56;}// Badges.badge {  font-weight: 500;  letter-spacing: 0.04em;  padding: 0.35em 0.55em;}// Pagination.page-link {  background: $card-bg;  border-color: $card-border-color;  color: $gray-300;  &:hover {    background: #1a1812;    border-color: $green-border;    color: $green-bright;  }}.page-item.active .page-link {  background: $green-dim;  border-color: $green-border;  color: $green-bright;}.page-item.disabled .page-link {  background: $card-bg;  border-color: $card-border-color;  color: #4a443c;}
modified status/templates/403.html
@@ -6,15 +6,19 @@{% block description %}You are not allowed to access this page.{% endblock %}{% block breadcrumbs_wrapper %}{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container"><div class="container py-5">  <div class="row">    <div class="col text-center py-5">      <h1 class="display-1">403</h1>      <p>You are not allowed to access this page.</p>    <div class="col-12 col-lg-8 offset-lg-2 text-center py-5">      <div class="section-label mb-3" style="display:inline-flex;">access denied</div>      <div class="display-1 fw-bolder text-white mb-3" style="font-size: clamp(5rem, 16vw, 10rem); letter-spacing: -0.04em; text-shadow: 0 0 30px rgba(239, 106, 91, 0.3);">        <span style="color: #ef6a5b;">4</span><span style="color: #e6b84c;">0</span><span style="color: #ef6a5b;">3</span>      </div>      <p class="text-muted mb-4">Credentials lack the clearance required for this endpoint.</p>      <a href="/" class="btn btn-primary">โ† Back to base</a>    </div>  </div></div>
modified status/templates/404.html
@@ -6,15 +6,19 @@{% block description %}That means the page you are looking for doesn't exist.{% endblock %}{% block breadcrumbs_wrapper %}{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container"><div class="container py-5">  <div class="row">    <div class="col text-center py-5">      <h1 class="display-1">404</h1>      <p>That means the page you are looking for doesn't exist.</p>    <div class="col-12 col-lg-8 offset-lg-2 text-center py-5">      <div class="section-label mb-3" style="display:inline-flex;">signal lost</div>      <div class="display-1 fw-bolder text-white mb-3" style="font-size: clamp(5rem, 16vw, 10rem); letter-spacing: -0.04em; text-shadow: 0 0 30px rgba(239, 106, 91, 0.3);">        <span style="color: #ef6a5b;">4</span><span style="color: #58d4e6;">0</span><span style="color: #ef6a5b;">4</span>      </div>      <p class="text-muted mb-4">The endpoint you pinged does not resolve. No bytes returned.</p>      <a href="/" class="btn btn-primary">โ† Back to base</a>    </div>  </div></div>
modified status/templates/500.html
@@ -6,15 +6,19 @@{% block description %}Something went wrong.{% endblock %}{% block breadcrumbs_wrapper %}{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container"><div class="container py-5">  <div class="row">    <div class="col text-center py-5">      <h1 class="display-1">500</h1>      <p>Something went wrong.</p>    <div class="col-12 col-lg-8 offset-lg-2 text-center py-5">      <div class="section-label mb-3" style="display:inline-flex;">server fault</div>      <div class="display-1 fw-bolder text-white mb-3" style="font-size: clamp(5rem, 16vw, 10rem); letter-spacing: -0.04em; text-shadow: 0 0 30px rgba(239, 106, 91, 0.3);">        <span style="color: #ef6a5b;">5</span><span style="color: #ef6a5b;">0</span><span style="color: #ef6a5b;">0</span>      </div>      <p class="text-muted mb-4">Something threw on the way back. We've been notified.</p>      <a href="/" class="btn btn-primary">โ† Back to base</a>    </div>  </div></div>
modified status/templates/base.html
@@ -14,6 +14,8 @@  <base href="{{ BASE_URL }}">  {% endif %}  <link rel="icon" type="image/svg+xml" href="{% url 'favicon' %}">  {% block extra_head %}{% endblock %}  <link href="{% static 'base.css' %}" rel="stylesheet">
@@ -24,29 +26,54 @@  {% include 'includes/messages.html' %}  {% block nav %}  <nav class="navbar bg-teal-900 d-print-none">  <nav class="navbar navbar-dark navbar-expand-md d-print-none">    <div class="container">      <a class="navbar-brand text-white" href="/">        <span class="display-1 fs-4 text-white text-decoration-none text-uppercase my-0">Status</span>      </a>      {% if user.is_authenticated %}      <form method="post" action="{% url 'logout' %}" class="d-inline">        {% csrf_token %}        <button type="submit" class="btn btn-sm btn-outline-light">          Logout        </button>      </form>      {% else %}      <a href="/accounts/login/" class="btn btn-sm btn-outline-light">        Login      <a class="navbar-brand" href="/">        <span class="logo" aria-hidden="true">          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">            <polyline points="2,34 18,34 24,28 30,14 36,52 42,20 48,34 62,34"              fill="none" stroke="#6b9e78" stroke-width="6"              stroke-linejoin="round" stroke-linecap="round"/>            <circle cx="30" cy="14" r="3.5" fill="#c9a84c"/>          </svg>        </span>        <span class="brand-text">Status</span>        <span class="brand-dot" aria-hidden="true"></span>      </a>      {% endif %}      <button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">        <span class="navbar-toggler-icon"></span>      </button>      <div class="collapse navbar-collapse" id="navMain">        <ul class="navbar-nav ms-3">          {% if user.is_authenticated %}          <li class="nav-item"><a class="nav-link {% if request.path == '/properties/' %}active{% endif %}" href="/properties/">Properties</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/accounts/profile/' %}active{% endif %}" href="/accounts/profile/">Profile</a></li>          {% else %}          <li class="nav-item"><a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/">Home</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/changelog/' %}active{% endif %}" href="/changelog/">Changelog</a></li>          {% endif %}        </ul>        <ul class="navbar-nav ms-auto">          {% if user.is_authenticated %}          <li class="nav-item d-flex align-items-center">            <form method="post" action="{% url 'logout' %}" class="d-inline">              {% csrf_token %}              <button type="submit" class="btn btn-sm btn-outline-light">Logout</button>            </form>          </li>          {% else %}          <li class="nav-item d-flex align-items-center">            <a href="/accounts/login/" class="btn btn-sm btn-outline-light">Login</a>          </li>          {% endif %}        </ul>      </div>    </div>  </nav>  {% endblock %}  {% block breadcrumb_wrapper %}  <div class="bg-teal-800 py-2 d-print-none">  <div class="breadcrumb-bar py-2 overflow-auto d-print-none">    <div class="container">      {% block breadcrumbs %}{% endblock %}    </div>
@@ -58,22 +85,25 @@  </main>  {% block footer %}  <footer class="mt-4 bg-black text-light d-print-none">  <footer class="d-print-none">    <div class="container">      <div class="row">        <div class="col-12 col-md-12 col-lg-4 mb-5 mb-lg-0">          <div class="h5 font-monospace mb-3">Status</div>          <p>Made by <a href="https://isaacbythewood.com/" class="text-light">Isaac Bythewood</a>, a simple opinionated self-hosted website status monitoring solution.</p>      <div class="row links">        <div class="col-12 col-lg-6 mb-4 mb-lg-0">          <div class="h5 mb-3">// Status</div>          <p>Self-hosted website monitoring by <a href="https://isaacbythewood.com/">Isaac Bythewood</a>.            HTTP checks every three minutes, Lighthouse audits, SEO crawler,            security header analysis. Alerts via email + Discord. Own your uptime.</p>        </div>        <div class="col-6 col-md-4 offset-lg-4 col-lg-2">          <div class="h5 font-monospace mb-3">Pages</div>        <div class="col-6 col-lg-2 offset-lg-2">          <div class="h5 mb-3">// Pages</div>          <ul class="list-unstyled">            <li class="mb-2"><a href="/" class="link-footer">Home</a></li>            <li class="mb-2"><a href="/changelog/" class="link-footer">Changelog</a></li>            <li class="mb-2"><a href="https://github.com/overshard/status" class="link-footer" target="_blank">Source</a></li>          </ul>        </div>        <div class="col-6 col-md-4 col-lg-2">          <div class="h5 font-monospace mb-3">Accounts</div>        <div class="col-6 col-lg-2">          <div class="h5 mb-3">// Accounts</div>          <ul class="list-unstyled">            {% if user.is_authenticated %}            <li class="mb-2"><a href="/properties/" class="link-footer">Properties</a></li>
@@ -94,21 +124,26 @@    </div>  </footer>  <div class="bg-dark py-2 d-print-none">  <div class="footer-bar d-print-none">    <div class="container">      <div class="row">        <div class="col-sm-6 d-flex align-items-center justify-content-center justify-content-sm-start mt-3 mt-sm-0 order-1 order-sm-0">          <small class="text-light">            &copy; {% now 'Y' %} Isaac Bythewood. Some rights reserved.          </small>      <div class="row align-items-center">        <div class="col-sm-6 d-flex align-items-center justify-content-center justify-content-sm-start order-1 order-sm-0">          <small>&copy; {% now 'Y' %} Isaac Bythewood ยท Some rights reserved</small>        </div>        <div class="col-sm-6 d-flex justify-content-center justify-content-sm-end">          <a href="https://github.com/overshard/status" target="_blank" class="text-light fs-3 d-flex align-items-center" aria-label="GitHub">            <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="bi bi-github" viewBox="0 0 16 16">        <div class="col-sm-6 d-flex justify-content-center justify-content-sm-end py-3 py-sm-0">          <a href="https://github.com/overshard/status" target="_blank" class="footer-bar-link" aria-label="GitHub">            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">              <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>            </svg>          </a>          <a href="https://isaacbythewood.com/" target="_blank" class="logo shadow ms-4" aria-label="Isaac Bythewood"></a>          <a href="https://isaacbythewood.com/" target="_blank" class="logo logo-sm ms-3" aria-label="Isaac Bythewood">            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">              <polyline points="2,34 18,34 24,28 30,14 36,52 42,20 48,34 62,34"                fill="none" stroke="#6b9e78" stroke-width="6"                stroke-linejoin="round" stroke-linecap="round"/>              <circle cx="30" cy="14" r="3.5" fill="#c9a84c"/>            </svg>          </a>        </div>      </div>    </div>
modified status/templates/registration/logged_out.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -13,8 +13,13 @@{% block main %}<div class="container my-5">  <h1>{{ title }}</h1>  <p>You've signed out, you can sign in again if you want here:</p>  <a href="{% url 'login' %}" class="btn btn-primary">Login</a>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">session ended</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">You've been signed out. Hit the button below when you're ready to come back.</p>      <a href="{% url 'login' %}" class="btn btn-primary">Login โ†’</a>    </div>  </div></div>{% endblock %}
modified status/templates/registration/login.html
@@ -3,7 +3,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>
@@ -12,87 +12,66 @@{% endblock %}{% block nav %}{% endblock %}{% block main %}<div class="auth-shell">  <div class="auth-form-col">    <div class="auth-form-wrap">      <div class="section-label mb-2">authenticate</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em; font-size: 1.9rem;">{{ title }}</h1>      <p class="text-muted small mb-4">Access is invite-only. Contact the operator for credentials.</p>      {% if form.errors and not form.non_field_errors %}      <div class="alert alert-warning py-2 small">        {% if form.errors.items|length == 1 %}{% translate "Please correct the error below." %}{% else %}{% translate "Please correct the errors below." %}{% endif %}      </div>      {% endif %}{% block breadcrumb_wrapper %}{% endblock %}      {% if form.non_field_errors %}      {% for error in form.non_field_errors %}      <div class="alert alert-danger py-2 small">{{ error }}</div>      {% endfor %}      {% endif %}      {% if user.is_authenticated %}      <div class="alert alert-warning py-2 small">        {% blocktranslate trimmed %}          You are authenticated as {{ username }}, but are not authorized to access this page.        {% endblocktranslate %}      </div>      {% endif %}{% block main %}<div class="container-fluid">  <div class="row">    <div class="col-12 col-md-8 col-lg-6 vh-100 d-flex align-items-center">      <div class="container my-5">        <div class="row">          <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">            <h1>{{ title }}</h1>            <p>You have to be invited to use this service, please contact the site              owner to get a unique username and password.</p>          </div>      <form method="POST" action="{{ app_path }}">        {% csrf_token %}        <input type="hidden" name="next" value="{{ next }}" />        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" name="username" id="id_username" placeholder="Username" value="{{ form.username.value|default:'' }}" required autofocus />          <label for="id_username">Username</label>        </div>        <div class="row">          <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">            {% if form.errors and not form.non_field_errors %}              <p class="errornote">                {% if form.errors.items|length == 1 %}                  {% translate "Please correct the error below." %}                {% else %}                  {% translate "Please correct the errors below." %}                {% endif %}              </p>            {% endif %}            {% if form.non_field_errors %}              {% for error in form.non_field_errors %}                <p class="errornote">                  {{ error }}                </p>              {% endfor %}            {% endif %}            {% if user.is_authenticated %}            <p class="errornote">            {% blocktranslate trimmed %}              You are authenticated as {{ username }}, but are not authorized to              access this page. Would you like to login to a different account?            {% endblocktranslate %}            </p>            {% endif %}          </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.password.errors %}is-invalid{% endif %}" name="password" id="id_password" placeholder="Password" required />          <label for="id_password">Password</label>        </div>        <div class="row">          <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">            <form method="POST" action="{{ app_path }}">              {% csrf_token %}              <input type="hidden" name="next" value="{{ next }}" />              <div class="form-floating mb-3">                <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" name="username" id="id_username" placeholder="Username" value="{{ form.username.value|default:'' }}" required autofocus />                <label for="id_username" class="form-label">Username</label>              </div>              <div class="form-floating mb-3">                <input type="password" class="form-control {% if form.password.errors %}is-invalid{% endif %}" name="password" id="id_password" placeholder="Password" required />                <label for="id_password" class="form-label">Password</label>              </div>              <div class="row">                <div class="col">                  <button type="submit" class="btn  btn-primary">                    Login                  </button>                  <a href="{% url 'password_reset' %}" class="btn btn-link float-end">                    Forgot password?                  </a>                </div>              </div>            </form>          </div>        <div class="d-flex justify-content-between align-items-center mt-4">          <button type="submit" class="btn btn-primary">Login โ†’</button>          <a href="{% url 'password_reset' %}" class="btn btn-link small">Forgot password?</a>        </div>      </form>    </div>  </div>  <div class="auth-visual-col">    <div class="grid-plane" style="position:absolute; inset:0; height:100%;">      <div class="grid-plane-overlay"></div>      <div class="grid-plane-grid"></div>    </div>    <div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; padding: 2rem; z-index: 3;">      <div class="terminal-block" style="max-width: 380px; width: 100%;">        <span class="t-line"><span class="t-comment"># status ยท monitor</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">whoami</span></span>        <span class="t-line"><span class="t-val">anonymous</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">login --mode=interactive</span></span>        <span class="t-line"><span class="t-key">awaiting</span>=<span class="t-val">credentials</span> <span class="t-cursor"></span></span>      </div>    </div>    <div class="col-md-4 col-lg-6 d-none d-md-block vh-100 login-hero" style="--background-image: url({% static 'images/home-hero.webp' %});"></div>  </div></div>{% endblock %}{% block footer %}{% endblock %}
modified status/templates/registration/password_change_done.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'profile' %}">Profile</a></li>
@@ -15,10 +15,12 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>Your password was changed.</p>      <p><a href="{% url 'profile' %}">Back to profile</a></p>    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">rotation complete</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">Your password has been updated.</p>      <a href="{% url 'profile' %}" class="btn btn-primary">โ† Back to profile</a>    </div>  </div></div>{% endblock %}
modified status/templates/registration/password_change_form.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'profile' %}">Profile</a></li>
@@ -15,50 +15,36 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>Forgotten your password? Enter your email address below, and we'll        email instructions for setting a new one.</p>    </div>  </div>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <form method="POST">        {% csrf_token %}    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">rotate credentials</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Confirm your existing password, then set a new one.</p>        {% if form.errors %}          <p>{% if form.errors.items|length == 1 %}Please correct the error            below.{% else %}Please correct the errors below.{% endif %}</p>        {% endif %}        <p>Please enter your old password, for security's sake, and then enter          your new password twice so we can verify you typed it in          correctly.</p>      {% if form.errors %}      <div class="alert alert-warning py-2 small">{% if form.errors.items|length == 1 %}Please correct the error below.{% else %}Please correct the errors below.{% endif %}</div>      {% endif %}      <form method="POST">        {% csrf_token %}        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.old_password.errors %}is-invalid{% endif %}" name="old_password" id="id_old_password" placeholder="New password" autocomplete="new-password" required autofocus />          <label for="id_old_password" class="form-label">Old password</label>          <input type="password" class="form-control {% if form.old_password.errors %}is-invalid{% endif %}" name="old_password" id="id_old_password" placeholder="Old password" autocomplete="current-password" required autofocus />          <label for="id_old_password">Old password</label>          {{ form.old_password.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}" name="new_password1" id="id_new_password1" placeholder="New password" autocomplete="new-password" required />          <label for="id_new_password1" class="form-label">New password</label>          <label for="id_new_password1">New password</label>          {% if form.new_password1.help_text %}          <div class="border py-2 pb-0 rounded rounded-1 mt-1 small">{{ form.new_password1.help_text|safe }}</div>          <div class="border border-subtle py-2 px-3 rounded mt-2 small text-muted">{{ form.new_password1.help_text|safe }}</div>          {% endif %}          {{ form.new_password1.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}" name="new_password2" id="id_new_password2" placeholder="New password" autocomplete="new-password" required />          <label for="id_new_password2" class="form-label">New password confirmation</label>          {% if form.new_password2.help_text %}          <div class="border py-2 pb-0 rounded rounded-1 mt-1 small">{{ form.new_password2.help_text|safe }}</div>          {% endif %}          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}" name="new_password2" id="id_new_password2" placeholder="Confirm new password" autocomplete="new-password" required />          <label for="id_new_password2">New password confirmation</label>          {{ form.new_password2.errors }}        </div>        <button type="submit" class="btn btn-primary">          Change my password        </button>        <button type="submit" class="btn btn-primary">Change password โ†’</button>      </form>    </div>  </div>
modified status/templates/registration/password_reset_complete.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>
@@ -15,10 +15,12 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>Your password has been set. You may go ahead and log in now.</p>      <p><a href="{{ login_url }}">Login</a></p>    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">reset complete</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">Your password has been updated. Sign in whenever you're ready.</p>      <a href="{{ login_url }}" class="btn btn-primary">Login โ†’</a>    </div>  </div></div>{% endblock %}
modified status/templates/registration/password_reset_confirm.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>
@@ -15,41 +15,33 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>    </div>  </div>  {% if validlink %}  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <p>Please enter your new password twice so we can verify you typed it in        correctly.</p>    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">set new password</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      {% if validlink %}      <p class="text-muted small mb-4">Enter it twice so we can verify.</p>      <form method="POST">        {% csrf_token %}        <input type="hidden" autocomplete="username" value="{{ form.user.get_username }}" />        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}" name="new_password1" id="id_new_password1" placeholder="New password" autocomplete="new-password" required autofocus />          <label for="id_new_password1" class="form-label">New password</label>          <label for="id_new_password1">New password</label>          {{ form.new_password1.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}" name="new_password2" id="id_new_password2" placeholder="Confirm password" autocomplete="new-password" required />          <label for="id_new_password2" class="form-label">Confirm password</label>          <label for="id_new_password2">Confirm password</label>          {{ form.new_password2.errors }}        </div>        <button type="submit" class="btn btn-primary">          Change my password        </button>        <button type="submit" class="btn btn-primary">Update password โ†’</button>      </form>      {% else %}      <div class="alert alert-warning py-2 small">        The reset link was invalid, possibly because it's already been used. <a href="{% url 'password_reset' %}">Request a new one</a>.      </div>      {% endif %}    </div>  </div>  {% else %}  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">    <p>The password reset link was invalid, possibly because it has already been      used. Please request a new password reset.</p>    </div>  </div>  {% endif %}</div>{% endblock %}
modified status/templates/registration/password_reset_done.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>
@@ -15,12 +15,11 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>We've emailed you instructions for setting your password, if an account        exists with the email you entered. You should receive them shortly.</p>      <p>If you don't receive an email, please make sure you've entered the        address you registered with, and check your spam folder.</p>    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">dispatched</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted">If an account with that email exists, a reset link is on its way. Check spam if it doesn't appear shortly.</p>      <a href="{% url 'login' %}" class="btn btn-outline-light">โ† Back to login</a>    </div>  </div></div>
modified status/templates/registration/password_reset_form.html
@@ -2,7 +2,7 @@{% block breadcrumbs %}<nav style="--bs-breadcrumb-divider: url(&#34;data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E&#34;);" aria-label="breadcrumb"><nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>
@@ -15,23 +15,18 @@{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">      <h1>{{ title }}</h1>      <p>Forgotten your password? Enter your email address below, and we'll email        instructions for setting a new one.</p>    </div>  </div>  <div class="row">    <div class="col-lg-6 offset-lg-3 col-sm-8 offset-sm-2 col-xs-12 offset-xs-0">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">recover access</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Enter your email โ€” we'll dispatch a reset link.</p>      <form method="POST">        {% csrf_token %}        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="Email" value="{{ form.email.value|default:'' }}" required autofocus />          <label for="id_email" class="form-label">Email</label>          <input type="email" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="you@domain" value="{{ form.email.value|default:'' }}" required autofocus />          <label for="id_email">Email</label>        </div>        <button type="submit" class="btn btn-primary">          Reset my password        </button>        <button type="submit" class="btn btn-primary">Send reset link โ†’</button>      </form>    </div>  </div>
modified vite.config.js
@@ -23,6 +23,7 @@ const fixDatamapsStrictMode = {export default defineConfig({  plugins: [fixDatamapsStrictMode],  base: "/static/",  build: {    outDir: resolve(__dirname, "status/static"),    emptyOutDir: true,