Files
2026-08-19 19:27:41 +02:00

279 lines
24 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
namespace FileInterface.Scheduler
{
public sealed class WebAdminServer : IDisposable
{
private readonly HttpListener _listener = new HttpListener();
private readonly CronJobRepository _cronRepository;
private readonly CronScheduler _scheduler;
private readonly WatcherJobRepository _watcherRepository;
private readonly FileSystemWatcherManager _watcherManager;
private readonly JobLog _log;
private readonly SchedulerOptions _options;
private readonly JavaScriptSerializer _json = new JavaScriptSerializer();
private CancellationTokenSource _cts;
public WebAdminServer(
CronJobRepository cronRepository,
CronScheduler scheduler,
WatcherJobRepository watcherRepository,
FileSystemWatcherManager watcherManager,
JobLog log,
SchedulerOptions options)
{
_cronRepository = cronRepository;
_scheduler = scheduler;
_watcherRepository = watcherRepository;
_watcherManager = watcherManager;
_log = log;
_options = options;
_listener.Prefixes.Add(options.WebPrefix);
_json.MaxJsonLength = Int32.MaxValue;
}
public void Start()
{
if (_listener.IsListening) return;
_cts = new CancellationTokenSource();
_listener.Start();
Task.Run(() => AcceptLoop(_cts.Token));
}
private async Task AcceptLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
HttpListenerContext context;
try { context = await _listener.GetContextAsync(); }
catch when (token.IsCancellationRequested) { break; }
catch { continue; }
Task.Run(() => Handle(context));
}
}
private void Handle(HttpListenerContext ctx)
{
try
{
if (!IsAuthorized(ctx))
{
ctx.Response.StatusCode = 401;
ctx.Response.AddHeader("WWW-Authenticate", "Basic realm=FileInterface Admin");
ctx.Response.Close();
return;
}
string path = ctx.Request.Url.AbsolutePath.TrimEnd('/');
if (path == "") path = "/";
if (ctx.Request.HttpMethod == "GET" && path == "/") { WriteHtml(ctx, Html); return; }
if (ctx.Request.HttpMethod == "GET" && (path == "/api/cronjobs" || path == "/api/jobs"))
{ WriteJson(ctx, _cronRepository.GetAll()); return; }
if (ctx.Request.HttpMethod == "POST" && (path == "/api/cronjobs/save" || path == "/api/jobs/save"))
{
CronJobConfig job = _json.Deserialize<CronJobConfig>(ReadBody(ctx.Request));
WriteJson(ctx, _cronRepository.Save(job)); return;
}
if (ctx.Request.HttpMethod == "POST" && (path == "/api/cronjobs/delete" || path == "/api/jobs/delete"))
{ WriteJson(ctx, new { deleted = _cronRepository.Delete(ctx.Request.QueryString["id"] ?? "") }); return; }
if (ctx.Request.HttpMethod == "POST" && (path == "/api/cronjobs/run" || path == "/api/jobs/run"))
{ WriteJson(ctx, new { started = _scheduler.RunNow(ctx.Request.QueryString["id"] ?? "") }); return; }
if (ctx.Request.HttpMethod == "GET" && path == "/api/watchers")
{ WriteJson(ctx, _watcherRepository.GetAll()); return; }
if (ctx.Request.HttpMethod == "POST" && path == "/api/watchers/save")
{
WatcherJobConfig job = _json.Deserialize<WatcherJobConfig>(ReadBody(ctx.Request));
WatcherJobConfig saved = _watcherRepository.Save(job);
_watcherManager.Reload();
WriteJson(ctx, saved); return;
}
if (ctx.Request.HttpMethod == "POST" && path == "/api/watchers/delete")
{
bool deleted = _watcherRepository.Delete(ctx.Request.QueryString["id"] ?? "");
_watcherManager.Reload();
WriteJson(ctx, new { deleted = deleted }); return;
}
if (ctx.Request.HttpMethod == "POST" && path == "/api/watchers/reload")
{ _watcherManager.Reload(); WriteJson(ctx, new { reloaded = true }); return; }
if (ctx.Request.HttpMethod == "POST" && path == "/api/watchers/scan")
{ WriteJson(ctx, new { started = _watcherManager.ProcessExistingNow(ctx.Request.QueryString["id"] ?? "") }); return; }
if (ctx.Request.HttpMethod == "GET" && path == "/api/logs")
{
int limit = 300;
int parsed;
if (Int32.TryParse(ctx.Request.QueryString["limit"], out parsed) && parsed > 0) limit = parsed;
WriteJson(ctx, _log.GetLatest(limit)); return;
}
if (ctx.Request.HttpMethod == "GET" && path == "/api/status")
{
WriteJson(ctx, new
{
runningCronJobIds = _scheduler.GetRunningJobIds(),
activeWatcherIds = _watcherManager.GetActiveWatcherIds(),
serverTime = DateTime.Now
});
return;
}
ctx.Response.StatusCode = 404;
WriteJson(ctx, new { error = "Not found" });
}
catch (Exception ex)
{
try { ctx.Response.StatusCode = 500; WriteJson(ctx, new { error = ex.Message }); }
catch { try { ctx.Response.Close(); } catch { } }
}
}
private bool IsAuthorized(HttpListenerContext ctx)
{
if (String.IsNullOrWhiteSpace(_options.WebUser)) return true;
string header = ctx.Request.Headers["Authorization"];
if (String.IsNullOrWhiteSpace(header) || !header.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) return false;
try
{
string decoded = Encoding.UTF8.GetString(Convert.FromBase64String(header.Substring(6).Trim()));
int colon = decoded.IndexOf(':');
if (colon < 0) return false;
return decoded.Substring(0, colon) == _options.WebUser && decoded.Substring(colon + 1) == (_options.WebPassword ?? "");
}
catch { return false; }
}
private static string ReadBody(HttpListenerRequest request)
{
using (var reader = new StreamReader(request.InputStream, request.ContentEncoding ?? Encoding.UTF8))
return reader.ReadToEnd();
}
private void WriteJson(HttpListenerContext ctx, object value)
{
byte[] data = Encoding.UTF8.GetBytes(_json.Serialize(value));
ctx.Response.ContentType = "application/json; charset=utf-8";
ctx.Response.ContentLength64 = data.Length;
ctx.Response.OutputStream.Write(data, 0, data.Length);
ctx.Response.Close();
}
private static void WriteHtml(HttpListenerContext ctx, string html)
{
byte[] data = Encoding.UTF8.GetBytes(html);
ctx.Response.ContentType = "text/html; charset=utf-8";
ctx.Response.ContentLength64 = data.Length;
ctx.Response.OutputStream.Write(data, 0, data.Length);
ctx.Response.Close();
}
public void Dispose()
{
if (_cts != null) _cts.Cancel();
try { _listener.Stop(); } catch { }
try { _listener.Close(); } catch { }
}
private const string Html = @"<!doctype html>
<html lang='de'>
<head>
<meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>
<title>FileInterface Admin</title>
<style>
*{box-sizing:border-box}body{font-family:Segoe UI,Arial,sans-serif;margin:0;background:#f4f6f8;color:#1f2937}header{background:#111827;color:white;padding:18px 24px}main{max-width:1400px;margin:20px auto;padding:0 16px}.tabs{display:flex;gap:8px;margin-bottom:16px}.tab{background:#e5e7eb}.tab.active{background:#2563eb;color:white}.panel{display:none}.panel.active{display:block}.grid{display:grid;grid-template-columns:minmax(300px,.9fr) minmax(520px,1.5fr);gap:18px}.card{background:white;border:1px solid #dfe3e8;border-radius:10px;padding:16px;box-shadow:0 1px 3px #00000012}button{border:0;border-radius:6px;padding:8px 12px;cursor:pointer}.primary{background:#2563eb;color:white}.danger{background:#dc2626;color:white}.secondary{background:#e5e7eb}.success{background:#15803d;color:white}input,textarea,select{width:100%;padding:8px;border:1px solid #cbd5e1;border-radius:6px}input[type=checkbox]{width:auto}label{font-weight:600;font-size:13px;display:block;margin-top:10px}.row{display:grid;grid-template-columns:1fr 1fr;gap:10px}.row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}.item{border-bottom:1px solid #e5e7eb;padding:10px 0}.item:last-child{border:0}.muted{color:#64748b;font-size:12px}.arg{display:flex;gap:6px;margin:6px 0}.arg input{flex:1}.log{font-family:Consolas,monospace;font-size:12px;max-height:340px;overflow:auto;background:#0f172a;color:#e2e8f0;padding:10px;border-radius:6px;white-space:pre-wrap}.ok{color:#15803d}.off{color:#9ca3af}.badge{display:inline-block;padding:2px 7px;border-radius:12px;font-size:11px;background:#e5e7eb}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}.help{background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;padding:8px;margin-top:10px;font-size:12px}@media(max-width:950px){.grid{grid-template-columns:1fr}.row,.row3{grid-template-columns:1fr}}
</style></head>
<body><header><h2 style='margin:0'>FileInterface HTTP Administration</h2><div style='color:#cbd5e1;font-size:12px'>FileSystemWatcher + CronScheduler · iTAC IMS CustomFunctions</div></header>
<main>
<div class='tabs'><button id='tabWatcher' class='tab active' onclick=""showTab('watcher')"">FileSystemWatcher</button><button id='tabCron' class='tab' onclick=""showTab('cron')"">CronScheduler</button></div>
<section id='watcherPanel' class='panel active'><div class='grid'>
<div class='card'><div style='display:flex;justify-content:space-between;align-items:center'><h3>Watcher</h3><button class='primary' onclick='newWatcher()'>+ Neuer Watcher</button></div><div id='watcherList'></div></div>
<div class='card'><h3 id='watcherTitle'>Watcher bearbeiten</h3><input id='wid' type='hidden'>
<label>Name</label><input id='wname'>
<label><input id='wenabled' type='checkbox'> Aktiv</label>
<div class='row'><div><label>Quellverzeichnis</label><input id='wsource' placeholder='C:\Import'></div><div><label>Dateifilter</label><input id='wpattern' placeholder='*.xml'></div></div>
<label><input id='wrecursive' type='checkbox'> Unterverzeichnisse einbeziehen</label>
<div class='row3'><label><input id='wcreated' type='checkbox'> Created</label><label><input id='wchanged' type='checkbox'> Changed</label><label><input id='wrenamed' type='checkbox'> Renamed</label></div>
<div class='row3'><div><label>Debounce (ms)</label><input id='wdebounce' type='number' min='0'></div><div><label>File-Ready Versuche</label><input id='wretries' type='number' min='1'></div><div><label>Abstand (ms)</label><input id='wretrydelay' type='number' min='10'></div></div>
<div class='row'><div><label><input id='wremoveempty' type='checkbox'> Leere Zeilen entfernen</label></div><div><label>Zeilentrenner</label><input id='wseparator' placeholder=';'></div></div>
<label>CustomFunction</label><input id='wcf' placeholder='Namespace.functionName'>
<label>inArgs <span class='muted'>beliebig viele</span></label><div id='wargs'></div><button class='secondary' onclick='addWatcherArg()'>+ inArg</button>
<div class='help'>Platzhalter: {{CONTENT}}, {{RAW_CONTENT}}, {{FILE_NAME}}, {{FILE_PATH}}, {{FILE_DIR}}, {{FILE_EXT}}, {{JOB_NAME}}, {{EVENT}}, {{NOW}}</div>
<hr style='margin:16px 0;border:0;border-top:1px solid #e5e7eb'>
<label><input id='wmove' type='checkbox'> Datei nach erfolgreichem CF-Aufruf verschieben</label>
<div class='row'><div><label>Zielverzeichnis</label><input id='wtarget'></div><div><label><input id='woverwrite' type='checkbox'> Zieldatei überschreiben</label></div></div>
<div class='toolbar'><button class='primary' onclick='saveWatcher()'>Speichern</button><button class='success' onclick='scanWatcher()'>Vorhandene Dateien verarbeiten</button><button class='secondary' onclick='reloadWatchers()'>Watcher neu laden</button><button class='danger' onclick='deleteWatcher()'>Löschen</button></div><div id='wmsg' class='muted' style='margin-top:10px'></div>
</div></div></section>
<section id='cronPanel' class='panel'><div class='grid'>
<div class='card'><div style='display:flex;justify-content:space-between;align-items:center'><h3>Cronjobs</h3><button class='primary' onclick='newCron()'>+ Neuer Cronjob</button></div><div id='cronList'></div></div>
<div class='card'><h3 id='cronTitle'>Cronjob bearbeiten</h3><input id='cid' type='hidden'>
<label>Name</label><input id='cname'>
<div class='row'><div><label>Cron (5 Felder)</label><input id='ccron' placeholder='*/5 * * * *'></div><div><label><input id='cenabled' type='checkbox'> Aktiv</label></div></div>
<label><input id='cwithoutfile' type='checkbox'> CustomFunction einmal ohne Datei aufrufen</label>
<div class='row'><div><label>Quellverzeichnis</label><input id='csource'></div><div><label>Dateifilter</label><input id='cpattern' placeholder='*.xml'></div></div>
<label><input id='crecursive' type='checkbox'> Unterverzeichnisse einbeziehen</label>
<div class='row'><div><label><input id='cremoveempty' type='checkbox'> Leere Zeilen entfernen</label></div><div><label>Zeilentrenner</label><input id='cseparator' placeholder=';'></div></div>
<label>CustomFunction</label><input id='ccf' placeholder='Namespace.functionName'>
<label>inArgs <span class='muted'>beliebig viele</span></label><div id='cargs'></div><button class='secondary' onclick='addCronArg()'>+ inArg</button>
<div class='help'>Platzhalter: {{CONTENT}}, {{RAW_CONTENT}}, {{FILE_NAME}}, {{FILE_PATH}}, {{FILE_DIR}}, {{FILE_EXT}}, {{JOB_NAME}}, {{NOW}}</div>
<hr style='margin:16px 0;border:0;border-top:1px solid #e5e7eb'>
<label><input id='cmove' type='checkbox'> Datei nach erfolgreichem CF-Aufruf verschieben</label>
<div class='row'><div><label>Zielverzeichnis</label><input id='ctarget'></div><div><label><input id='coverwrite' type='checkbox'> Zieldatei überschreiben</label></div></div>
<div class='toolbar'><button class='primary' onclick='saveCron()'>Speichern</button><button class='success' onclick='runCron()'>Jetzt ausführen</button><button class='danger' onclick='deleteCron()'>Löschen</button></div><div id='cmsg' class='muted' style='margin-top:10px'></div>
</div></div></section>
<div class='card' style='margin-top:18px'><div style='display:flex;justify-content:space-between;align-items:center'><h3>Runtime / Log</h3><div id='status' class='muted'></div><button class='secondary' onclick='loadLogs()'>Aktualisieren</button></div><div id='log' class='log'></div></div>
</main>
<script>
const $=id=>document.getElementById(id);let watchers=[],crons=[];
async function api(url,opt){const r=await fetch(url,opt);const t=await r.text();let x;try{x=JSON.parse(t)}catch(e){throw new Error(t||r.statusText)}if(!r.ok)throw new Error(x.error||r.statusText);return x}
function showTab(t){$('watcherPanel').classList.toggle('active',t==='watcher');$('cronPanel').classList.toggle('active',t==='cron');$('tabWatcher').classList.toggle('active',t==='watcher');$('tabCron').classList.toggle('active',t==='cron')}
function esc(s){return String(s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}
function addArg(containerId,v){const d=document.createElement('div');d.className='arg';const i=document.createElement('input');i.value=v==null?'':v;const b=document.createElement('button');b.type='button';b.textContent='';b.className='danger';b.onclick=()=>d.remove();d.append(i,b);$(containerId).append(d)}
function addWatcherArg(v=''){addArg('wargs',v)}function addCronArg(v=''){addArg('cargs',v)}
async function loadWatchers(){watchers=await api('/api/watchers');renderWatchers();if(watchers.length&&!$('wid').value)editWatcher(watchers[0].Id)}
function renderWatchers(){$('watcherList').innerHTML=watchers.length?watchers.map(j=>`<div class='item'><b>${esc(j.Name)}</b> <span class='${j.Enabled?'ok':'off'}'>${j.Enabled?'aktiv':'inaktiv'}</span><div class='muted'>${esc(j.SourceDirectory)} · ${esc(j.SearchPattern)} · ${esc(j.CustomFunction)}</div><div class='muted'>Events: ${[j.WatchCreated?'Created':'',j.WatchChanged?'Changed':'',j.WatchRenamed?'Renamed':''].filter(Boolean).join(', ')}</div><button class='secondary' style='margin-top:6px' data-id='${esc(j.Id)}' onclick='editWatcher(this.dataset.id)'>Bearbeiten</button></div>`).join(''):'<div class=muted>Noch keine Watcher.</div>'}
function newWatcher(){fillWatcher({Id:'',Name:'Neuer Watcher',Enabled:true,SourceDirectory:'',SearchPattern:'*.*',IncludeSubdirectories:false,WatchCreated:true,WatchChanged:true,WatchRenamed:true,DebounceMilliseconds:1000,FileReadyRetries:10,FileReadyDelayMilliseconds:250,RemoveEmptyLines:true,JoinSeparator:';',CustomFunction:'',InArgs:['{{CONTENT}}'],MoveFileAfterSuccess:false,MoveTargetDirectory:'',OverwriteTargetFile:false})}
function editWatcher(id){const j=watchers.find(x=>x.Id===id);if(j)fillWatcher(j)}
function fillWatcher(j){$('wid').value=j.Id||'';$('wname').value=j.Name||'';$('wenabled').checked=!!j.Enabled;$('wsource').value=j.SourceDirectory||'';$('wpattern').value=j.SearchPattern||'*.*';$('wrecursive').checked=!!j.IncludeSubdirectories;$('wcreated').checked=!!j.WatchCreated;$('wchanged').checked=!!j.WatchChanged;$('wrenamed').checked=!!j.WatchRenamed;$('wdebounce').value=j.DebounceMilliseconds==null?1000:j.DebounceMilliseconds;$('wretries').value=j.FileReadyRetries||10;$('wretrydelay').value=j.FileReadyDelayMilliseconds||250;$('wremoveempty').checked=!!j.RemoveEmptyLines;$('wseparator').value=j.JoinSeparator==null?';':j.JoinSeparator;$('wcf').value=j.CustomFunction||'';$('wmove').checked=!!j.MoveFileAfterSuccess;$('wtarget').value=j.MoveTargetDirectory||'';$('woverwrite').checked=!!j.OverwriteTargetFile;$('wargs').innerHTML='';(j.InArgs||[]).forEach(addWatcherArg);$('watcherTitle').textContent=j.Id?'Watcher bearbeiten':'Neuer Watcher'}
function watcherModel(){return {Id:$('wid').value,Name:$('wname').value,Enabled:$('wenabled').checked,SourceDirectory:$('wsource').value,SearchPattern:$('wpattern').value,IncludeSubdirectories:$('wrecursive').checked,WatchCreated:$('wcreated').checked,WatchChanged:$('wchanged').checked,WatchRenamed:$('wrenamed').checked,DebounceMilliseconds:Number($('wdebounce').value||0),FileReadyRetries:Number($('wretries').value||10),FileReadyDelayMilliseconds:Number($('wretrydelay').value||250),RemoveEmptyLines:$('wremoveempty').checked,JoinSeparator:$('wseparator').value,CustomFunction:$('wcf').value,InArgs:[...$('wargs').querySelectorAll('input')].map(x=>x.value),MoveFileAfterSuccess:$('wmove').checked,MoveTargetDirectory:$('wtarget').value,OverwriteTargetFile:$('woverwrite').checked}}
async function saveWatcher(){try{const j=await api('/api/watchers/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(watcherModel())});$('wid').value=j.Id;$('wmsg').textContent='Gespeichert und FileSystemWatcher neu geladen.';await loadWatchers();await loadStatus()}catch(e){$('wmsg').textContent='Fehler: '+e.message}}
async function deleteWatcher(){if(!$('wid').value||!confirm('Watcher wirklich löschen?'))return;await api('/api/watchers/delete?id='+encodeURIComponent($('wid').value),{method:'POST'});$('wid').value='';newWatcher();await loadWatchers();await loadStatus()}
async function reloadWatchers(){try{await api('/api/watchers/reload',{method:'POST'});$('wmsg').textContent='Alle FileSystemWatcher neu geladen.';await loadStatus()}catch(e){$('wmsg').textContent='Fehler: '+e.message}}
async function scanWatcher(){if(!$('wid').value){$('wmsg').textContent='Bitte zuerst speichern.';return}const r=await api('/api/watchers/scan?id='+encodeURIComponent($('wid').value),{method:'POST'});$('wmsg').textContent=r.started?'Verzeichnisverarbeitung gestartet.':'Watcher nicht gefunden.';setTimeout(loadLogs,500)}
async function loadCrons(){crons=await api('/api/cronjobs');renderCrons();if(crons.length&&!$('cid').value)editCron(crons[0].Id)}
function renderCrons(){$('cronList').innerHTML=crons.length?crons.map(j=>`<div class='item'><b>${esc(j.Name)}</b> <span class='${j.Enabled?'ok':'off'}'>${j.Enabled?'aktiv':'inaktiv'}</span><div class='muted'>${esc(j.Cron)} · ${esc(j.CustomFunction)}</div><button class='secondary' style='margin-top:6px' data-id='${esc(j.Id)}' onclick='editCron(this.dataset.id)'>Bearbeiten</button></div>`).join(''):'<div class=muted>Noch keine Cronjobs.</div>'}
function newCron(){fillCron({Id:'',Name:'Neuer Cronjob',Enabled:true,Cron:'*/5 * * * *',CallOnceWithoutFile:false,SourceDirectory:'',SearchPattern:'*.*',IncludeSubdirectories:false,RemoveEmptyLines:true,JoinSeparator:';',CustomFunction:'',InArgs:['{{CONTENT}}'],MoveFileAfterSuccess:false,MoveTargetDirectory:'',OverwriteTargetFile:false})}
function editCron(id){const j=crons.find(x=>x.Id===id);if(j)fillCron(j)}
function fillCron(j){$('cid').value=j.Id||'';$('cname').value=j.Name||'';$('ccron').value=j.Cron||'';$('cenabled').checked=!!j.Enabled;$('cwithoutfile').checked=!!j.CallOnceWithoutFile;$('csource').value=j.SourceDirectory||'';$('cpattern').value=j.SearchPattern||'*.*';$('crecursive').checked=!!j.IncludeSubdirectories;$('cremoveempty').checked=!!j.RemoveEmptyLines;$('cseparator').value=j.JoinSeparator==null?';':j.JoinSeparator;$('ccf').value=j.CustomFunction||'';$('cmove').checked=!!j.MoveFileAfterSuccess;$('ctarget').value=j.MoveTargetDirectory||'';$('coverwrite').checked=!!j.OverwriteTargetFile;$('cargs').innerHTML='';(j.InArgs||[]).forEach(addCronArg);$('cronTitle').textContent=j.Id?'Cronjob bearbeiten':'Neuer Cronjob'}
function cronModel(){return {Id:$('cid').value,Name:$('cname').value,Enabled:$('cenabled').checked,Cron:$('ccron').value,CallOnceWithoutFile:$('cwithoutfile').checked,SourceDirectory:$('csource').value,SearchPattern:$('cpattern').value,IncludeSubdirectories:$('crecursive').checked,RemoveEmptyLines:$('cremoveempty').checked,JoinSeparator:$('cseparator').value,CustomFunction:$('ccf').value,InArgs:[...$('cargs').querySelectorAll('input')].map(x=>x.value),MoveFileAfterSuccess:$('cmove').checked,MoveTargetDirectory:$('ctarget').value,OverwriteTargetFile:$('coverwrite').checked}}
async function saveCron(){try{const j=await api('/api/cronjobs/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(cronModel())});$('cid').value=j.Id;$('cmsg').textContent='Gespeichert.';await loadCrons()}catch(e){$('cmsg').textContent='Fehler: '+e.message}}
async function deleteCron(){if(!$('cid').value||!confirm('Cronjob wirklich löschen?'))return;await api('/api/cronjobs/delete?id='+encodeURIComponent($('cid').value),{method:'POST'});$('cid').value='';newCron();await loadCrons()}
async function runCron(){if(!$('cid').value){$('cmsg').textContent='Bitte zuerst speichern.';return}const r=await api('/api/cronjobs/run?id='+encodeURIComponent($('cid').value),{method:'POST'});$('cmsg').textContent=r.started?'Cronjob gestartet.':'Cronjob nicht gefunden.';setTimeout(loadLogs,500)}
async function loadStatus(){try{const s=await api('/api/status');$('status').textContent=`Aktive Watcher: ${s.activeWatcherIds.length} · Laufende Cronjobs: ${s.runningCronJobIds.length}`}catch(e){$('status').textContent='Statusfehler: '+e.message}}
async function loadLogs(){try{const l=await api('/api/logs?limit=400');$('log').textContent=l.map(x=>`${formatDate(x.Timestamp)} [${x.Level}] [${x.JobType||''}] ${x.JobName}: ${x.Message}`).join('\n')}catch(e){$('log').textContent='Logfehler: '+e.message}}
function formatDate(v){if(typeof v==='string')return v;return JSON.stringify(v)}
newWatcher();newCron();Promise.all([loadWatchers(),loadCrons(),loadStatus(),loadLogs()]);setInterval(()=>{loadStatus();loadLogs()},10000);
</script></body></html>";
}
}