Files
foodlinkk-command-center/cockpit/templates/browser.html
T

562 lines
25 KiB
HTML
Raw Normal View History

{% extends "base.html" %}
{% block extra_head %}
<style>.word-chip-cloud{display:flex;flex-wrap:wrap;gap:.35rem;overflow-y:auto;padding:.4rem;background:rgba(0,0,0,.2);border-radius:8px}.word-chip{font-size:.72rem;padding:.15rem .5rem;border-radius:999px;background:rgba(232,168,56,.15);color:#f3e8d0;border:1px solid rgba(232,168,56,.3)}.word-chip.low{opacity:.7;font-size:.65rem}</style>
<style>
.browser-tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.browser-tab {
padding: 0.5rem 1rem; border-radius: 8px; cursor: pointer; font-size: 0.9rem;
background: rgba(148,163,184,.08); border: 1px solid rgba(148,163,184,.15); color: var(--text-secondary);
}
.browser-tab.active { background: rgba(212,175,55,.15); border-color: var(--accent-gold); color: var(--accent-gold); }
.browser-layout { display: grid; grid-template-columns: 1fr 280px; gap: 1rem; }
@media (max-width: 1100px) { .browser-layout { grid-template-columns: 1fr; } }
.browser-live-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;
}
@media (max-width: 900px) { .browser-live-row { grid-template-columns: 1fr; } }
.browser-live-panel { min-height: 380px; }
.browser-live-frame {
width: 100%; min-height: 420px; background: #0a0f1a; border-radius: 10px;
border: 1px solid rgba(148,163,184,.15); object-fit: contain;
}
.browser-novnc { width: 100%; height: 360px; border: 0; border-radius: 10px; background: #000; }
.browser-preview-text {
max-height: 220px; overflow: auto; font-size: 0.82rem; color: var(--text-muted);
white-space: pre-wrap; background: rgba(0,0,0,.25); padding: .75rem; border-radius: 8px;
}
.browser-links { max-height: 160px; overflow: auto; font-size: 0.78rem; }
.browser-status { font-size: 0.8rem; color: var(--accent-cyan); margin-top: 0.35rem; }
.novnc-hint { font-size: 0.78rem; color: var(--text-muted); margin-top: 0.5rem; }
.preset-grid { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.5rem 0; }
.preset-chip { font-size: 0.78rem; padding: 0.25rem 0.55rem; border-radius: 999px; cursor: pointer; border: 1px solid rgba(148,163,184,.2); background: rgba(0,0,0,.2); }
.preset-chip:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
.job-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
padding: 0.6rem 0.85rem; margin-bottom: 1rem; border-radius: 10px;
background: rgba(0,0,0,.25); border: 1px solid rgba(148,163,184,.12);
}
.job-bar.active { border-color: rgba(232,168,56,.35); }
</style>
{% endblock %}
{% block content %}
<div x-data="browserPage()" x-init="init()">
<header class="page-header">
<h1>Browser &amp; Monitor</h1>
<p class="subtitle page-tip">Voer een URL in — Playwright + live VNC desktop openen dezelfde pagina. Gebruik <strong>Volledige extractie</strong> voor gewicht, prijs en alle zichtbare data.</p>
</header>
<div class="job-bar" :class="{ active: jobRunning }">
<strong style="font-size:0.85rem">Achtergrondtaak</strong>
<span class="browser-status" style="margin:0" x-text="jobLabel || 'Geen actieve taak — je kunt vrij navigeren'"></span>
<button type="button" class="btn btn-sm btn-reject" x-show="jobRunning" @click="stopJob()">⏹ Stop</button>
<label style="font-size:0.78rem;color:var(--text-muted);margin-left:auto">
<input type="checkbox" x-model="vncSync" @change="startPoll()" /> Screenshot = VNC scherm
</label>
<label style="font-size:0.78rem;color:var(--text-muted)">
<input type="checkbox" x-model="livePoll" @change="startPoll()" /> Live poll
</label>
</div>
<section class="panel">
<h2>Snelle sites</h2>
<p class="page-tip">Klik om URL te laden. TikTok/Instagram vereisen soms inloggen via VNC.</p>
<div class="preset-grid">
<span class="preset-chip" @click="loadPreset('https://www.bidfood.nl/webshop/assortiment/mekkafood/_/N-1z10pje/')">Bidfood Mekkafood</span>
<span class="preset-chip" @click="loadPreset('https://www.sligro.nl/')">Sligro</span>
<span class="preset-chip" @click="loadPreset('https://www.tiktok.com/')">TikTok</span>
<span class="preset-chip" @click="loadPreset('https://www.instagram.com/')">Instagram</span>
<span class="preset-chip" @click="loadPreset('https://www.cheemafood.nl/')">Cheema</span>
</div>
<div class="form-grid" style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.5rem">
<input class="form-input" style="flex:2;min-width:200px" placeholder="Nieuwe site URL" x-model="newSiteUrl" />
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="newSiteName" />
<button type="button" class="btn btn-secondary" @click="addSiteQuick()">+ Toevoegen aan monitor</button>
</div>
</section>
<div class="browser-tabs">
<button type="button" class="browser-tab" :class="{ active: tab === 'research' }" @click="setTab('research')">Research</button>
<button type="button" class="browser-tab" :class="{ active: tab === 'monitor' }" @click="setTab('monitor')">Site Monitor <span class="muted">({{ stats.active_sites }})</span></button>
</div>
<div class="browser-live-row" style="margin-bottom:1rem">
<section class="panel browser-live-panel">
<h2>Screenshot <small class="muted" x-text="vncSync ? 'VNC sync' : 'Playwright'"></small></h2>
<img class="browser-live-frame" :src="screenshotUrl" alt="Browser screenshot" />
</section>
<section class="panel browser-live-panel">
<h2>Live desktop <small class="muted">noVNC</small>
<a href="{{ novnc_url }}" target="_blank" style="font-size:0.75rem;margin-left:0.5rem">Open apart ↗</a>
</h2>
<iframe class="browser-novnc" src="{{ novnc_url }}" title="Live VNC" data-clear-on-nav></iframe>
<p class="novnc-hint">Wachtwoord: <code>Foodlinkk2026</code>. Tip: open VNC ook <a href="{{ novnc_url }}" target="_blank">in apart venster</a> — dan geen navigatie-waarschuwing bij wisselen van pagina.</p>
</section>
</div>
<div x-show="tab === 'research'">
<section class="panel browser-instruct-box">
<h2>Interactief — leer Herman wat te klikken</h2>
<p class="page-tip">Beschrijf in het Nederlands wat de browser moet doen. Voorbeeld: <em>Accepteren cookies, scroll naar beneden, klik op Mekkafood filter</em></p>
<textarea class="form-input" x-model="instruction" rows="3" placeholder="Accepteren cookies → scroll 2× → klik op 'Snacks'"></textarea>
<div class="browser-action-bar">
<button type="button" class="btn btn-primary" @click="instructBrowse()" :disabled="loading">Uitvoeren + screenshot</button>
<button type="button" class="btn btn-primary" @click="extractFull()" :disabled="loading">🔬 Volledige extractie</button>
<button type="button" class="btn btn-secondary" @click="vncNavigate()" :disabled="loading">🖥 Open in VNC</button>
<button type="button" class="btn btn-secondary" @click="exportPhoto()" :disabled="!session">📥 Foto export</button>
<button type="button" class="btn btn-secondary" @click="runOcr()" :disabled="!session">🔍 OCR tekst</button>
<button type="button" class="btn btn-secondary" @click="savePhotoToDb()" :disabled="!session">💾 Foto → DB</button>
<a class="btn btn-secondary" href="{{ gradio_url }}" target="_blank" rel="noopener">🤖 Browser Use AI</a>
</div>
<div class="browser-steps-log" x-show="stepsLog.length" x-text="stepsLog.join(' · ')"></div>
<div x-show="ocrChips.length" style="margin-top:0.5rem">
<small class="muted">OCR woorden (compact)</small>
<div class="word-chip-cloud" style="max-height:72px;margin-top:0.35rem">
<template x-for="(w,i) in ocrChips" :key="i">
<span class="word-chip low" x-text="w"></span>
</template>
</div>
</div>
</section>
<section class="panel">
<form class="form-grid" @submit.prevent="browse()" style="grid-template-columns: 1fr 1fr 80px auto auto auto;">
<input class="form-input" x-model="url" placeholder="https://..." required />
<input class="form-input" x-model="task" placeholder="Taak (optioneel)" />
<input class="form-input" type="number" min="1" max="12" x-model.number="scrollPages" title="Scroll pagina's voor extractie" />
<button type="submit" class="btn btn-primary" :disabled="loading">
<span x-show="!loading">Browse + VNC</span><span x-show="loading">Bezig…</span>
</button>
<button type="button" class="btn btn-secondary" @click="addMonitor()">+ Monitor</button>
</form>
<div class="browser-status" x-text="statusText"></div>
</section>
<div class="browser-layout">
<div>
<section class="panel" x-show="extractedItems.length">
<h2>Geëxtraheerde data <small class="muted" x-text="'(' + extractedItems.length + ' items)'"></small></h2>
<table class="data-table extract-table">
<thead><tr><th>Regel / product</th><th>Gewicht</th><th>Prijs</th><th>Bron</th></tr></thead>
<tbody>
<template x-for="(it, idx) in extractedItems" :key="idx">
<tr>
<td x-text="it.raw"></td>
<td x-text="it.weight || '—'"></td>
<td x-text="it.price || '—'"></td>
<td x-text="it.source || '—'"></td>
</tr>
</template>
</tbody>
</table>
</section>
<section class="panel" x-show="session">
<h2 x-text="session ? session.title : 'Resultaat'"></h2>
<p class="page-tip"><span x-text="session ? session.final_url : ''"></span></p>
<div class="browser-preview-text" x-text="session ? (session.content_preview || session.content_text || '') : ''"></div>
<div class="browser-links" x-show="session && session.links && session.links.length">
<strong>Links op pagina:</strong>
<template x-for="lnk in (session.links || []).slice(0,15)" :key="lnk.href">
<div><a :href="lnk.href" target="_blank" x-text="lnk.text || lnk.href"></a></div>
</template>
</div>
</section>
</div>
<aside>
<section class="panel">
<h2>Recente sessies</h2>
<template x-for="s in recent" :key="s.id">
<div class="clickable-row" style="padding:.4rem 0;border-bottom:1px solid rgba(148,163,184,.1)" @click="loadSession(s.id)">
<strong x-text="s.title || s.url"></strong><br/>
<small x-text="s.created_at ? s.created_at.substring(0,16).replace('T',' ') : ''"></small>
</div>
</template>
<p class="empty-state" x-show="!recent.length">Nog geen sessies.</p>
</section>
</aside>
</div>
</div>
<div x-show="tab === 'monitor'" x-cloak>
<div class="kpi-grid">
<div class="kpi-card clickable-kpi"><div class="kpi-label">Active sites</div><div class="kpi-value">{{ stats.active_sites }}</div></div>
<div class="kpi-card amber clickable-kpi"><div class="kpi-label">Changes 24h</div><div class="kpi-value">{{ stats.changes_24h }}</div></div>
</div>
<section class="panel">
<h2>Website toevoegen</h2>
<form class="form-grid" style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center" @submit.prevent="addSite">
<input class="form-input" style="flex:2;min-width:200px" placeholder="https://..." x-model="monitorForm.url" required />
<input class="form-input" style="flex:1;min-width:120px" placeholder="Naam" x-model="monitorForm.name" />
<button class="btn btn-primary" type="submit">Toevoegen</button>
<button type="button" class="btn btn-secondary" @click="crawlAll()">Alles crawlen</button>
</form>
</section>
<section class="panel">
<h2>Gemonitorde websites</h2>
<table class="data-table"><thead><tr><th>Naam</th><th>URL</th><th>Laatst gecheckt</th><th>Acties</th></tr></thead>
<tbody>{% for s in sites %}
<tr>
<td>{{ s.name }}</td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis" title="{{ s.url }}">{{ s.url }}</td>
<td>{{ s.last_crawled or '—' }}</td>
<td class="btn-group">
<button class="btn btn-sm" @click="crawlOne({{ s.id }})">Crawl</button>
<button class="btn btn-sm btn-secondary" @click="toggleSite({{ s.id }})">{{ 'Pauze' if s.is_active else 'Activeer' }}</button>
<button class="btn btn-sm btn-reject" @click="removeSite({{ s.id }})">Verwijder</button>
</td></tr>{% else %}
<tr><td colspan="4" class="empty-state">Geen sites — voeg een URL toe of klik + Monitor bij Research.</td></tr>
{% endfor %}</tbody></table>
</section>
<div class="grid-2">
<section class="panel"><h2>Wijzigingen</h2>
<ul>{% for c in page_changes %}<li><strong>{{ c.name or c.url }}</strong> · {{ c.changed_at }}</li>{% else %}<li class="empty-state">Geen wijzigingen</li>{% endfor %}</ul>
</section>
<section class="panel"><h2>Crawl logs</h2>
<ul>{% for l in crawl_logs %}<li><span class="badge">{{ l.status }}</span> {{ l.message }} <small>{{ l.logged_at }}</small></li>{% endfor %}</ul>
</section>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function browserPage() {
return {
tab: window.location.hash === '#monitor' ? 'monitor' : 'research',
url: {{ default_url | tojson }},
instruction: 'Accepteren cookies, scroll naar beneden, toon alle producten',
scrollPages: 6,
newSiteUrl: '',
newSiteName: '',
extractedItems: [],
stepsLog: [],
ocrText: '',
ocrChips: [],
loading: false,
livePoll: true,
vncSync: true,
jobRunning: false,
jobLabel: '',
jobAbort: null,
session: null,
recent: [],
monitorForm: { url: '', name: '' },
screenshotUrl: '/api/admin/browser/live/screenshot.jpg?t=0',
statusText: 'Klaar — voer een URL in of klik Browse nu.',
pollTimer: null,
normalizeUrl(u) {
u = (u || '').trim();
if (!u) return u;
if (!/^https?:\/\//i.test(u)) u = 'https://' + u;
return u;
},
siteNameFromUrl(u) {
try { return new URL(u).hostname.replace(/^www\./, ''); } catch (e) { return u; }
},
setTab(t) {
this.tab = t;
history.replaceState(null, '', t === 'monitor' ? '/browser#monitor' : '/browser');
},
init() {
if (window.location.hash === '#monitor') this.tab = 'monitor';
window.addEventListener('hashchange', () => {
this.tab = window.location.hash === '#monitor' ? 'monitor' : 'research';
});
this.loadRecent();
this.startPoll();
},
startPoll() {
if (this.pollTimer) clearInterval(this.pollTimer);
if (!this.livePoll) return;
const base = this.vncSync
? '/api/admin/browser/vnc/screenshot.jpg?t='
: '/api/admin/browser/live/screenshot.jpg?t=';
this.pollTimer = setInterval(() => {
this.screenshotUrl = base + Date.now();
}, 1500);
this.screenshotUrl = base + Date.now();
},
startJob(label) {
if (this.jobAbort) this.jobAbort.abort();
this.jobAbort = new AbortController();
this.jobRunning = true;
this.jobLabel = label;
return this.jobAbort.signal;
},
endJob(msg) {
this.jobRunning = false;
this.jobLabel = msg || '';
this.jobAbort = null;
},
stopJob() {
if (this.jobAbort) {
this.jobAbort.abort();
Cockpit.toast('Taak geannuleerd — server kan nog kort doorlopen', 'info');
}
this.endJob('Gestopt door gebruiker');
this.loading = false;
},
async runJob(label, fn) {
const signal = this.startJob(label);
this.loading = true;
try {
return await fn(signal);
} catch (e) {
if (e.name === 'AbortError') {
this.endJob('Gestopt');
return null;
}
throw e;
} finally {
this.loading = false;
if (this.jobRunning) this.endJob('Klaar');
}
},
reloadPage() {
if (window.Cockpit && Cockpit.clearIframes) Cockpit.clearIframes();
setTimeout(() => location.reload(), 80);
},
async loadRecent() {
try {
const r = await Cockpit.api('/browser/sessions?limit=8');
this.recent = r.sessions || [];
} catch (e) {}
},
async instructBrowse() {
this.stepsLog = [];
this.statusText = 'Instructies uitvoeren…';
try {
const r = await this.runJob('Instructies uitvoeren…', (signal) =>
Cockpit.api('/browser/instruct', {
method: 'POST',
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 5 }),
signal,
})
);
if (!r) return;
this.session = r.session;
this.stepsLog = (r.session.metadata && r.session.metadata.steps) || [];
this.statusText = 'Klaar — ' + this.stepsLog.length + ' stappen';
if (r.session.id) {
this.screenshotUrl = '/api/admin/browser/sessions/' + r.session.id + '/screenshot.jpg?t=' + Date.now();
this.vncSync = false;
}
await this.loadRecent();
Cockpit.toast('Instructies uitgevoerd', 'success');
} catch (e) { this.statusText = e.message; Cockpit.toast(e.message, 'error'); }
},
exportPhoto() {
if (!this.session || !this.session.id) return;
const a = document.createElement('a');
a.href = '/api/admin/browser/sessions/' + this.session.id + '/screenshot.jpg';
a.download = 'foodlinkk-browser-' + this.session.id + '.jpg';
a.click();
},
async runOcr() {
if (!this.session || !this.session.id) return;
try {
const r = await Cockpit.api('/browser/sessions/' + this.session.id + '/ocr');
this.ocrText = r.ocr_text || '';
const words = (this.ocrText.match(/[\wÀ-ÿ]{3,}/g) || []);
const seen = new Set();
this.ocrChips = [];
words.forEach(w => { const k = w.toLowerCase(); if (!seen.has(k)) { seen.add(k); this.ocrChips.push(w); } });
if (this.ocrChips.length > 40) this.ocrChips = this.ocrChips.slice(0, 40);
Cockpit.toast('OCR klaar', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async browse() {
this.statusText = 'Browser + VNC starten op achtergrond…';
try {
const r = await this.runJob('Browse + VNC…', async (signal) => {
const vncP = Cockpit.api('/browser/vnc-navigate', {
method: 'POST',
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 4 }),
signal,
}).catch(() => null);
const browseP = Cockpit.api('/browser/browse', {
method: 'POST',
body: JSON.stringify({ url: this.url, task: this.task, instruction: this.instruction, wait_seconds: 4 }),
signal,
});
const [res] = await Promise.all([browseP, vncP]);
return res;
});
if (!r) return;
this.session = r.session;
this.statusText = 'Klaar — sessie #' + r.session.id + ' (je kunt verder navigeren)';
if (r.session.id) {
this.vncSync = true;
this.startPoll();
}
await this.loadRecent();
Cockpit.toast('Browse gestart — VNC + database', 'success');
} catch (e) {
this.statusText = 'Fout: ' + e.message;
Cockpit.toast(e.message, 'error');
}
},
async vncNavigate() {
this.statusText = 'VNC opent URL…';
try {
const r = await this.runJob('VNC navigate…', (signal) =>
Cockpit.api('/browser/vnc-navigate', {
method: 'POST',
body: JSON.stringify({ url: this.url, instruction: this.instruction, wait_seconds: 5 }),
signal,
})
);
if (!r) return;
this.statusText = 'VNC: ' + (r.title || r.final_url || this.url);
this.vncSync = true;
this.startPoll();
Cockpit.toast('URL geopend in live desktop', 'success');
} catch (e) {
this.statusText = 'VNC fout: ' + e.message;
Cockpit.toast(e.message, 'error');
}
},
async extractFull() {
this.statusText = 'Extractie op achtergrond…';
this.extractedItems = [];
try {
const r = await this.runJob('Volledige extractie…', (signal) =>
Cockpit.api('/browser/extract-full', {
method: 'POST',
body: JSON.stringify({
url: this.url, instruction: this.instruction, wait_seconds: 5,
scroll_pages: this.scrollPages, also_vnc: true,
}),
signal,
})
);
if (!r) return;
this.session = r.session;
this.extractedItems = r.session.extracted_items || (r.session.metadata && r.session.metadata.extracted_items) || [];
this.statusText = 'Extractie klaar — ' + this.extractedItems.length + ' items';
if (r.session.id) {
this.vncSync = true;
this.startPoll();
}
await this.loadRecent();
Cockpit.toast(this.extractedItems.length + ' items geëxtraheerd', 'success');
} catch (e) {
this.statusText = 'Extractie fout: ' + e.message;
Cockpit.toast(e.message, 'error');
}
},
loadPreset(u) {
this.url = u;
this.statusText = 'Preset geladen — klik Browse + VNC of Volledige extractie';
},
async addSiteQuick() {
const u = this.normalizeUrl(this.newSiteUrl || this.url);
if (!u) return Cockpit.toast('Geen URL', 'error');
try {
await Cockpit.api('/monitor/sites', {
method: 'POST',
body: JSON.stringify({ url: u, name: this.newSiteName || this.siteNameFromUrl(u) }),
});
Cockpit.toast('Site toegevoegd — tab Monitor', 'success');
this.newSiteUrl = '';
this.newSiteName = '';
this.setTab('monitor');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async savePhotoToDb() {
if (!this.session || !this.session.id) return;
try {
const img = await fetch('/api/admin/browser/sessions/' + this.session.id + '/screenshot.jpg');
const blob = await img.blob();
const b64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result.split(',')[1]);
r.onerror = rej;
r.readAsDataURL(blob);
});
const r = await Cockpit.api('/photos/analyze', {
method: 'POST',
body: JSON.stringify({
image_b64: b64,
source: 'browser-session',
filename: 'session-' + this.session.id + '.jpg',
session_id: this.session.id,
}),
});
Cockpit.toast('Foto opgeslagen in DB #' + r.photo.id, 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async loadSession(id) {
try {
const r = await Cockpit.api('/browser/sessions/' + id);
this.session = r.session;
this.url = r.session.url || this.url;
this.extractedItems = (r.session.metadata && r.session.metadata.extracted_items) || r.session.extracted_items || [];
this.screenshotUrl = '/api/admin/browser/sessions/' + id + '/screenshot.jpg?t=' + Date.now();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async addMonitor() {
const u = this.normalizeUrl(this.url);
if (!u) return Cockpit.toast('Geen URL', 'error');
try {
await Cockpit.api('/monitor/sites', {
method: 'POST',
body: JSON.stringify({ url: u, name: (this.session && this.session.title) || this.siteNameFromUrl(u) }),
});
Cockpit.toast('Toegevoegd aan Site Monitor', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async addSite() {
const payload = { ...this.monitorForm, url: this.normalizeUrl(this.monitorForm.url) };
if (!payload.url) return Cockpit.toast('URL verplicht', 'error');
if (!payload.name) payload.name = this.siteNameFromUrl(payload.url);
try {
await Cockpit.api('/monitor/sites', { method: 'POST', body: JSON.stringify(payload) });
Cockpit.toast('Site toegevoegd', 'success');
this.reloadPage();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async crawlAll() {
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: '{}' });
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => this.reloadPage(), 2000);
},
async crawlOne(id) {
await Cockpit.api('/monitor/trigger-crawl', { method: 'POST', body: JSON.stringify({ site_id: id }) });
Cockpit.toast('Crawl gestart', 'success'); setTimeout(() => this.reloadPage(), 2000);
},
async toggleSite(id) {
await Cockpit.api('/monitor/sites/' + id + '/toggle', { method: 'PATCH' });
this.reloadPage();
},
async removeSite(id) {
if (!confirm('Website permanent verwijderen uit monitoring?')) return;
await Cockpit.api('/monitor/sites/' + id + '?hard=true', { method: 'DELETE' });
Cockpit.toast('Verwijderd', 'success'); this.reloadPage();
},
};
}
</script>
{% endblock %}