{% extends "base.html" %} {% block title %}Job onboarding — BackupWatch{% endblock %} {% block main_class %}onb{% endblock %} {% block content %} {# how many rows of a group/section are on screen before the footer offers the rest #} {% set VISIBLE_CLUSTER_ROWS = 3 %} {% set VISIBLE_READY_ROWS = 8 %} {% set kind_label = 'Sweep' if data.ai_sweep else 'Discovery' if data.discovery else 'Inventory' %}
← Onboarding workspace / Batch {{ batch.id[:8] }}

Job onboarding

{{ kind_label }} saved {{ batch.created_at|dt }} · {{ batch.created_by }}
Download rows needing correction {% if data.ai_sweep %}
{% endif %}
{% if msg %}{% endif %} {% if data.capped %}{% endif %} {% if summary.observation %}
OBSERVATION {{ summary.observation }} job{{ '' if summary.observation == 1 else 's' }} {{ 'is' if summary.observation == 1 else 'are' }} awaiting onboarding completion. Observation records reports without raising operational alerts. Show the job{{ '' if summary.observation == 1 else 's' }}
{% endif %} {# ---- 3. sweep overview ---------------------------------------------------- #}

What the sweep found

{% if data.ai_sweep %}Read {{ '{:,}'.format(data.message_count or 0) }} waiting Review-queue report{{ '' if data.message_count == 1 else 's' }} across {{ data.client_count or 0 }} client{{ '' if data.client_count == 1 else 's' }} and proposed {{ summary.proposed }} job{{ '' if summary.proposed == 1 else 's' }}.{% elif data.discovery %}Grouped {{ '{:,}'.format(data.message_count or 0) }} report{{ '' if data.message_count == 1 else 's' }} from {{ data.source }} into {{ summary.proposed }} proposed job{{ '' if summary.proposed == 1 else 's' }}.{% else %}Read {{ summary.proposed }} inventory row{{ '' if summary.proposed == 1 else 's' }} and proposed {{ summary.proposed }} job{{ '' if summary.proposed == 1 else 's' }}.{% endif %} Nothing is written until you save.

{% if data.coverage %}
Inventory reconciliation {{ data.coverage.expected }} expected {{ data.coverage.configured }} configured {{ data.coverage.reporting }} have reported {{ data.coverage.missing }} missing {{ data.coverage.never_seen }} never seen {% if data.unexpected %}
{{ data.unexpected|length }} additional configured job{{ '' if data.unexpected|length == 1 else 's' }} for these clients
These jobs are outside this batch's expected inventory. Review them before deciding whether to retire anything. {% for j in data.unexpected %}
{{ j.client }} / {{ j.name }}
{% endfor %}
{% endif %}
{% endif %}
{% if data.ai_candidates or data.ai_summary %}

Refine with Claude AI

{% if data.ai_candidates %}

One request per client for {{ data.ai_candidates|length }} client{{ '' if data.ai_candidates|length == 1 else 's' }} — subject lines and sender addresses only, using your Anthropic API key and the shared hourly AI request budget. If the sweep hits the hourly limit partway through, the remaining clients are reported below the next run.

Send to Anthropic?

Subject lines and sender addresses for {{ data.ai_candidates|length }} client(s) will be sent to api.anthropic.com, one request per client, using your API key.

No message bodies, recipients or attachments are included. Each request also carries BackupWatch's own instructions and your model settings.

Show exactly what will be sent, per client {% for c in data.ai_candidates %}

{{ c.client_name }} — {{ c.sent }}{% if c.total > c.sent %} of {{ c.total }}{% endif %} email(s)

{{ c.preview_json }}
{% endfor %}
{% endif %} {% if data.ai_summary %}

Last AI refinement run: {{ data.ai_summary.refined|length }} client(s) refined (~${{ '%.2f'|format(data.ai_summary.cost) }}). {% if data.ai_summary.skipped_cap %} Hourly AI limit reached. Not sent: {{ data.ai_summary.skipped_cap|join(', ') }}. Re-run the sweep later.{% endif %} {% if data.ai_summary.skipped_error %} Couldn't refine: {{ data.ai_summary.skipped_error|join(', ') }}.{% endif %}

{% endif %}
{% endif %} {# ---- 4. sticky save bar --------------------------------------------------- #}
0 row(s) selected · {{ summary.ready }} valid in this batch Saving creates drafts. Existing jobs need explicit confirmation; blank values keep current settings. {% if updates %} {% endif %}
{% macro row_detail(p, mails) %} {% endmacro %} {# ---- 5. Need a client ----------------------------------------------------- #}

Need a client

{{ summary.needs }} Grouped by the hostname the reports came from, or by sender when the reports name no machine. Assign the client once for a group, then check the job references.
{% for c in clusters %}
{{ c.label }} {{ c.row_count }} row{{ '' if c.row_count == 1 else 's' }} · {{ c.report_count }} report{{ '' if c.report_count == 1 else 's' }}{% if c.sample_host %} · {{ c.sample_host }}{% endif %}
{% if c.evidence %}{{ c.evidence }}{% elif c.kind == 'leftover' %}Mixed senders — these rows may belong to different clients. Check each row's Detail before assigning the whole group.{% else %}No client suggestion — pick one or correct the file{% endif %}
{% for p in c.rows %}
VISIBLE_CLUSTER_ROWS %}hidden data-collapsed="1"{% endif %}> {{ p.number }} {{ p.input.job_name }}{% if p.hostname and p.hostname not in p.input.job_name|upper %} · {{ p.hostname }}{% endif %}{% if p.ai %} AI{% endif %} {{ p.input.client_name or 'Client needed' }} {{ 'CHECK ROW' if p.blocked else 'NEEDS CLIENT' }} {# a client drafted in the New client dialog: still just a description until Save #}
{{ row_detail(p, p.samples or []) }} {% endfor %}
{{ c.row_count - VISIBLE_CLUSTER_ROWS }} more row{{ '' if c.row_count - VISIBLE_CLUSTER_ROWS == 1 else 's' }} in this group {% if c.kind == 'sender' %}come from the same sender domain{% elif c.kind == 'leftover' %}have no hostname and no identifying sender{% else %}follow the same hostname pattern{% endif %} {% if c.row_count > VISIBLE_CLUSTER_ROWS %}{% endif %} Download this group as CSV
{% endfor %}
{# ---- 6. Ready to create --------------------------------------------------- #}

Ready to create

{{ summary.ready }} Client matched from report content. Saving creates each as a draft job in observation.
RowClientJob ReferenceStatusAction
{% for p in ready %}
VISIBLE_READY_ROWS %}hidden data-collapsed="1"{% endif %}> {# a superseded row starts unticked: an AI row now covers the same reports, and saving both would create two jobs for one backup. Select all leaves it alone too; ticking it by hand still works. #} {{ p.number }} {{ p.client_name }} {% set job_title = p['values'].name if p['values'] else p.input.job_name %} {{ job_title }}{% if p.ai %} AI{% endif %} {{ p.input.job_ref }} {% if p.superseded_by %} REPLACED BY #{{ p.superseded_by|join(', #') }} {% else %} {{ 'UPDATE' if p.action == 'update' else 'CREATE' if p.action == 'create' else 'NO CHANGE' }} {% endif %}
{{ row_detail(p, p.samples or []) }} {% endfor %}
{{ ready|length - VISIBLE_READY_ROWS }} further ready row{{ '' if ready|length - VISIBLE_READY_ROWS == 1 else 's' }} are collapsed · saving applies to every selected row, not just the visible ones {% if ready|length > VISIBLE_READY_ROWS %}Show all ready rows{% endif %}
{# ---- 8. In observation ---------------------------------------------------- #}

In observation

{{ observation|length }} Created earlier in this batch. Reports are recorded, alerts stay off until onboarding is completed.
{% for p in observation %}{% set job = jobs[p.saved_job_id] %}
VISIBLE_READY_ROWS %} hidden data-collapsed="1"{% endif %}> {{ p.number }} {{ job.client.display_name }} {{ job.name }}{% if job.external_ref %} · {{ job.external_ref }}{% endif %} {{ job.onboarding_state|upper }}{% if job.monitoring_started_at %} SINCE {{ job.monitoring_started_at|date|upper }}{% elif job.created_at %} SINCE {{ job.created_at|date|upper }}{% endif %} {% if job.onboarding_state in ('draft', 'observing') %}
{% endif %} Open job
{% endfor %} {% if observation|length > VISIBLE_READY_ROWS %}
{{ observation|length - VISIBLE_READY_ROWS }} further observed job(s) are collapsed Show all observed jobs
{% endif %}
{% if not clusters and not ready and not observation %}
No candidate jobs in this batch. Try another date range, run the sweep again, or upload an inventory.
{% endif %}

Imports never delete jobs missing from the file. Changed matching rules return active jobs to observation until they report again.

New client

For . Nothing is created until you save the batch — the client is written once, with the group's jobs, when you do.

{# ---- ignore these reports: the Review page's "Always ignore" rule, opened from a row ---- #}

Ignore these reports

AND — optional second condition

This writes straight away, unlike the rest of this page: every waiting email the rule matches is ignored now, future ones never reach Review, and any row here whose reports it covers is removed. Undo it under Review › Ignore rules. Keep the value specific — a too-broad rule swallows real backup mail.

{% endblock %}