Projektdateien hinzufügen.
This commit is contained in:
131
FileinterfaceCronScheduler/Scheduler/CronExpression.cs
Normal file
131
FileinterfaceCronScheduler/Scheduler/CronExpression.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard 5-field cron expression: minute hour day-of-month month day-of-week.
|
||||
/// Supported: *, */n, a,b,c, a-b, a-b/n. Day-of-week: 0 or 7 = Sunday.
|
||||
/// </summary>
|
||||
public sealed class CronExpression
|
||||
{
|
||||
private readonly HashSet<int> _minutes;
|
||||
private readonly HashSet<int> _hours;
|
||||
private readonly HashSet<int> _days;
|
||||
private readonly HashSet<int> _months;
|
||||
private readonly HashSet<int> _daysOfWeek;
|
||||
|
||||
public string Expression { get; private set; }
|
||||
|
||||
private CronExpression(string expression,
|
||||
HashSet<int> minutes,
|
||||
HashSet<int> hours,
|
||||
HashSet<int> days,
|
||||
HashSet<int> months,
|
||||
HashSet<int> daysOfWeek)
|
||||
{
|
||||
Expression = expression;
|
||||
_minutes = minutes;
|
||||
_hours = hours;
|
||||
_days = days;
|
||||
_months = months;
|
||||
_daysOfWeek = daysOfWeek;
|
||||
}
|
||||
|
||||
public static CronExpression Parse(string expression)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(expression))
|
||||
throw new FormatException("Cron expression is empty.");
|
||||
|
||||
string[] parts = expression.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 5)
|
||||
throw new FormatException("Cron must contain exactly 5 fields: minute hour day month day-of-week.");
|
||||
|
||||
return new CronExpression(
|
||||
expression.Trim(),
|
||||
ParseField(parts[0], 0, 59, false),
|
||||
ParseField(parts[1], 0, 23, false),
|
||||
ParseField(parts[2], 1, 31, false),
|
||||
ParseField(parts[3], 1, 12, false),
|
||||
ParseField(parts[4], 0, 7, true));
|
||||
}
|
||||
|
||||
public bool IsMatch(DateTime localTime)
|
||||
{
|
||||
int dow = (int)localTime.DayOfWeek;
|
||||
return _minutes.Contains(localTime.Minute)
|
||||
&& _hours.Contains(localTime.Hour)
|
||||
&& _days.Contains(localTime.Day)
|
||||
&& _months.Contains(localTime.Month)
|
||||
&& _daysOfWeek.Contains(dow);
|
||||
}
|
||||
|
||||
private static HashSet<int> ParseField(string text, int min, int max, bool normalizeSunday)
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
string[] segments = text.Split(',');
|
||||
foreach (string rawSegment in segments)
|
||||
{
|
||||
string segment = rawSegment.Trim();
|
||||
if (segment.Length == 0)
|
||||
throw new FormatException("Empty cron field segment.");
|
||||
|
||||
int step = 1;
|
||||
string rangePart = segment;
|
||||
int slash = segment.IndexOf('/');
|
||||
if (slash >= 0)
|
||||
{
|
||||
rangePart = segment.Substring(0, slash);
|
||||
string stepText = segment.Substring(slash + 1);
|
||||
if (!Int32.TryParse(stepText, NumberStyles.Integer, CultureInfo.InvariantCulture, out step) || step <= 0)
|
||||
throw new FormatException("Invalid cron step: " + segment);
|
||||
}
|
||||
|
||||
int start;
|
||||
int end;
|
||||
if (rangePart == "*")
|
||||
{
|
||||
start = min;
|
||||
end = max;
|
||||
}
|
||||
else
|
||||
{
|
||||
int dash = rangePart.IndexOf('-');
|
||||
if (dash >= 0)
|
||||
{
|
||||
start = ParseNumber(rangePart.Substring(0, dash), min, max);
|
||||
end = ParseNumber(rangePart.Substring(dash + 1), min, max);
|
||||
if (start > end)
|
||||
throw new FormatException("Cron range start is greater than end: " + segment);
|
||||
}
|
||||
else
|
||||
{
|
||||
start = ParseNumber(rangePart, min, max);
|
||||
end = start;
|
||||
}
|
||||
}
|
||||
|
||||
for (int value = start; value <= end; value += step)
|
||||
{
|
||||
int normalized = normalizeSunday && value == 7 ? 0 : value;
|
||||
result.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Count == 0)
|
||||
throw new FormatException("Cron field produced no values: " + text);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int ParseNumber(string text, int min, int max)
|
||||
{
|
||||
int value;
|
||||
if (!Int32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
|
||||
throw new FormatException("Invalid cron number: " + text);
|
||||
if (value < min || value > max)
|
||||
throw new FormatException("Cron value out of range: " + value + " (allowed " + min + "-" + max + ")");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
69
FileinterfaceCronScheduler/Scheduler/CronJobConfig.cs
Normal file
69
FileinterfaceCronScheduler/Scheduler/CronJobConfig.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class CronJobConfig
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public string Cron { get; set; }
|
||||
|
||||
// File processing. If CallOnceWithoutFile=true, SourceDirectory/SearchPattern are ignored.
|
||||
public bool CallOnceWithoutFile { get; set; }
|
||||
public string SourceDirectory { get; set; }
|
||||
public string SearchPattern { get; set; }
|
||||
public bool IncludeSubdirectories { get; set; }
|
||||
|
||||
// Existing FileInterface-style preprocessing.
|
||||
public bool RemoveEmptyLines { get; set; }
|
||||
public string JoinSeparator { get; set; }
|
||||
|
||||
// iTAC CustomFunction.
|
||||
public string CustomFunction { get; set; }
|
||||
public List<string> InArgs { get; set; }
|
||||
|
||||
// File handling after successful CF call.
|
||||
public bool MoveFileAfterSuccess { get; set; }
|
||||
public string MoveTargetDirectory { get; set; }
|
||||
public bool OverwriteTargetFile { get; set; }
|
||||
|
||||
public CronJobConfig()
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N");
|
||||
Name = "New job";
|
||||
Enabled = true;
|
||||
Cron = "*/5 * * * *";
|
||||
CallOnceWithoutFile = false;
|
||||
SourceDirectory = "";
|
||||
SearchPattern = "*.*";
|
||||
IncludeSubdirectories = false;
|
||||
RemoveEmptyLines = true;
|
||||
JoinSeparator = ";";
|
||||
CustomFunction = "";
|
||||
InArgs = new List<string>();
|
||||
MoveFileAfterSuccess = false;
|
||||
MoveTargetDirectory = "";
|
||||
OverwriteTargetFile = false;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SchedulerOptions
|
||||
{
|
||||
public string ConfigFile { get; set; }
|
||||
public string WatcherConfigFile { get; set; }
|
||||
public string WebPrefix { get; set; }
|
||||
public string WebUser { get; set; }
|
||||
public string WebPassword { get; set; }
|
||||
|
||||
public SchedulerOptions()
|
||||
{
|
||||
ConfigFile = "cronjobs.json";
|
||||
WatcherConfigFile = "watcherjobs.json";
|
||||
WebPrefix = "http://127.0.0.1:8095/";
|
||||
WebUser = "";
|
||||
WebPassword = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
145
FileinterfaceCronScheduler/Scheduler/CronJobExecutor.cs
Normal file
145
FileinterfaceCronScheduler/Scheduler/CronJobExecutor.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class CronJobExecutor
|
||||
{
|
||||
private readonly ICustomFunctionInvoker _invoker;
|
||||
private readonly JobLog _log;
|
||||
|
||||
public CronJobExecutor(ICustomFunctionInvoker invoker, JobLog log)
|
||||
{
|
||||
_invoker = invoker;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Execute(CronJobConfig job)
|
||||
{
|
||||
if (job.CallOnceWithoutFile)
|
||||
{
|
||||
ExecuteSingle(job, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(job.SourceDirectory))
|
||||
{
|
||||
_log.Error(job, "Source directory does not exist: " + job.SourceDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
SearchOption option = job.IncludeSubdirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
string[] files;
|
||||
try
|
||||
{
|
||||
files = Directory.GetFiles(job.SourceDirectory, String.IsNullOrWhiteSpace(job.SearchPattern) ? "*.*" : job.SearchPattern, option)
|
||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(job, "Cannot enumerate source files: " + ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Info(job, "Found " + files.Length + " file(s).");
|
||||
foreach (string file in files)
|
||||
{
|
||||
try { ExecuteSingle(job, file); }
|
||||
catch (Exception ex) { _log.Error(job, "File failed: " + file + " - " + ex.Message); }
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteSingle(CronJobConfig job, string file)
|
||||
{
|
||||
string rawContent = "";
|
||||
string processedContent = "";
|
||||
|
||||
if (file != null)
|
||||
{
|
||||
// Opening with FileShare.ReadWrite prevents common producer/consumer lock issues.
|
||||
using (var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
using (var reader = new StreamReader(stream, Encoding.UTF8, true))
|
||||
rawContent = reader.ReadToEnd();
|
||||
|
||||
processedContent = ProcessContent(rawContent, job.RemoveEmptyLines, job.JoinSeparator);
|
||||
}
|
||||
|
||||
string[] inArgs = (job.InArgs ?? new List<string>())
|
||||
.Select(arg => Expand(arg, job, file, rawContent, processedContent))
|
||||
.ToArray();
|
||||
|
||||
_log.Info(job, "Calling CustomFunction '" + job.CustomFunction + "' with " + inArgs.Length + " inArg(s)" + (file == null ? "." : " for " + Path.GetFileName(file) + "."));
|
||||
CustomFunctionCallResult result = _invoker.Invoke(job.CustomFunction, inArgs);
|
||||
if (result == null) result = CustomFunctionCallResult.Fail("Invoker returned null.");
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_log.Error(job, "CustomFunction failed: " + result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Info(job, "CustomFunction succeeded: " + result.Message);
|
||||
if (file != null && job.MoveFileAfterSuccess)
|
||||
{
|
||||
string movedTo = MoveProcessedFile(file, job.MoveTargetDirectory, job.OverwriteTargetFile);
|
||||
_log.Info(job, "Moved file to: " + movedTo);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ProcessContent(string text, bool removeEmptyLines, string separator)
|
||||
{
|
||||
if (!removeEmptyLines) return text ?? "";
|
||||
string[] lines = (text ?? "").Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
|
||||
return String.Join(separator ?? ";", lines.Where(l => !String.IsNullOrWhiteSpace(l)).Select(l => l.Trim()));
|
||||
}
|
||||
|
||||
private static string Expand(string template, CronJobConfig job, string file, string rawContent, string processedContent)
|
||||
{
|
||||
string value = template ?? "";
|
||||
string fullPath = file ?? "";
|
||||
string fileName = file == null ? "" : Path.GetFileName(file);
|
||||
string extension = file == null ? "" : Path.GetExtension(file);
|
||||
string directory = file == null ? "" : Path.GetDirectoryName(file);
|
||||
|
||||
return value
|
||||
.Replace("{{CONTENT}}", processedContent ?? "")
|
||||
.Replace("{{RAW_CONTENT}}", rawContent ?? "")
|
||||
.Replace("{{FILE_NAME}}", fileName ?? "")
|
||||
.Replace("{{FILE_PATH}}", fullPath)
|
||||
.Replace("{{FILE_DIR}}", directory ?? "")
|
||||
.Replace("{{FILE_EXT}}", extension ?? "")
|
||||
.Replace("{{JOB_NAME}}", job.Name ?? "")
|
||||
.Replace("{{NOW}}", DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffK"));
|
||||
}
|
||||
|
||||
private static string MoveProcessedFile(string source, string targetDirectory, bool overwrite)
|
||||
{
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
string target = Path.Combine(targetDirectory, Path.GetFileName(source));
|
||||
|
||||
if (File.Exists(target))
|
||||
{
|
||||
if (overwrite) File.Delete(target);
|
||||
else
|
||||
{
|
||||
string name = Path.GetFileNameWithoutExtension(source);
|
||||
string ext = Path.GetExtension(source);
|
||||
target = Path.Combine(targetDirectory, name + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + ext);
|
||||
int i = 1;
|
||||
while (File.Exists(target))
|
||||
{
|
||||
target = Path.Combine(targetDirectory, name + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + "_" + i + ext);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(source, target);
|
||||
return target;
|
||||
}
|
||||
}
|
||||
}
|
||||
120
FileinterfaceCronScheduler/Scheduler/CronJobRepository.cs
Normal file
120
FileinterfaceCronScheduler/Scheduler/CronJobRepository.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Web.Script.Serialization;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class CronJobRepository
|
||||
{
|
||||
private readonly object _sync = new object();
|
||||
private readonly string _fileName;
|
||||
private readonly JavaScriptSerializer _serializer = new JavaScriptSerializer();
|
||||
private List<CronJobConfig> _jobs;
|
||||
|
||||
public CronJobRepository(string fileName)
|
||||
{
|
||||
_fileName = Path.GetFullPath(fileName);
|
||||
_serializer.MaxJsonLength = Int32.MaxValue;
|
||||
_jobs = LoadInternal();
|
||||
}
|
||||
|
||||
public IList<CronJobConfig> GetAll()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
// Clone through JSON so callers cannot mutate repository state behind our lock.
|
||||
return Clone(_jobs);
|
||||
}
|
||||
}
|
||||
|
||||
public CronJobConfig GetById(string id)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
CronJobConfig job = _jobs.FirstOrDefault(j => String.Equals(j.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
return job == null ? null : Clone(job);
|
||||
}
|
||||
}
|
||||
|
||||
public CronJobConfig Save(CronJobConfig job)
|
||||
{
|
||||
if (job == null) throw new ArgumentNullException("job");
|
||||
Validate(job);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(job.Id))
|
||||
job.Id = Guid.NewGuid().ToString("N");
|
||||
|
||||
int index = _jobs.FindIndex(j => String.Equals(j.Id, job.Id, StringComparison.OrdinalIgnoreCase));
|
||||
if (index >= 0) _jobs[index] = Clone(job);
|
||||
else _jobs.Add(Clone(job));
|
||||
|
||||
SaveInternal();
|
||||
return Clone(job);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Delete(string id)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
int count = _jobs.RemoveAll(j => String.Equals(j.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (count > 0) SaveInternal();
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
private List<CronJobConfig> LoadInternal()
|
||||
{
|
||||
if (!File.Exists(_fileName)) return new List<CronJobConfig>();
|
||||
string json = File.ReadAllText(_fileName, Encoding.UTF8);
|
||||
if (String.IsNullOrWhiteSpace(json)) return new List<CronJobConfig>();
|
||||
return _serializer.Deserialize<List<CronJobConfig>>(json) ?? new List<CronJobConfig>();
|
||||
}
|
||||
|
||||
private void SaveInternal()
|
||||
{
|
||||
string dir = Path.GetDirectoryName(_fileName);
|
||||
if (!String.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
string json = _serializer.Serialize(_jobs);
|
||||
string tmp = _fileName + ".tmp";
|
||||
File.WriteAllText(tmp, json, new UTF8Encoding(false));
|
||||
|
||||
if (File.Exists(_fileName))
|
||||
{
|
||||
string backup = _fileName + ".bak";
|
||||
try { File.Replace(tmp, _fileName, backup, true); }
|
||||
catch
|
||||
{
|
||||
File.Delete(_fileName);
|
||||
File.Move(tmp, _fileName);
|
||||
}
|
||||
}
|
||||
else File.Move(tmp, _fileName);
|
||||
}
|
||||
|
||||
private static void Validate(CronJobConfig job)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(job.Name)) throw new InvalidOperationException("Name is required.");
|
||||
CronExpression.Parse(job.Cron);
|
||||
if (String.IsNullOrWhiteSpace(job.CustomFunction)) throw new InvalidOperationException("CustomFunction is required.");
|
||||
if (!job.CallOnceWithoutFile && String.IsNullOrWhiteSpace(job.SourceDirectory))
|
||||
throw new InvalidOperationException("SourceDirectory is required for file jobs.");
|
||||
if (job.MoveFileAfterSuccess && String.IsNullOrWhiteSpace(job.MoveTargetDirectory))
|
||||
throw new InvalidOperationException("MoveTargetDirectory is required when MoveFileAfterSuccess=true.");
|
||||
if (job.InArgs == null) job.InArgs = new List<string>();
|
||||
if (String.IsNullOrWhiteSpace(job.SearchPattern)) job.SearchPattern = "*.*";
|
||||
if (job.JoinSeparator == null) job.JoinSeparator = ";";
|
||||
}
|
||||
|
||||
private T Clone<T>(T value)
|
||||
{
|
||||
return _serializer.Deserialize<T>(_serializer.Serialize(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
106
FileinterfaceCronScheduler/Scheduler/CronScheduler.cs
Normal file
106
FileinterfaceCronScheduler/Scheduler/CronScheduler.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class CronScheduler : IDisposable
|
||||
{
|
||||
private readonly CronJobRepository _repository;
|
||||
private readonly CronJobExecutor _executor;
|
||||
private readonly JobLog _log;
|
||||
private readonly ConcurrentDictionary<string, byte> _running = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> _lastMinuteRun = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
private Timer _timer;
|
||||
private int _checking;
|
||||
|
||||
public CronScheduler(CronJobRepository repository, CronJobExecutor executor, JobLog log)
|
||||
{
|
||||
_repository = repository;
|
||||
_executor = executor;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_timer != null) return;
|
||||
_timer = new Timer(CheckDueJobs, null, TimeSpan.Zero, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public bool RunNow(string jobId)
|
||||
{
|
||||
CronJobConfig job = _repository.GetById(jobId);
|
||||
if (job == null) return false;
|
||||
QueueExecution(job, "manual");
|
||||
return true;
|
||||
}
|
||||
|
||||
public IList<string> GetRunningJobIds()
|
||||
{
|
||||
return new List<string>(_running.Keys);
|
||||
}
|
||||
|
||||
private void CheckDueJobs(object state)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _checking, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
DateTime now = DateTime.Now;
|
||||
string minuteStamp = now.ToString("yyyyMMddHHmm");
|
||||
foreach (CronJobConfig job in _repository.GetAll())
|
||||
{
|
||||
if (!job.Enabled) continue;
|
||||
try
|
||||
{
|
||||
CronExpression cron = CronExpression.Parse(job.Cron);
|
||||
if (!cron.IsMatch(now)) continue;
|
||||
|
||||
string previous;
|
||||
if (_lastMinuteRun.TryGetValue(job.Id, out previous) && previous == minuteStamp) continue;
|
||||
_lastMinuteRun[job.Id] = minuteStamp;
|
||||
QueueExecution(job, "cron " + job.Cron);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(job, "Invalid cron/configuration: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { Interlocked.Exchange(ref _checking, 0); }
|
||||
}
|
||||
|
||||
private void QueueExecution(CronJobConfig job, string reason)
|
||||
{
|
||||
byte ignored;
|
||||
if (!_running.TryAdd(job.Id, 0))
|
||||
{
|
||||
_log.Info(job, "Skipped " + reason + " start because this job is still running.");
|
||||
return;
|
||||
}
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_log.Info(job, "Job started (" + reason + ").");
|
||||
_executor.Execute(job);
|
||||
_log.Info(job, "Job finished.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(job, "Job crashed: " + ex);
|
||||
}
|
||||
finally { _running.TryRemove(job.Id, out ignored); }
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Timer timer = _timer;
|
||||
_timer = null;
|
||||
if (timer != null) timer.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class CustomFunctionCallResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; }
|
||||
|
||||
public static CustomFunctionCallResult Ok(string message)
|
||||
{
|
||||
return new CustomFunctionCallResult { Success = true, Message = message ?? "OK" };
|
||||
}
|
||||
|
||||
public static CustomFunctionCallResult Fail(string message)
|
||||
{
|
||||
return new CustomFunctionCallResult { Success = false, Message = message ?? "CustomFunction failed" };
|
||||
}
|
||||
}
|
||||
|
||||
public interface ICustomFunctionInvoker
|
||||
{
|
||||
CustomFunctionCallResult Invoke(string customFunctionName, string[] inArgs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets the existing FileInterface IMS code remain untouched. Wire the current
|
||||
/// IMS CustomFunction call into this delegate in Program.cs / service startup.
|
||||
/// </summary>
|
||||
public sealed class DelegateCustomFunctionInvoker : ICustomFunctionInvoker
|
||||
{
|
||||
private readonly Func<string, string[], CustomFunctionCallResult> _handler;
|
||||
|
||||
public DelegateCustomFunctionInvoker(Func<string, string[], CustomFunctionCallResult> handler)
|
||||
{
|
||||
if (handler == null) throw new ArgumentNullException("handler");
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public CustomFunctionCallResult Invoke(string customFunctionName, string[] inArgs)
|
||||
{
|
||||
return _handler(customFunctionName, inArgs);
|
||||
}
|
||||
}
|
||||
}
|
||||
172
FileinterfaceCronScheduler/Scheduler/FileSystemWatcherManager.cs
Normal file
172
FileinterfaceCronScheduler/Scheduler/FileSystemWatcherManager.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class FileSystemWatcherManager : IDisposable
|
||||
{
|
||||
private sealed class ActiveWatcher : IDisposable
|
||||
{
|
||||
public WatcherJobConfig Job;
|
||||
public FileSystemWatcher Watcher;
|
||||
public void Dispose() { if (Watcher != null) { Watcher.EnableRaisingEvents = false; Watcher.Dispose(); } }
|
||||
}
|
||||
|
||||
private readonly object _sync = new object();
|
||||
private readonly WatcherJobRepository _repository;
|
||||
private readonly WatcherJobExecutor _executor;
|
||||
private readonly JobLog _log;
|
||||
private readonly Dictionary<string, ActiveWatcher> _watchers = new Dictionary<string, ActiveWatcher>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, DateTime> _lastEvents = new ConcurrentDictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, byte> _runningFiles = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _disposed;
|
||||
|
||||
public FileSystemWatcherManager(WatcherJobRepository repository, WatcherJobExecutor executor, JobLog log)
|
||||
{
|
||||
_repository = repository;
|
||||
_executor = executor;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Start() { Reload(); }
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
foreach (ActiveWatcher old in _watchers.Values) old.Dispose();
|
||||
_watchers.Clear();
|
||||
|
||||
foreach (WatcherJobConfig job in _repository.GetAll())
|
||||
{
|
||||
if (!job.Enabled) continue;
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(job.SourceDirectory))
|
||||
{
|
||||
_log.Error(job, "Watcher source directory does not exist: " + job.SourceDirectory);
|
||||
continue;
|
||||
}
|
||||
|
||||
var fsw = new FileSystemWatcher(job.SourceDirectory, String.IsNullOrWhiteSpace(job.SearchPattern) ? "*.*" : job.SearchPattern)
|
||||
{
|
||||
IncludeSubdirectories = job.IncludeSubdirectories,
|
||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime,
|
||||
EnableRaisingEvents = false
|
||||
};
|
||||
|
||||
WatcherJobConfig captured = job;
|
||||
if (job.WatchCreated) fsw.Created += (s, e) => OnFileEvent(captured, e.FullPath, "Created");
|
||||
if (job.WatchChanged) fsw.Changed += (s, e) => OnFileEvent(captured, e.FullPath, "Changed");
|
||||
if (job.WatchRenamed) fsw.Renamed += (s, e) => OnFileEvent(captured, e.FullPath, "Renamed");
|
||||
fsw.Error += (s, e) => _log.Error(captured, "FileSystemWatcher error: " + (e.GetException() == null ? "unknown" : e.GetException().Message));
|
||||
|
||||
var active = new ActiveWatcher { Job = job, Watcher = fsw };
|
||||
_watchers[job.Id] = active;
|
||||
fsw.EnableRaisingEvents = true;
|
||||
_log.Info(job, "FileSystemWatcher active: " + job.SourceDirectory + "\\" + job.SearchPattern);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(job, "Cannot start FileSystemWatcher: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IList<string> GetActiveWatcherIds()
|
||||
{
|
||||
lock (_sync) return _watchers.Keys.ToList();
|
||||
}
|
||||
|
||||
public bool ProcessExistingNow(string watcherId)
|
||||
{
|
||||
WatcherJobConfig job = _repository.GetById(watcherId);
|
||||
if (job == null) return false;
|
||||
Task.Run(() => ProcessExisting(job));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ProcessExisting(WatcherJobConfig job)
|
||||
{
|
||||
if (!Directory.Exists(job.SourceDirectory))
|
||||
{
|
||||
_log.Error(job, "Source directory does not exist: " + job.SourceDirectory);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
SearchOption option = job.IncludeSubdirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
string[] files = Directory.GetFiles(job.SourceDirectory, String.IsNullOrWhiteSpace(job.SearchPattern) ? "*.*" : job.SearchPattern, option)
|
||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
_log.Info(job, "Manual watcher scan found " + files.Length + " file(s).");
|
||||
foreach (string file in files) QueueFile(job, file, "ManualScan", false);
|
||||
}
|
||||
catch (Exception ex) { _log.Error(job, "Manual watcher scan failed: " + ex.Message); }
|
||||
}
|
||||
|
||||
private void OnFileEvent(WatcherJobConfig job, string fullPath, string reason)
|
||||
{
|
||||
if (!job.Enabled || String.IsNullOrWhiteSpace(fullPath)) return;
|
||||
if (IsMoveTarget(job, fullPath)) return;
|
||||
QueueFile(job, fullPath, reason, true);
|
||||
}
|
||||
|
||||
private void QueueFile(WatcherJobConfig job, string fullPath, string reason, bool debounce)
|
||||
{
|
||||
string key = job.Id + "|" + fullPath;
|
||||
DateTime now = DateTime.UtcNow;
|
||||
if (debounce)
|
||||
{
|
||||
DateTime previous;
|
||||
if (_lastEvents.TryGetValue(key, out previous) && (now - previous).TotalMilliseconds < Math.Max(0, job.DebounceMilliseconds))
|
||||
return;
|
||||
_lastEvents[key] = now;
|
||||
}
|
||||
|
||||
byte ignored;
|
||||
if (!_runningFiles.TryAdd(key, 0)) return;
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(fullPath)) return;
|
||||
_log.Info(job, "File event queued: " + reason + " - " + fullPath);
|
||||
_executor.ExecuteFile(job, fullPath, reason);
|
||||
}
|
||||
catch (Exception ex) { _log.Error(job, "Watcher processing failed for " + fullPath + ": " + ex); }
|
||||
finally { _runningFiles.TryRemove(key, out ignored); }
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsMoveTarget(WatcherJobConfig job, string fullPath)
|
||||
{
|
||||
if (!job.MoveFileAfterSuccess || String.IsNullOrWhiteSpace(job.MoveTargetDirectory)) return false;
|
||||
try
|
||||
{
|
||||
string file = Path.GetFullPath(fullPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
string target = Path.GetFullPath(job.MoveTargetDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return file.StartsWith(target, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(GetType().FullName); }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
foreach (ActiveWatcher w in _watchers.Values) w.Dispose();
|
||||
_watchers.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
FileinterfaceCronScheduler/Scheduler/JobLog.cs
Normal file
60
FileinterfaceCronScheduler/Scheduler/JobLog.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class JobLogEntry
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string JobType { get; set; }
|
||||
public string JobId { get; set; }
|
||||
public string JobName { get; set; }
|
||||
public string Level { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
|
||||
public sealed class JobLog
|
||||
{
|
||||
private readonly object _sync = new object();
|
||||
private readonly Queue<JobLogEntry> _entries = new Queue<JobLogEntry>();
|
||||
private readonly int _maxEntries;
|
||||
|
||||
public JobLog(int maxEntries)
|
||||
{
|
||||
_maxEntries = Math.Max(100, maxEntries);
|
||||
}
|
||||
|
||||
public void Info(CronJobConfig job, string message) { Add("CRON", job == null ? "" : job.Id, job == null ? "" : job.Name, "INFO", message); }
|
||||
public void Error(CronJobConfig job, string message) { Add("CRON", job == null ? "" : job.Id, job == null ? "" : job.Name, "ERROR", message); }
|
||||
public void Info(WatcherJobConfig job, string message) { Add("WATCHER", job == null ? "" : job.Id, job == null ? "" : job.Name, "INFO", message); }
|
||||
public void Error(WatcherJobConfig job, string message) { Add("WATCHER", job == null ? "" : job.Id, job == null ? "" : job.Name, "ERROR", message); }
|
||||
public void SystemInfo(string message) { Add("SYSTEM", "", "System", "INFO", message); }
|
||||
public void SystemError(string message) { Add("SYSTEM", "", "System", "ERROR", message); }
|
||||
|
||||
public IList<JobLogEntry> GetLatest(int limit)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _entries.Reverse().Take(Math.Max(1, Math.Min(limit, _maxEntries))).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private void Add(string jobType, string jobId, string jobName, string level, string message)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_entries.Enqueue(new JobLogEntry
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
JobType = jobType ?? "",
|
||||
JobId = jobId ?? "",
|
||||
JobName = jobName ?? "",
|
||||
Level = level,
|
||||
Message = message ?? ""
|
||||
});
|
||||
while (_entries.Count > _maxEntries) _entries.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
FileinterfaceCronScheduler/Scheduler/SchedulerRuntime.cs
Normal file
57
FileinterfaceCronScheduler/Scheduler/SchedulerRuntime.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class SchedulerRuntime : IDisposable
|
||||
{
|
||||
private readonly CronScheduler _scheduler;
|
||||
private readonly FileSystemWatcherManager _watcherManager;
|
||||
private readonly WebAdminServer _web;
|
||||
private readonly IDisposable _ownedResource;
|
||||
private bool _disposed;
|
||||
|
||||
public SchedulerRuntime(ICustomFunctionInvoker invoker, SchedulerOptions options)
|
||||
: this(invoker, options, null)
|
||||
{
|
||||
}
|
||||
|
||||
public SchedulerRuntime(ICustomFunctionInvoker invoker, SchedulerOptions options, IDisposable ownedResource)
|
||||
{
|
||||
if (invoker == null) throw new ArgumentNullException("invoker");
|
||||
if (options == null) options = new SchedulerOptions();
|
||||
|
||||
_ownedResource = ownedResource;
|
||||
var cronRepository = new CronJobRepository(options.ConfigFile);
|
||||
var watcherRepository = new WatcherJobRepository(options.WatcherConfigFile);
|
||||
var log = new JobLog(5000);
|
||||
var cronExecutor = new CronJobExecutor(invoker, log);
|
||||
var watcherExecutor = new WatcherJobExecutor(invoker, log);
|
||||
_scheduler = new CronScheduler(cronRepository, cronExecutor, log);
|
||||
_watcherManager = new FileSystemWatcherManager(watcherRepository, watcherExecutor, log);
|
||||
_web = new WebAdminServer(cronRepository, _scheduler, watcherRepository, _watcherManager, log, options);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_scheduler.Start();
|
||||
_watcherManager.Start();
|
||||
_web.Start();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try { _web.Dispose(); }
|
||||
finally
|
||||
{
|
||||
try { _watcherManager.Dispose(); }
|
||||
finally
|
||||
{
|
||||
try { _scheduler.Dispose(); }
|
||||
finally { if (_ownedResource != null) _ownedResource.Dispose(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
FileinterfaceCronScheduler/Scheduler/WatcherJobConfig.cs
Normal file
56
FileinterfaceCronScheduler/Scheduler/WatcherJobConfig.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class WatcherJobConfig
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string SourceDirectory { get; set; }
|
||||
public string SearchPattern { get; set; }
|
||||
public bool IncludeSubdirectories { get; set; }
|
||||
|
||||
public bool WatchCreated { get; set; }
|
||||
public bool WatchChanged { get; set; }
|
||||
public bool WatchRenamed { get; set; }
|
||||
public int DebounceMilliseconds { get; set; }
|
||||
public int FileReadyRetries { get; set; }
|
||||
public int FileReadyDelayMilliseconds { get; set; }
|
||||
|
||||
public bool RemoveEmptyLines { get; set; }
|
||||
public string JoinSeparator { get; set; }
|
||||
|
||||
public string CustomFunction { get; set; }
|
||||
public List<string> InArgs { get; set; }
|
||||
|
||||
public bool MoveFileAfterSuccess { get; set; }
|
||||
public string MoveTargetDirectory { get; set; }
|
||||
public bool OverwriteTargetFile { get; set; }
|
||||
|
||||
public WatcherJobConfig()
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N");
|
||||
Name = "New watcher";
|
||||
Enabled = true;
|
||||
SourceDirectory = "";
|
||||
SearchPattern = "*.*";
|
||||
IncludeSubdirectories = false;
|
||||
WatchCreated = true;
|
||||
WatchChanged = true;
|
||||
WatchRenamed = true;
|
||||
DebounceMilliseconds = 1000;
|
||||
FileReadyRetries = 10;
|
||||
FileReadyDelayMilliseconds = 250;
|
||||
RemoveEmptyLines = true;
|
||||
JoinSeparator = ";";
|
||||
CustomFunction = "";
|
||||
InArgs = new List<string>();
|
||||
MoveFileAfterSuccess = false;
|
||||
MoveTargetDirectory = "";
|
||||
OverwriteTargetFile = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
149
FileinterfaceCronScheduler/Scheduler/WatcherJobExecutor.cs
Normal file
149
FileinterfaceCronScheduler/Scheduler/WatcherJobExecutor.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class WatcherJobExecutor
|
||||
{
|
||||
private readonly ICustomFunctionInvoker _invoker;
|
||||
private readonly JobLog _log;
|
||||
|
||||
public WatcherJobExecutor(ICustomFunctionInvoker invoker, JobLog log)
|
||||
{
|
||||
_invoker = invoker;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void ExecuteFile(WatcherJobConfig job, string file, string reason)
|
||||
{
|
||||
if (job == null) throw new ArgumentNullException("job");
|
||||
if (String.IsNullOrWhiteSpace(file)) return;
|
||||
if (!WaitUntilReadable(file, job.FileReadyRetries, job.FileReadyDelayMilliseconds))
|
||||
{
|
||||
_log.Error(job, "File is not readable after retries: " + file);
|
||||
return;
|
||||
}
|
||||
|
||||
string rawContent;
|
||||
using (var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
using (var reader = new StreamReader(stream, Encoding.UTF8, true))
|
||||
rawContent = reader.ReadToEnd();
|
||||
|
||||
string processedContent = ProcessContent(rawContent, job.RemoveEmptyLines, job.JoinSeparator);
|
||||
string[] inArgs = (job.InArgs ?? new List<string>())
|
||||
.Select(arg => Expand(arg, job, file, rawContent, processedContent, reason))
|
||||
.ToArray();
|
||||
|
||||
_log.Info(job, "Calling CustomFunction '" + job.CustomFunction + "' with " + inArgs.Length + " inArg(s) for " + Path.GetFileName(file) + " (" + reason + ").");
|
||||
CustomFunctionCallResult result = _invoker.Invoke(job.CustomFunction, inArgs);
|
||||
if (result == null) result = CustomFunctionCallResult.Fail("Invoker returned null.");
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_log.Error(job, "CustomFunction failed: " + result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Info(job, "CustomFunction succeeded: " + result.Message);
|
||||
if (job.MoveFileAfterSuccess && File.Exists(file))
|
||||
{
|
||||
string movedTo = MoveProcessedFile(file, job.MoveTargetDirectory, job.OverwriteTargetFile);
|
||||
_log.Info(job, "Moved file to: " + movedTo);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool WaitUntilReadable(string file, int retries, int delayMs)
|
||||
{
|
||||
retries = Math.Max(2, retries);
|
||||
delayMs = Math.Max(10, delayMs);
|
||||
long previousLength = -1;
|
||||
DateTime previousWrite = DateTime.MinValue;
|
||||
int stableChecks = 0;
|
||||
|
||||
for (int i = 0; i < retries; i++)
|
||||
{
|
||||
if (!File.Exists(file)) return false;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
long length = info.Length;
|
||||
DateTime lastWrite = info.LastWriteTimeUtc;
|
||||
using (var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
if (stream.Length != length) length = stream.Length;
|
||||
}
|
||||
|
||||
if (length == previousLength && lastWrite == previousWrite)
|
||||
{
|
||||
stableChecks++;
|
||||
if (stableChecks >= 1) return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
stableChecks = 0;
|
||||
previousLength = length;
|
||||
previousWrite = lastWrite;
|
||||
}
|
||||
}
|
||||
catch (IOException) { stableChecks = 0; }
|
||||
catch (UnauthorizedAccessException) { stableChecks = 0; }
|
||||
|
||||
Thread.Sleep(delayMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ProcessContent(string text, bool removeEmptyLines, string separator)
|
||||
{
|
||||
if (!removeEmptyLines) return text ?? "";
|
||||
string[] lines = (text ?? "").Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
|
||||
return String.Join(separator ?? ";", lines.Where(l => !String.IsNullOrWhiteSpace(l)).Select(l => l.Trim()));
|
||||
}
|
||||
|
||||
private static string Expand(string template, WatcherJobConfig job, string file, string rawContent, string processedContent, string reason)
|
||||
{
|
||||
string value = template ?? "";
|
||||
string fileName = Path.GetFileName(file) ?? "";
|
||||
string extension = Path.GetExtension(file) ?? "";
|
||||
string directory = Path.GetDirectoryName(file) ?? "";
|
||||
return value
|
||||
.Replace("{{CONTENT}}", processedContent ?? "")
|
||||
.Replace("{{RAW_CONTENT}}", rawContent ?? "")
|
||||
.Replace("{{FILE_NAME}}", fileName)
|
||||
.Replace("{{FILE_PATH}}", file ?? "")
|
||||
.Replace("{{FILE_DIR}}", directory)
|
||||
.Replace("{{FILE_EXT}}", extension)
|
||||
.Replace("{{JOB_NAME}}", job.Name ?? "")
|
||||
.Replace("{{EVENT}}", reason ?? "")
|
||||
.Replace("{{NOW}}", DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffK"));
|
||||
}
|
||||
|
||||
private static string MoveProcessedFile(string source, string targetDirectory, bool overwrite)
|
||||
{
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
string target = Path.Combine(targetDirectory, Path.GetFileName(source));
|
||||
if (File.Exists(target))
|
||||
{
|
||||
if (overwrite) File.Delete(target);
|
||||
else
|
||||
{
|
||||
string name = Path.GetFileNameWithoutExtension(source);
|
||||
string ext = Path.GetExtension(source);
|
||||
target = Path.Combine(targetDirectory, name + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + ext);
|
||||
int i = 1;
|
||||
while (File.Exists(target))
|
||||
{
|
||||
target = Path.Combine(targetDirectory, name + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + "_" + i + ext);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
File.Move(source, target);
|
||||
return target;
|
||||
}
|
||||
}
|
||||
}
|
||||
109
FileinterfaceCronScheduler/Scheduler/WatcherJobRepository.cs
Normal file
109
FileinterfaceCronScheduler/Scheduler/WatcherJobRepository.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Web.Script.Serialization;
|
||||
|
||||
namespace FileInterface.Scheduler
|
||||
{
|
||||
public sealed class WatcherJobRepository
|
||||
{
|
||||
private readonly object _sync = new object();
|
||||
private readonly string _fileName;
|
||||
private readonly JavaScriptSerializer _serializer = new JavaScriptSerializer();
|
||||
private List<WatcherJobConfig> _jobs;
|
||||
|
||||
public WatcherJobRepository(string fileName)
|
||||
{
|
||||
_fileName = Path.GetFullPath(fileName);
|
||||
_serializer.MaxJsonLength = Int32.MaxValue;
|
||||
_jobs = LoadInternal();
|
||||
}
|
||||
|
||||
public IList<WatcherJobConfig> GetAll()
|
||||
{
|
||||
lock (_sync) return Clone(_jobs);
|
||||
}
|
||||
|
||||
public WatcherJobConfig GetById(string id)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
WatcherJobConfig job = _jobs.FirstOrDefault(j => String.Equals(j.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
return job == null ? null : Clone(job);
|
||||
}
|
||||
}
|
||||
|
||||
public WatcherJobConfig Save(WatcherJobConfig job)
|
||||
{
|
||||
if (job == null) throw new ArgumentNullException("job");
|
||||
Validate(job);
|
||||
lock (_sync)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(job.Id)) job.Id = Guid.NewGuid().ToString("N");
|
||||
int index = _jobs.FindIndex(j => String.Equals(j.Id, job.Id, StringComparison.OrdinalIgnoreCase));
|
||||
if (index >= 0) _jobs[index] = Clone(job); else _jobs.Add(Clone(job));
|
||||
SaveInternal();
|
||||
return Clone(job);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Delete(string id)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
int count = _jobs.RemoveAll(j => String.Equals(j.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (count > 0) SaveInternal();
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
private List<WatcherJobConfig> LoadInternal()
|
||||
{
|
||||
if (!File.Exists(_fileName)) return new List<WatcherJobConfig>();
|
||||
string json = File.ReadAllText(_fileName, Encoding.UTF8);
|
||||
if (String.IsNullOrWhiteSpace(json)) return new List<WatcherJobConfig>();
|
||||
return _serializer.Deserialize<List<WatcherJobConfig>>(json) ?? new List<WatcherJobConfig>();
|
||||
}
|
||||
|
||||
private void SaveInternal()
|
||||
{
|
||||
string dir = Path.GetDirectoryName(_fileName);
|
||||
if (!String.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir);
|
||||
string json = _serializer.Serialize(_jobs);
|
||||
string tmp = _fileName + ".tmp";
|
||||
File.WriteAllText(tmp, json, new UTF8Encoding(false));
|
||||
if (File.Exists(_fileName))
|
||||
{
|
||||
string backup = _fileName + ".bak";
|
||||
try { File.Replace(tmp, _fileName, backup, true); }
|
||||
catch { File.Delete(_fileName); File.Move(tmp, _fileName); }
|
||||
}
|
||||
else File.Move(tmp, _fileName);
|
||||
}
|
||||
|
||||
private static void Validate(WatcherJobConfig job)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(job.Name)) throw new InvalidOperationException("Name is required.");
|
||||
if (String.IsNullOrWhiteSpace(job.SourceDirectory)) throw new InvalidOperationException("SourceDirectory is required.");
|
||||
if (String.IsNullOrWhiteSpace(job.CustomFunction)) throw new InvalidOperationException("CustomFunction is required.");
|
||||
if (!job.WatchCreated && !job.WatchChanged && !job.WatchRenamed)
|
||||
throw new InvalidOperationException("At least one watcher event (Created, Changed, Renamed) must be enabled.");
|
||||
if (job.MoveFileAfterSuccess && String.IsNullOrWhiteSpace(job.MoveTargetDirectory))
|
||||
throw new InvalidOperationException("MoveTargetDirectory is required when MoveFileAfterSuccess=true.");
|
||||
if (job.InArgs == null) job.InArgs = new List<string>();
|
||||
if (String.IsNullOrWhiteSpace(job.SearchPattern)) job.SearchPattern = "*.*";
|
||||
if (job.JoinSeparator == null) job.JoinSeparator = ";";
|
||||
if (job.DebounceMilliseconds < 0) job.DebounceMilliseconds = 0;
|
||||
if (job.FileReadyRetries < 1) job.FileReadyRetries = 1;
|
||||
if (job.FileReadyRetries > 1000) job.FileReadyRetries = 1000;
|
||||
if (job.FileReadyDelayMilliseconds < 10) job.FileReadyDelayMilliseconds = 10;
|
||||
}
|
||||
|
||||
private T Clone<T>(T value)
|
||||
{
|
||||
return _serializer.Deserialize<T>(_serializer.Serialize(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
278
FileinterfaceCronScheduler/Scheduler/WebAdminServer.cs
Normal file
278
FileinterfaceCronScheduler/Scheduler/WebAdminServer.cs
Normal 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=>({'&':'&','<':'<','>':'>'}[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>";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user