Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ namespace Cute.Lib.Contentful.CommandModels.Schedule
{
public class CuteSchedule : IContent
{
public static readonly string RUNNING = "running";
public static readonly string SUCCESS = "success";
public static readonly string ERROR = "error";

public SystemProperties Sys { get; set; } = default!;
public string Id => Sys != null ? Sys.Id : "";
public string Key { get; set; } = default!;
Expand Down
4 changes: 2 additions & 2 deletions source/Cute/Commands/BaseCommands/BaseServerCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public override ValidationResult Validate(CommandContext context, TSettings sett

private string HtmlHeader => $"""
<!DOCTYPE html>
<html lang="en">
<html lang="en" style="scroll-behavior: smooth">
<head>
<meta charset="utf-8">
<link rel="icon" type="image/x-icon" href="https://raw.githubusercontent.com/andresharpe/cute/master/docs/images/cute.png">
Expand All @@ -37,7 +37,7 @@ public override ValidationResult Validate(CommandContext context, TSettings sett
<script src="https://cdn.jsdelivr.net/gh/google/code-prettify@master/loader/run_prettify.js"></script>
{_prettifyColors}
</head>
<body>
<body style='max-width:60%'>
""";

private static string HtmlFooter => $"""
Expand Down
179 changes: 162 additions & 17 deletions source/Cute/Commands/Server/ServerSchedulerCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public override async Task<int> ExecuteCommandAsync(CommandContext context, Sett

public override void ConfigureWebApplication(WebApplication webApp)
{
webApp.MapPost("/reload", RefreshSchedule).DisableAntiforgery();
webApp.MapPost("/run", RunCommand).DisableAntiforgery();
}

public override async Task RenderHomePageBody(HttpContext context)
Expand All @@ -143,35 +143,86 @@ public override async Task RenderHomePageBody(HttpContext context)
foreach (var (key, cronEntry) in _scheduledEntries.Where(k => nextRuns.ContainsKey(k.Key)).OrderBy(kv => nextRuns[kv.Key]))
{
var entry = cronEntry;
var runningEntry = GetRelatedRunningEntry(entry);

while (entry is not null)
{
await RenderHomePageTableLines(context, entry, nextRuns[key]);
await RenderHomePageTableLines(context, entry, cronEntry, runningEntry, nextRuns[key]);
entry = entry.RunNext;
}
}

await context.Response.WriteAsync($"</table>");
await context.Response.WriteAsync($"<form action='{_baseUrl}/reload' method='POST' enctype='multipart/form-data'>");
await context.Response.WriteAsync($"<form action='{_baseUrl}/run' method='POST' enctype='multipart/form-data'>");
await context.Response.WriteAsync($"<input type='hidden' name='command' value='reload'>");
await context.Response.WriteAsync($"<button type='submit' style='width:100%'>Reload schedule from Contentful</button>");
await context.Response.WriteAsync($"</form>");

await context.Response.WriteAsync($"<form action='{_baseUrl}/run' method='POST' enctype='multipart/form-data'>");
await context.Response.WriteAsync($"<input type='hidden' name='command' value='resume_chain'>");
await context.Response.WriteAsync($"<button type='submit' style='width:100%'>Resume chains</button>");
await context.Response.WriteAsync($"</form>");
await context.Response.WriteAsync($"<script>");
await context.Response.WriteAsync($"function toggleMenu(btn) {{");
await context.Response.WriteAsync($" var menu = btn.nextElementSibling;");
await context.Response.WriteAsync($" var allMenus = document.querySelectorAll('.context-menu');");
await context.Response.WriteAsync($" allMenus.forEach(function(m) {{ if (m !== menu) m.style.display = 'none'; }});");
await context.Response.WriteAsync($" menu.style.display = menu.style.display === 'none' ? 'block' : 'none';");
await context.Response.WriteAsync($"}}");
await context.Response.WriteAsync($"document.addEventListener('click', function(e) {{");
await context.Response.WriteAsync($" if (!e.target.matches('button')) {{");
await context.Response.WriteAsync($" var menus = document.querySelectorAll('.context-menu');");
await context.Response.WriteAsync($" menus.forEach(function(m) {{ m.style.display = 'none'; }});");
await context.Response.WriteAsync($" }}");
await context.Response.WriteAsync($"}});");
await context.Response.WriteAsync($"</script>");
}

private static async Task RenderHomePageTableLines(HttpContext context, ScheduledEntry entry, DateTime nextRunTime)
private async Task RenderHomePageTableLines(HttpContext context, ScheduledEntry entry, ScheduledEntry parentEntry, ScheduledEntry? runningEntry, DateTime nextRunTime)
{
string? lastRunStarted = entry.LastRunStarted?.ToString("R");
string? lastRunFinished = entry.LastRunFinished?.ToString("R");
string? status = entry.LastRunStatus;
string? nextRun = nextRunTime.ToString("R");

string toRunningDiv = runningEntry == null ? string.Empty : $"<div><a href='#{runningEntry.Key}'>to running</a></div>";
string runningStyle = entry.LastRunStatus == ScheduledEntry.RUNNING ? "style='font-weight:bold;color: green'" : string.Empty;
string parentStyle = entry.Key == parentEntry.Key ? "style='font-weight:bold; font-size:22px'" : string.Empty;

string? cronExpression = entry.IsRunAfter ? null : entry.Schedule?.ToCronExpression().ToString();
var schedule = entry.IsRunAfter ? "Run after " + entry.RunAfter!.Key : entry.Schedule;

await context.Response.WriteAsync($"<tr>");
await context.Response.WriteAsync($"<td>{entry.Key}</td>");
await context.Response.WriteAsync($"<td id='{entry.Key}' style='position:relative'><span {runningStyle} {parentStyle}>{entry.Key}</span>");
await context.Response.WriteAsync($"<div style='position:absolute;top:5px;left:5px'>");
await context.Response.WriteAsync($"<button type='button' onclick='toggleMenu(this)' style='cursor:pointer;position:relative;top:5px;left:5px'>▼</button>");
await context.Response.WriteAsync($"<div class='context-menu' style='display:none;background:white;border:1px solid #ccc;box-shadow:0 2px 5px rgba(0,0,0,0.2);z-index:1000;min-width:120px;width:auto'>");

await context.Response.WriteAsync($"<form id='single_run_{entry.Key}' action='{_baseUrl}/run' method='POST' enctype='multipart/form-data'>");
await context.Response.WriteAsync($"<input type='hidden' name='command' value='run_single'>");
await context.Response.WriteAsync($"<input type='hidden' name='param' value='{entry.Id}'>");
await context.Response.WriteAsync($"<a href='javascript:{{}}' onclick=\"document.getElementById('single_run_{entry.Key}').submit();\" style='display:block;padding:8px 12px;text-decoration:none;color:black;' onmouseover='this.style.background=\"#f0f0f0\"' onmouseout='this.style.background=\"white\"' title='Run this job now, ignore chain'>▶ Run Solo</a>");
await context.Response.WriteAsync($"</form>");

await context.Response.WriteAsync($"<form id='run_{entry.Key}' action='{_baseUrl}/run' method='POST' enctype='multipart/form-data'>");
await context.Response.WriteAsync($"<input type='hidden' name='command' value='run_chain'>");
await context.Response.WriteAsync($"<input type='hidden' name='param' value='{entry.Id}'>");
await context.Response.WriteAsync($"<a href='javascript:{{}}' onclick=\"document.getElementById('run_{entry.Key}').submit();\" style='display:block;padding:8px 12px;text-decoration:none;color:black;' onmouseover='this.style.background=\"#f0f0f0\"' onmouseout='this.style.background=\"white\"' title='Run from here through end of chain'>▶▶ Run Chain</a>");
await context.Response.WriteAsync($"</form>");

if (parentEntry.Key != entry.Key)
{
await context.Response.WriteAsync($"<a href='#{parentEntry.Key}' style='display:block;padding:8px 12px;text-decoration:none;color:black;' onmouseover='this.style.background=\"#f0f0f0\"' onmouseout='this.style.background=\"white\"' title='Go to chain start'>↑ Parent</a>");
}
if(runningEntry != null && runningEntry.Key != entry.Key)
{
await context.Response.WriteAsync($"<a href='#{runningEntry.Key}' style='display:block;padding:8px 12px;text-decoration:none;color:black;' onmouseover='this.style.background=\"#f0f0f0\"' onmouseout='this.style.background=\"white\"' title='Go to running job'>↗ Active</a>");
}
await context.Response.WriteAsync($"</div>");
await context.Response.WriteAsync($"</div>");
await context.Response.WriteAsync($"</td>");
await context.Response.WriteAsync($"<td>{schedule}</td>");
await context.Response.WriteAsync($"<td>{cronExpression}</td>");
await context.Response.WriteAsync($"<td><div>{cronExpression}</div></td>");
await context.Response.WriteAsync($"<td>");

if (lastRunStarted is not null)
Expand Down Expand Up @@ -369,17 +420,106 @@ private static void EnsureNewScheduler(ILogger<Scheduler> cronLogger)
}
}

private void RefreshSchedule([FromForm] string command, HttpContext context)
private void RunCommand([FromForm] string command, [FromForm] string? param, HttpContext context)
{
if (!command.Equals("reload")) return;
ScheduledEntry? entry = null;
switch (command)
{
case "run_single":
entry = GetScheduleByKey(param!);
if (entry != null && entry.LastRunStatus != ScheduledEntry.RUNNING)
{
_ = Task.Run(() => ProcessContentSyncApi(entry, true));
}
break;
case "run_chain":
entry = GetScheduleByKey(param!);
if (entry != null && GetRelatedRunningEntry(entry!) == null)
{
_ = Task.Run(() => ProcessContentSyncApi(entry));
}
break;
case "reload":
EnsureNewScheduler(_cronLogger);
UpdateScheduler();
_scheduler.Start();
break;
case "resume_chain":
ResumeBrokenChains();
break;
default:
break;
}

EnsureNewScheduler(_cronLogger);
context.Response.Redirect($"{_baseUrl}/");
}

UpdateScheduler();
private void ResumeBrokenChains()
{
var nextRuns = _scheduler.GetNextOccurrences()
.SelectMany(i => i.ScheduledTasks, (i, j) => new { j.Id, i.NextOccurrence })
.ToDictionary(o => o.Id, o => o.NextOccurrence);

_scheduler.Start();
foreach (var (key, cronEntry) in _scheduledEntries.Where(k => nextRuns.ContainsKey(k.Key)).OrderBy(kv => nextRuns[kv.Key]))
{
var entry = cronEntry;

context.Response.Redirect($"{_baseUrl}/");
while (entry is not null)
{
//await RenderHomePageTableLines(context, entry, nextRuns[key]);
entry = entry.RunNext;
}
}

foreach (var nextRun in nextRuns)
{
var entry = _scheduledEntries[nextRun.Key];
var baseStartDate = entry.LastRunStarted;

while(entry != null && entry.LastRunStarted >= baseStartDate)
{
if (entry.LastRunStatus == CuteSchedule.RUNNING)
{
entry = null;
break;
}
entry = entry.RunNext;
}

if(entry != null)
{
_ = Task.Run(() => ProcessContentSyncApi(entry));
}
}
}

private ScheduledEntry? GetRelatedRunningEntry(ScheduledEntry entry)
{
string parentKey;
if (entry.RunAfter == null)
{
parentKey = entry.Key;
}
else
{
var parentSchedule = entry.RunAfter;
while (parentSchedule.RunAfter != null)
{
parentSchedule = parentSchedule.RunAfter;
}
parentKey = parentSchedule.Key;
}

var scheduledEntry = _scheduledEntries.Where(k => k.Value.Key == parentKey).FirstOrDefault().Value;

if (scheduledEntry.LastRunStatus == CuteSchedule.RUNNING) return scheduledEntry;
while(scheduledEntry.RunNext != null)
{
scheduledEntry = scheduledEntry.RunNext;
if (scheduledEntry.LastRunStatus == CuteSchedule.RUNNING) return scheduledEntry;
}

return null;
}

private async Task ProcessAndUpdateSchedule(ScheduledEntry entry)
Expand All @@ -402,19 +542,19 @@ private void DisplaySchedule()
}
}

private async Task ProcessContentSyncApi(ScheduledEntry CuteSchedule)
private async Task ProcessContentSyncApi(ScheduledEntry cuteSchedule, bool singleRun = false)
{
string verbosity = _settings?.Verbosity.ToString() ?? Verbosity.Normal.ToString();

var entry = CuteSchedule;
var entry = cuteSchedule;

while (entry is not null)
{
_console.WriteNormal("Started content sync-api for '{syncApiKey}'", entry.Key);

entry.LastRunStarted = DateTime.UtcNow;
entry.LastRunFinished = null;
entry.LastRunStatus = "running";
entry.LastRunStatus = CuteSchedule.RUNNING;
entry.LastRunErrorMessage = string.Empty;

try
Expand Down Expand Up @@ -445,13 +585,13 @@ private async Task ProcessContentSyncApi(ScheduledEntry CuteSchedule)

await command.RunAsync(args);

entry.LastRunStatus = "success";
entry.LastRunStatus = CuteSchedule.SUCCESS;
entry.LastRunFinished = DateTime.UtcNow;
}
catch (Exception ex)
{
_console.WriteException(ex);
entry.LastRunStatus = $"error";
entry.LastRunStatus = CuteSchedule.RUNNING;
entry.LastRunErrorMessage = $"Exception: {ex.Message} \nTrace: {ex.StackTrace}";
entry.LastRunFinished = DateTime.UtcNow;
}
Expand All @@ -461,6 +601,11 @@ private async Task ProcessContentSyncApi(ScheduledEntry CuteSchedule)
await UpdateScheduleEntry(entry);
}

if (singleRun)
{
break;
}

entry = entry.RunNext;
}
}
Expand Down