worker-service 0.2.0

Worker Service - A worker administration microservice that interoperates with the worker-matcher crate
{% extends "layout.html.tera" %}

{% block title %}Deduplicate — {{ entity_plural | capitalize }} — {{ app_display }}{% endblock title %}

{% block content %}
<nav class="breadcrumb-nav" aria-label="Breadcrumb">
  <ol class="breadcrumb-list" aria-label="Breadcrumb trail">
    <li class="breadcrumb-list-item"><a href="/">Home</a></li>
    <li class="breadcrumb-list-item"><a href="/{{ entity_plural }}">{{ entity_plural | capitalize }}</a></li>
    <li class="breadcrumb-list-item" aria-current="page">Deduplicate</li>
  </ol>
</nav>

<section aria-label="Batch deduplication scan"
         x-data='{
           threshold: 0.70,
           autoMergeThreshold: 0.95,
           maxCandidates: 50,
           dryRun: true,
           pin: ["", "", "", "", "", ""],
           step: "config",
           progressPercent: 0,
           result: null,
           pinJoined() { return this.pin.join(""); },
           pinValid() { return this.pinJoined() === "1357"; },
           pinInput(idx) {
             var box = document.querySelector(".pin-input-div");
             return box ? box.querySelectorAll("input")[idx] : null;
           },
           onPinKey(idx, evt) {
             if (evt.key === "Backspace" &amp;&amp; !this.pin[idx] &amp;&amp; idx > 0) {
               var prev = this.pinInput(idx - 1);
               if (prev) prev.focus();
             } else if (/^[0-9]$/.test(evt.key) &amp;&amp; idx < 5) {
               var self = this;
               setTimeout(function () {
                 var next = self.pinInput(idx + 1);
                 if (next) next.focus();
               }, 0);
             }
           },
           goToVerify() {
             this.step = "verify";
             this.pin = ["", "", "", "", "", ""];
             var self = this;
             setTimeout(function () {
               var first = self.pinInput(0);
               if (first) first.focus();
             }, 100);
           },
           verify() {
             if (!this.pinValid()) {
               if (window.lily &amp;&amp; window.lily.toast) {
                 window.lily.toast("Invalid PIN — try 1357", "error");
               }
               return;
             }
             this.step = "running";
             this.progressPercent = 0;
             var self = this;
             var tick = setInterval(function () {
               self.progressPercent += 8;
               if (self.progressPercent >= 100) {
                 clearInterval(tick);
                 self.progressPercent = 100;
                 self.result = {
                   scanned: 12482,
                   duplicates: 47,
                   auto_merged: self.dryRun ? 0 : 12,
                   queued: 35,
                   ms: 28471
                 };
                 self.step = "done";
                 if (window.lily &amp;&amp; window.lily.toast) {
                   window.lily.toast("Scan complete: 47 duplicates found", "success");
                 }
               }
             }, 250);
           },
           reset() {
             this.step = "config";
             this.progressPercent = 0;
             this.result = null;
             this.pin = ["", "", "", "", "", ""];
           }
         }'>
  <h2>Batch deduplication scan</h2>

  <div class="information-callout" aria-label="Deduplicate explainer">
    <p>
      Scans every active {{ entity_singular }} and compares pairs above the
      threshold. Pairs at or above the <strong>auto-merge threshold</strong>
      merge automatically (when not in dry-run); the rest queue into the
      <a href="/{{ entity_plural }}/review-queue">review queue</a>. This is a
      heavy operation; production gates it behind a 6-digit verification PIN.
      Scaffold PIN for the demo: <code>1357</code>.
    </p>
  </div>

  <!-- Step 1: configure -->
  <section x-show="step === 'config'" aria-label="Configuration step">
    <h3>1. Configure</h3>
    <form class="form" aria-label="Scan configuration" @submit.prevent="goToVerify()">
      <div class="field" aria-label="Match threshold field">
        <label class="label" for="threshold">
          Match threshold: <strong x-text="threshold.toFixed(2)"></strong>
        </label>
        <span class="hint" id="threshold-hint">
          Minimum score (0.00–1.00) for a pair to be considered a candidate.
        </span>
        <input class="range-input"
               type="range"
               id="threshold"
               min="0.50" max="0.99" step="0.01"
               aria-label="Match threshold"
               aria-describedby="threshold-hint"
               x-model.number="threshold">
      </div>

      <div class="field" aria-label="Auto-merge threshold field">
        <label class="label" for="auto-merge">
          Auto-merge threshold: <strong x-text="autoMergeThreshold.toFixed(2)"></strong>
        </label>
        <span class="hint" id="auto-merge-hint">
          Pairs at or above this score auto-merge without review.
        </span>
        <input class="range-input"
               type="range"
               id="auto-merge"
               min="0.85" max="1.00" step="0.01"
               aria-label="Auto-merge threshold"
               aria-describedby="auto-merge-hint"
               x-model.number="autoMergeThreshold">
      </div>

      <div class="field" aria-label="Max candidates field">
        <label class="label" for="max-cands">Max candidates per record</label>
        <span class="hint" id="max-cands-hint">Cap on how many pairs we keep per record before sorting.</span>
        <input class="text-input"
               type="number"
               id="max-cands"
               min="1" max="500"
               aria-label="Max candidates per record"
               aria-describedby="max-cands-hint"
               x-model.number="maxCandidates">
      </div>

      <div class="field" aria-label="Dry-run field">
        <label class="label" for="dry-run">
          <input id="dry-run" type="checkbox" aria-label="Dry-run mode" x-model="dryRun">
          Dry-run (count only, no auto-merge)
        </label>
        <span class="hint">Recommended for the first run on production data.</span>
      </div>

      <div class="action-bar" role="toolbar" aria-label="Configuration actions">
        <button class="button" type="submit" aria-label="Continue to PIN verification">Continue</button>
        <a class="button" href="/{{ entity_plural }}" aria-label="Cancel and return to index">Cancel</a>
      </div>
    </form>
  </section>

  <!-- Step 2: PIN verification -->
  <section x-show="step === 'verify'" aria-label="PIN verification step">
    <h3>2. Verify with PIN</h3>
    <p>
      <span class="hint" aria-label="PIN hint">
        Enter the 6-digit PIN to authorise this scan. Production posts the PIN
        to <code>POST /api/auth/verify-pin</code>; this scaffold accepts
        <code>1357</code>.
      </span>
    </p>
    <form class="form" aria-label="PIN form" @submit.prevent="verify()">
      <div class="pin-input-div" aria-label="6-digit PIN entry">
        <template x-for="i in [0, 1, 2, 3, 4, 5]" :key="i">
          <input class="text-input"
                 type="text"
                 inputmode="numeric"
                 maxlength="1"
                 pattern="[0-9]"
                 x-bind:aria-label="'PIN digit ' + (i + 1) + ' of 6'"
                 style="width: 3rem; text-align: center;"
                 x-model="pin[i]"
                 @keydown="onPinKey(i, $event)">
        </template>
      </div>

      <div class="action-bar" role="toolbar" aria-label="Verify actions">
        <button class="button"
                type="submit"
                aria-label="Verify PIN and start scan"
                x-bind:disabled="pinJoined().length !== 6">Verify and start scan</button>
        <button class="button"
                type="button"
                aria-label="Go back to configuration"
                @click="step = 'config'">Back</button>
      </div>
    </form>
  </section>

  <!-- Step 3: running -->
  <section x-show="step === 'running'" aria-label="Scan in progress step">
    <h3>3. Scanning&hellip;</h3>
    <p>
      <progress class="progress"
                x-bind:aria-label="'Scan progress ' + progressPercent + ' percent'"
                max="100"
                x-bind:value="progressPercent"></progress>
      <span x-text="progressPercent + '%'"></span>
    </p>
    <div class="progress-spinner"
         role="progressbar"
         aria-label="Scan in progress"
         aria-busy="true"
         x-show="progressPercent &lt; 100"></div>
    <p>
      <span class="hint" aria-label="Status">
        Pairwise compare with threshold <strong x-text="threshold.toFixed(2)"></strong>,
        auto-merge at <strong x-text="autoMergeThreshold.toFixed(2)"></strong>
        <span x-show="dryRun">&middot; <strong>dry-run</strong></span>.
      </span>
    </p>
  </section>

  <!-- Step 4: done -->
  <section x-show="step === 'done'" aria-label="Scan results step">
    <h3>4. Done</h3>
    <div class="alert" role="alert" data-type="success" aria-label="Scan succeeded">
      Scan complete. Duplicates queued for review.
    </div>
    <ol class="summary-list" aria-label="Scan results" x-show="result">
      <li class="summary-list-item">
        <dl>
          <dt>{{ entity_plural | capitalize }} scanned</dt>
          <dd>
            <span class="badge" data-type="info" aria-label="Scanned count">
              <span x-text="result &amp;&amp; result.scanned.toLocaleString()"></span>
            </span>
          </dd>
        </dl>
      </li>
      <li class="summary-list-item">
        <dl>
          <dt>Duplicates found</dt>
          <dd>
            <span class="badge" data-type="warning" aria-label="Duplicates count">
              <span x-text="result &amp;&amp; result.duplicates"></span>
            </span>
          </dd>
        </dl>
      </li>
      <li class="summary-list-item">
        <dl>
          <dt>Auto-merged</dt>
          <dd>
            <span class="badge"
                  x-bind:data-type="result &amp;&amp; result.auto_merged > 0 ? 'success' : 'info'"
                  aria-label="Auto-merged count">
              <span x-text="result &amp;&amp; result.auto_merged"></span>
            </span>
          </dd>
        </dl>
      </li>
      <li class="summary-list-item">
        <dl>
          <dt>Queued for review</dt>
          <dd>
            <span class="badge" data-type="warning" aria-label="Queued count">
              <span x-text="result &amp;&amp; result.queued"></span>
            </span>
          </dd>
        </dl>
      </li>
      <li class="summary-list-item">
        <dl>
          <dt>Duration</dt>
          <dd><span x-text="result &amp;&amp; (result.ms + ' ms')"></span></dd>
        </dl>
      </li>
    </ol>
    <div class="action-bar" role="toolbar" aria-label="Done actions">
      <a class="button"
         href="/{{ entity_plural }}/review-queue"
         aria-label="Open the review queue">Open review queue</a>
      <button class="button"
              type="button"
              aria-label="Run another scan"
              @click="reset()">Run another</button>
    </div>
  </section>
</section>
{% endblock content %}