Projektdateien hinzufügen.

This commit is contained in:
mr_sc
2026-08-19 19:27:41 +02:00
parent 0889f1f6c7
commit 7fe54c0785
32 changed files with 2406 additions and 0 deletions

View File

@@ -0,0 +1,278 @@
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>";
}
}