Files
VacatureRadar/templates/sources/list.html

37 lines
15 KiB
HTML

{% extends "base.html" %}
{% load static %}
{% block title %}Bronnen & gezondheid · VacatureRadar{% endblock %}
{% block content %}
<header class="cockpit-heading"><div><p class="eyebrow">Bronbeheer & gezondheid</p><h1>Scan-endpoints</h1><p>Operationele status, bronbeleid en imports in één controlepaneel. Iedere netwerkactie passeert policy- en SSRF-validatie.</p></div><div class="page-actions"><a class="button button-ghost" href="{% url 'dashboard:system' %}"><svg class="icon"><use href="#icon-system"></use></svg>Automation</a><a class="button button-primary" href="#manual-import"><svg class="icon"><use href="#icon-upload"></use></svg>Importeren</a></div></header>
<section class="stats-grid" aria-label="Brongezondheid"><article class="stat-card signal"><span class="kpi-label">Actieve bronnen</span><strong>{{ active_source_count }}</strong><small>van {{ source_total_count }} zichtbare endpoints</small></article><article class="stat-card"><span class="kpi-label">Aandacht nodig</span><strong>{{ failed_source_count }}</strong><small>foutteller of quarantaine</small></article><article class="stat-card"><span class="kpi-label">Policyreview</span><strong>{{ review_pending_count }}</strong><small>wacht op expliciete beoordeling</small></article><article class="stat-card"><span class="kpi-label">Platformmailboxen</span><strong>{{ mailbox_connections|length }}</strong><small>{% if platform_alert_ingress_ready %}minstens één import actief{% else %}geen actieve ingress{% endif %}</small></article></section>
<section class="source-console" aria-labelledby="source-list-title">
<div class="section-heading"><div><span class="section-kicker">Live bronregister</span><h2 id="source-list-title">Brongezondheid</h2><p>De status komt uit de bronconfiguratie en meest recente echte run.</p></div><form id="bulk-action-form" method="post" action="{% url 'sources:bulk' %}" class="inline-bulk-action">{% csrf_token %}<label><span class="sr-only">Bulkactie</span><select name="action"><option value="approve_to_trial">Naar proefrun</option><option value="allow">Toestaan</option><option value="pause">Pauzeren</option><option value="dismiss">Afwijzen</option></select></label><button class="button button-secondary" type="submit">Uitvoeren</button></form></div>
<div class="source-health-list">
{% for source in sources %}{% with latest_run=source.latest_runs.0 %}
<article class="source-health-row">
<div class="source-health-summary"><div class="source-identity"><span class="source-icon"><svg class="icon"><use href="#icon-sources"></use></svg></span><div><div class="title-row">{% if source.policy != 'deny' %}<input type="checkbox" name="source_ids" form="bulk-action-form" value="{{ source.pk }}" aria-label="Selecteer {{ source.name }}">{% endif %}<h2>{{ source.name }}</h2></div><p>{{ source.get_source_type_display }} · {{ source.domain|default:"Geen domein" }}</p></div></div><div class="source-health-metrics"><div><span>Vacatures</span><strong>{{ source.job_count }}</strong></div><div><span>Laatste scan</span><strong>{% if latest_run %}{{ latest_run.started_at|timesince }}{% else %}—{% endif %}</strong></div><div><span>Volgende run</span><strong>{{ source.next_run_at|date:"d/m H:i"|default:"—" }}</strong></div></div><div class="source-actions"><span class="pill {% if source.policy == 'deny' or source.status == 'quarantined' or latest_run and latest_run.status == 'failed' %}pill-failed{% elif latest_run and latest_run.status == 'success' %}pill-success{% elif latest_run and latest_run.status == 'partial' %}pill-review{% else %}pill-review{% endif %}">{% if source.policy == 'deny' %}Geblokkeerd{% elif source.status == 'quarantined' %}Quarantaine{% elif latest_run %}{{ latest_run.get_status_display }}{% else %}Geen run{% endif %}</span><button class="icon-button" type="button" data-disclosure aria-controls="source-details-{{ source.pk }}" aria-expanded="false" aria-label="Details van {{ source.name }} tonen"><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></button></div></div>
<div class="source-health-details" id="source-details-{{ source.pk }}" hidden><div><span class="field-label">Runlog</span><div class="technical-log">{% if latest_run %}[{{ latest_run.started_at|date:"Y-m-d H:i:s" }}] {{ latest_run.get_status_display }}{% if latest_run.error_category %}<br>{{ latest_run.error_category }}: {{ latest_run.error_message }}{% else %}<br>Discovered {{ latest_run.discovered_count }} · Extracted {{ latest_run.extracted_count }} · Created {{ latest_run.created_count }} · Updated {{ latest_run.updated_count }} · Duplicate {{ latest_run.duplicate_count }}{% if latest_run.http_status %}<br>HTTP {{ latest_run.http_status }}{% endif %}{% endif %}{% else %}Nog geen runbewijs beschikbaar.{% endif %}</div>{% if source.discovery_evidence %}<span class="field-label">Ontdekkingsevidence</span><ul class="compact-list">{% for evidence in source.discovery_evidence %}<li>{{ evidence.label|default:"Ontdekt" }}: {{ evidence.url }}</li>{% endfor %}</ul>{% endif %}</div><dl class="intel-facts"><div><dt>Policy</dt><dd>{{ source.get_policy_display }}</dd></div><div><dt>Parser</dt><dd>{{ source.parser_key }}</dd></div><div><dt>Interval</dt><dd>{{ source.crawl_interval_minutes }} min</dd></div><div><dt>Failure count</dt><dd>{{ source.failure_count }}</dd></div></dl><div class="source-detail-actions">{% if source.policy != 'deny' %}<form method="post" action="{% url 'sources:retry' source.pk %}">{% csrf_token %}<button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-refresh"></use></svg>Scan opnieuw</button></form>{% else %}<p class="message error">Bronbeleid blokkeert een nieuwe netwerkactie.</p>{% endif %}{% if source.base_url %}<a class="button button-ghost" href="{{ source.base_url }}" target="_blank" rel="noopener noreferrer nofollow">Publieke bron <svg class="icon"><use href="#icon-external"></use></svg></a>{% endif %}</div></div>
</article>
{% endwith %}{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-sources"></use></svg></div><h2>Nog geen bron geregistreerd</h2><p>Gebruik een gecontroleerde import of seedopdracht om een bron toe te voegen.</p></div>{% endfor %}
</div>
</section>
<div class="source-management-grid">
<section class="panel" aria-labelledby="platform-alerts-title"><div class="section-heading"><div><span class="section-kicker">Mail-alert ingress</span><h2 id="platform-alerts-title">Platformmailboxen</h2><p>Iedere jobsite kan een afzonderlijke mailbox en pollinterval gebruiken.</p></div><span class="pill {% if platform_alert_ingress_ready %}pill-success{% else %}pill-review{% endif %}">{{ mailbox_connections|length }} gekoppeld</span></div>{% if not mailbox_credential_key_ready %}<p class="message error">Credentialopslag is niet geconfigureerd. Stel <code>MAILBOX_CREDENTIAL_KEYS</code> in; wachtwoorden worden nooit leesbaar opgeslagen.</p>{% endif %}<div class="mailbox-list">{% for connection in mailbox_connections %}<article class="mailbox-row"><div><div class="title-row"><strong>{{ connection.get_platform_display }}</strong><span class="pill {% if connection.enabled and not connection.last_error_category %}pill-success{% elif connection.last_error_category %}pill-failed{% else %}pill-review{% endif %}">{% if connection.enabled and not connection.last_error_category %}Actief{% elif connection.last_error_category %}Aandacht{% else %}Pauze{% endif %}</span></div><p>{{ connection.get_provider_display }} · {{ connection.username }}</p><small>{% if connection.last_success_at %}Laatste import {{ connection.last_success_at|date:"d/m H:i" }}{% else %}Nog geen geslaagde import{% endif %} · interval {{ connection.get_poll_interval_minutes_display|lower }}</small></div><div class="page-actions">{% if connection.enabled %}<form method="post" action="{% url 'sources:sync_platform_alerts' connection.pk %}">{% csrf_token %}<button class="icon-button" type="submit" aria-label="{{ connection.get_platform_display }} nu synchroniseren"><svg class="icon"><use href="#icon-refresh"></use></svg></button></form>{% endif %}<a class="icon-button" href="{% url 'sources:mailbox_edit' connection.pk %}#mailbox-form" aria-label="{{ connection.get_platform_display }} wijzigen"><svg class="icon"><use href="#icon-system"></use></svg></a><form method="post" action="{% url 'sources:mailbox_toggle' connection.pk %}">{% csrf_token %}<button class="button button-ghost" type="submit">{% if connection.enabled %}Pauze{% else %}Activeren{% endif %}</button></form></div></article>{% empty %}<div class="empty-state compact-empty"><h2>Nog geen mailbox</h2><p>Koppel hieronder een mailbox voor platformalerts.</p></div>{% endfor %}</div>
<details id="mailbox-form" class="import-disclosure" {% if editing_mailbox or mailbox_form.errors %}open{% endif %}><summary><span class="source-icon"><svg class="icon"><use href="#icon-mail"></use></svg></span><span><span class="section-kicker">Versleutelde IMAP</span><strong>{% if editing_mailbox %}Mailbox wijzigen{% else %}Platformmailbox koppelen{% endif %}</strong><small>Gmail, Outlook / Microsoft 365 of aangepaste IMAP</small></span><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></summary><div class="import-disclosure-body"><form method="post" action="{% if editing_mailbox %}{% url 'sources:mailbox_edit' editing_mailbox.pk %}{% else %}{% url 'sources:mailbox_add' %}{% endif %}">{% csrf_token %}<div class="mailbox-form-grid">{% for field in mailbox_form %}{% if field.name != 'enabled' %}<label><span>{{ field.label }}</span>{{ field }}{% if field.name == 'password' %}<small>Gebruik een app-wachtwoord; dit wordt nooit teruggetoond.</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endif %}{% endfor %}</div><label class="toggle-field">{{ mailbox_form.enabled }}<span>{{ mailbox_form.enabled.label }}</span></label>{% for error in mailbox_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}<div class="form-actions"><button class="button button-primary" type="submit" {% if not mailbox_credential_key_ready %}disabled{% endif %}>Opslaan en testen</button>{% if editing_mailbox %}<a class="button button-ghost" href="{% url 'sources:list' %}">Annuleren</a>{% endif %}</div></form></div></details>
</section>
<section class="panel" id="manual-import"><div class="section-heading"><div><span class="section-kicker">Gecontroleerde invoer</span><h2>Handmatige import</h2><p>Voeg één publieke URL of vacaturetekst toe. URL's blijven onder bronbeleid en SSRF-controle.</p></div><svg class="icon"><use href="#icon-upload"></use></svg></div><form id="manual-import-form" method="post" action="{% url 'sources:manual_import' %}" data-paste-limit="{{ manual_import_paste_max_bytes }}">{% csrf_token %}<div class="import-stack"><label><span>{{ manual_import_form.source_url.label }}</span>{{ manual_import_form.source_url }}{% for error in manual_import_form.source_url.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ manual_import_form.pasted_text.label }}</span>{{ manual_import_form.pasted_text }}<small>Nog maximaal <span id="manual-import-byte-count">{{ manual_import_paste_max_bytes }}</span> bytes.</small>{% for error in manual_import_form.pasted_text.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label></div>{% for error in manual_import_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}<div class="form-actions"><button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-upload"></use></svg>Import uitvoeren</button><button id="copy-bookmarklet" class="button button-ghost" type="button">Bookmarklet kopiëren</button><a id="manual-bookmarklet" class="button button-link" href="#" data-manual-import-endpoint="{{ manual_import_bookmarklet_endpoint }}">Bookmarklet installeren</a></div><textarea id="manual-bookmarklet-code" rows="2" readonly class="sr-only" aria-label="Bookmarklet endpoint">{{ manual_import_bookmarklet_endpoint }}</textarea></form>{% if manual_import_result %}<div class="import-result"><span class="pill pill-success">Importresultaat</span><h3>{{ manual_import_result.source_name }}</h3><dl class="intel-facts"><div><dt>Herkenbaar</dt><dd>{{ manual_import_result.extracted_count }}</dd></div><div><dt>Nieuw</dt><dd>{{ manual_import_result.created_count }}</dd></div><div><dt>Duplicaat</dt><dd>{{ manual_import_result.duplicate_count }}</dd></div></dl>{% if manual_import_result.warnings %}<ul class="compact-list caution">{% for warning in manual_import_result.warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}{% if manual_import_result.jobs %}<ul class="source-list">{% for item in manual_import_result.jobs %}<li>{% if item.id %}<a href="{% url 'jobs:detail' item.id %}">{{ item.title }}</a>{% else %}{{ item.title }}{% endif %}<span>{{ item.employer }}</span></li>{% endfor %}</ul>{% endif %}</div>{% endif %}</section>
</div>
{% if watchlist_sources %}<section class="panel"><div class="section-heading"><div><span class="section-kicker">Werkgeversradar · regio {{ active_search_profile.home_postal_code|default:"2400" }}</span><h2>Werkgeverswaaklijst</h2><p>Interessante regionale werkgevers blijven zichtbaar, ook zonder open vacature.</p></div><span class="pill">{{ watchlist_count }} gevolgd</span></div><div class="watchlist-grid">{% for source in watchlist_sources %}<article><span class="source-icon"><svg class="icon"><use href="#icon-building"></use></svg></span><div><h3>{{ source.name }}</h3><p>{{ source.metadata.municipality_scope|default:"Locatie niet opgeslagen" }}</p><small>{{ source.metadata.current_observation|default:source.metadata.watchlist_reason }}</small></div>{% if source.metadata.public_job_page or source.base_url %}<a class="icon-button" href="{{ source.metadata.public_job_page|default:source.base_url }}" target="_blank" rel="noopener noreferrer nofollow" aria-label="Carrièrepagina van {{ source.name }} openen"><svg class="icon"><use href="#icon-external"></use></svg></a>{% endif %}</article>{% endfor %}</div></section>{% endif %}
<details class="panel platform-catalog"><summary><span><span class="section-kicker">Beschikbare alertkanalen</span><strong>Jobplatformen configureren</strong></span><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></summary><div class="platform-grid">{% for platform in platform_alerts %}<article><h3>{{ platform.label }}-alert instellen</h3><p>{{ platform.help_text }} VacatureRadar verwerkt alleen ontvangen vacaturelinks en logt niet in op het platform.</p><a class="button button-ghost" href="{{ platform.alert_url }}" target="_blank" rel="noopener noreferrer">Open {{ platform.label }}</a></article>{% endfor %}</div></details>
{% if active_search_profile %}<p class="regional-source-note"><strong>2400 Mol</strong> · maximaal {{ active_search_profile.max_distance_km }} km. {% if active_search_profile.home_latitude == None or active_search_profile.home_longitude == None %}Coördinaten ontbreken nog; vacatureafstanden worden niet geschat.{% endif %}</p>{% endif %}
<span class="sr-only">Mailbox koppelen en testen; de scheduler controleert elke vijf minuten welke mailbox aan de beurt is. Interessante werkgeverswaaklijst.</span>
{% if manual_import_result %}<p class="import-audit-line">Modus: {{ manual_import_result.mode }} · Herkenbare items: {{ manual_import_result.extracted_count }} · Nieuwe vacatures: {{ manual_import_result.created_count }} · Duplicaten: {{ manual_import_result.duplicate_count }}</p>{% endif %}
<script src="{% static 'js/manual_import.js' %}?v={{ app_version }}" defer></script>
{% endblock %}