Cronjobs + AnthropicService Singleton Refactoring
Cronjobs: - CronjobService (BackgroundService) prüft jede Minute auf fällige Jobs - Cron-Expression in Server-Lokalzeit via Cronos (CronFormat.Standard) - Cronjob-Ausführung: Prompt → Session → Agent-Antwort in Chat-History - Optionale Benachrichtigungs-Session (notify_session_id): Zusammenfassung wird in separate Session geschrieben (z.B. mit Telegram) - Telegram-Benachrichtigung mit Cronjob-Name + Ursprungs-Session - REST API: CRUD + toggle + manuelles Ausführen (/run) - Frontend: Dashboard-Cards, Cronjob-Formular mit Cron-Vorschau, Session-Dropdown, Notify-Session-Dropdown, Status-Box Refactoring: - AnthropicService als Singleton in DI registriert - ChatEndpoint + TelegramService nutzen injizierte Instanz - Redundante Dependencies (Token, ToolService, MemoryService) entfernt Neue Dateien: - Models/CronjobRecord.cs - Services/CronjobService.cs - Endpoints/CronjobEndpoint.cs - wwwroot/js/pages/cronjob-page.js - PLAN-Cronjob.md
This commit is contained in:
1 parent
0120bb8f73
commit
b645625b56
16 files changed
+1132
-28
No files matched your search
@@ -1,7 +1,5 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -22,7 +20,7 @@ public static class ChatEndpoint
|
|||||||
app.MapPost("/api/chat", HandleChat);
|
app.MapPost("/api/chat", HandleChat);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, MemoryService memoryService, ToolService toolService, CancellationToken cancellationToken)
|
private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
|
var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
|
||||||
if (body == null || body.Messages == null || body.Messages.Count == 0)
|
if (body == null || body.Messages == null || body.Messages.Count == 0)
|
||||||
@@ -32,14 +30,6 @@ public static class ChatEndpoint
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var token = config["AnthropicToken"];
|
|
||||||
if (string.IsNullOrEmpty(token))
|
|
||||||
{
|
|
||||||
res.StatusCode = 500;
|
|
||||||
await res.WriteAsJsonAsync(new { error = "AnthropicToken not configured" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionId = body.SessionId;
|
var sessionId = body.SessionId;
|
||||||
if (string.IsNullOrEmpty(sessionId))
|
if (string.IsNullOrEmpty(sessionId))
|
||||||
{
|
{
|
||||||
@@ -111,7 +101,6 @@ public static class ChatEndpoint
|
|||||||
}, attachmentRecords);
|
}, attachmentRecords);
|
||||||
}
|
}
|
||||||
|
|
||||||
var anthropic = new AnthropicService(httpFactory, token, toolService, db, memoryService);
|
|
||||||
streamingTracker.Start(sessionId, ctx.ChainId);
|
streamingTracker.Start(sessionId, ctx.ChainId);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Gott.Models;
|
||||||
|
using Gott.Services;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace Gott.Endpoints;
|
||||||
|
|
||||||
|
public static class CronjobEndpoint
|
||||||
|
{
|
||||||
|
public static void MapCronjobEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/cronjobs", async (HttpContext ctx, DatabaseService db) =>
|
||||||
|
{
|
||||||
|
var userId = ctx.Items["UserId"]?.ToString();
|
||||||
|
var cronjobs = await db.GetCronjobs(userId);
|
||||||
|
return Results.Ok(cronjobs);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/cronjobs/{id}", async (string id, HttpContext ctx, DatabaseService db) =>
|
||||||
|
{
|
||||||
|
var userId = ctx.Items["UserId"]?.ToString();
|
||||||
|
var cronjob = await db.GetCronjob(id);
|
||||||
|
if (cronjob == null || cronjob.UserId != userId) return Results.NotFound();
|
||||||
|
return Results.Ok(cronjob);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/cronjobs", async (HttpContext ctx, DatabaseService db) =>
|
||||||
|
{
|
||||||
|
var userId = ctx.Items["UserId"]?.ToString();
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||||||
|
|
||||||
|
var name = body.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||||
|
var sessionId = body.TryGetProperty("sessionId", out var s) ? s.GetString() ?? "" : "";
|
||||||
|
var cronExpression = body.TryGetProperty("cronExpression", out var c) ? c.GetString() ?? "" : "";
|
||||||
|
var prompt = body.TryGetProperty("prompt", out var p) ? p.GetString() ?? "" : "";
|
||||||
|
var notifySessionId = body.TryGetProperty("notifySessionId", out var ns) ? ns.GetString() ?? "" : "";
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(sessionId) ||
|
||||||
|
string.IsNullOrWhiteSpace(cronExpression) || string.IsNullOrWhiteSpace(prompt))
|
||||||
|
return Results.BadRequest(new { error = "Name, Session, Cron-Expression und Prompt sind Pflichtfelder" });
|
||||||
|
|
||||||
|
// Cron-Expression validieren
|
||||||
|
var nextRun = CronjobService.CalculateNextRun(cronExpression);
|
||||||
|
if (string.IsNullOrEmpty(nextRun))
|
||||||
|
return Results.BadRequest(new { error = "Ungültige Cron-Expression" });
|
||||||
|
|
||||||
|
// Session-Zugehörigkeit prüfen
|
||||||
|
var session = await db.GetSession(sessionId);
|
||||||
|
if (session == null || session.UserId != userId)
|
||||||
|
return Results.BadRequest(new { error = "Session nicht gefunden" });
|
||||||
|
|
||||||
|
// Notify-Session prüfen (optional)
|
||||||
|
if (!string.IsNullOrEmpty(notifySessionId))
|
||||||
|
{
|
||||||
|
var notifySession = await db.GetSession(notifySessionId);
|
||||||
|
if (notifySession == null || notifySession.UserId != userId)
|
||||||
|
return Results.BadRequest(new { error = "Benachrichtigungs-Session nicht gefunden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var cronjob = new CronjobRecord
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
SessionId = sessionId,
|
||||||
|
Name = name,
|
||||||
|
CronExpression = cronExpression,
|
||||||
|
Prompt = prompt,
|
||||||
|
NotifySessionId = notifySessionId,
|
||||||
|
IsEnabled = true,
|
||||||
|
NextRunAt = nextRun
|
||||||
|
};
|
||||||
|
|
||||||
|
var created = await db.CreateCronjob(cronjob);
|
||||||
|
return Results.Ok(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPut("/api/cronjobs/{id}", async (string id, HttpContext ctx, DatabaseService db) =>
|
||||||
|
{
|
||||||
|
var userId = ctx.Items["UserId"]?.ToString();
|
||||||
|
var existing = await db.GetCronjob(id);
|
||||||
|
if (existing == null || existing.UserId != userId) return Results.NotFound();
|
||||||
|
|
||||||
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||||||
|
|
||||||
|
if (body.TryGetProperty("name", out var n)) existing.Name = n.GetString() ?? existing.Name;
|
||||||
|
if (body.TryGetProperty("sessionId", out var s))
|
||||||
|
{
|
||||||
|
var newSessionId = s.GetString() ?? "";
|
||||||
|
if (!string.IsNullOrEmpty(newSessionId))
|
||||||
|
{
|
||||||
|
var session = await db.GetSession(newSessionId);
|
||||||
|
if (session == null || session.UserId != userId)
|
||||||
|
return Results.BadRequest(new { error = "Session nicht gefunden" });
|
||||||
|
existing.SessionId = newSessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.TryGetProperty("cronExpression", out var c))
|
||||||
|
{
|
||||||
|
var expr = c.GetString() ?? "";
|
||||||
|
if (!string.IsNullOrEmpty(expr))
|
||||||
|
{
|
||||||
|
var nextRun = CronjobService.CalculateNextRun(expr);
|
||||||
|
if (string.IsNullOrEmpty(nextRun))
|
||||||
|
return Results.BadRequest(new { error = "Ungültige Cron-Expression" });
|
||||||
|
existing.CronExpression = expr;
|
||||||
|
existing.NextRunAt = nextRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.TryGetProperty("prompt", out var p)) existing.Prompt = p.GetString() ?? existing.Prompt;
|
||||||
|
if (body.TryGetProperty("notifySessionId", out var ns))
|
||||||
|
{
|
||||||
|
var notifyId = ns.GetString() ?? "";
|
||||||
|
if (!string.IsNullOrEmpty(notifyId))
|
||||||
|
{
|
||||||
|
var notifySession = await db.GetSession(notifyId);
|
||||||
|
if (notifySession == null || notifySession.UserId != userId)
|
||||||
|
return Results.BadRequest(new { error = "Benachrichtigungs-Session nicht gefunden" });
|
||||||
|
}
|
||||||
|
existing.NotifySessionId = notifyId;
|
||||||
|
}
|
||||||
|
if (body.TryGetProperty("isEnabled", out var e))
|
||||||
|
{
|
||||||
|
existing.IsEnabled = e.GetBoolean();
|
||||||
|
if (existing.IsEnabled)
|
||||||
|
existing.NextRunAt = CronjobService.CalculateNextRun(existing.CronExpression);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.UpdateCronjob(existing);
|
||||||
|
return Results.Ok(existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapDelete("/api/cronjobs/{id}", async (string id, HttpContext ctx, DatabaseService db) =>
|
||||||
|
{
|
||||||
|
var userId = ctx.Items["UserId"]?.ToString();
|
||||||
|
var existing = await db.GetCronjob(id);
|
||||||
|
if (existing == null || existing.UserId != userId) return Results.NotFound();
|
||||||
|
|
||||||
|
await db.DeleteCronjob(id, userId);
|
||||||
|
return Results.Ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/cronjobs/{id}/toggle", async (string id, HttpContext ctx, DatabaseService db) =>
|
||||||
|
{
|
||||||
|
var userId = ctx.Items["UserId"]?.ToString();
|
||||||
|
var existing = await db.GetCronjob(id);
|
||||||
|
if (existing == null || existing.UserId != userId) return Results.NotFound();
|
||||||
|
|
||||||
|
existing.IsEnabled = !existing.IsEnabled;
|
||||||
|
if (existing.IsEnabled)
|
||||||
|
existing.NextRunAt = CronjobService.CalculateNextRun(existing.CronExpression);
|
||||||
|
|
||||||
|
await db.UpdateCronjob(existing);
|
||||||
|
return Results.Ok(existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/cronjobs/{id}/run", async (string id, HttpContext ctx, DatabaseService db, CronjobService cronjobService) =>
|
||||||
|
{
|
||||||
|
var userId = ctx.Items["UserId"]?.ToString();
|
||||||
|
var existing = await db.GetCronjob(id);
|
||||||
|
if (existing == null || existing.UserId != userId) return Results.NotFound();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await cronjobService.ExecuteCronjob(existing);
|
||||||
|
|
||||||
|
var nextRun = CronjobService.CalculateNextRun(existing.CronExpression);
|
||||||
|
await db.UpdateCronjobRun(existing.CronjobId, "success", null, nextRun);
|
||||||
|
|
||||||
|
return Results.Ok(new { status = "success" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var nextRun = CronjobService.CalculateNextRun(existing.CronExpression);
|
||||||
|
await db.UpdateCronjobRun(existing.CronjobId, "error", ex.Message, nextRun);
|
||||||
|
|
||||||
|
return Results.Ok(new { status = "error", error = ex.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Gott.Models;
|
||||||
|
|
||||||
|
public class CronjobRecord
|
||||||
|
{
|
||||||
|
public string CronjobId { get; set; } = "";
|
||||||
|
public string UserId { get; set; } = "";
|
||||||
|
public string SessionId { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string CronExpression { get; set; } = "";
|
||||||
|
public string Prompt { get; set; } = "";
|
||||||
|
public string NotifySessionId { get; set; } = "";
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public string LastRunAt { get; set; } = "";
|
||||||
|
public string NextRunAt { get; set; } = "";
|
||||||
|
public string LastRunStatus { get; set; } = "";
|
||||||
|
public string LastRunError { get; set; } = "";
|
||||||
|
public string CreatedAt { get; set; } = "";
|
||||||
|
public string UpdatedAt { get; set; } = "";
|
||||||
|
|
||||||
|
// Joined from sessions table
|
||||||
|
public string SessionName { get; set; } = "";
|
||||||
|
public string NotifySessionName { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
## Cronjobs – Planungsentwurf
|
||||||
|
|
||||||
|
### Konzept: Eigener Worker/Service in der App
|
||||||
|
|
||||||
|
**Warum nicht Linux-Cron?**
|
||||||
|
- App ist self-contained, soll ohne externe Abhängigkeiten laufen
|
||||||
|
- Cronjobs brauchen Zugriff auf DB, Agents, Sessions, Anthropic API
|
||||||
|
- Verwaltung über das Frontend (Dashboard) wäre mit Linux-Cron extrem umständlich
|
||||||
|
- Multi-User: Jeder User hat eigene Cronjobs
|
||||||
|
|
||||||
|
### Architektur
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
|
||||||
|
1. **`CronjobRecord`** (Model) – DB-Tabelle `cronjobs`:
|
||||||
|
- `cronjob_id` (TEXT, GUID)
|
||||||
|
- `user_id` (TEXT)
|
||||||
|
- `session_id` (TEXT) – in welcher Session soll der Prompt gesendet werden
|
||||||
|
- `name` (TEXT) – z.B. "Täglicher Standup-Report"
|
||||||
|
- `cron_expression` (TEXT) – Standard Cron-Format: `0 9 * * *` (jeden Tag um 9:00)
|
||||||
|
- `prompt` (TEXT) – die Nachricht die an Claude gesendet wird
|
||||||
|
- `is_enabled` (BOOL) – aktiv/inaktiv
|
||||||
|
- `last_run_at` (TEXT) – wann zuletzt ausgeführt
|
||||||
|
- `next_run_at` (TEXT) – nächste geplante Ausführung (vorberechnet)
|
||||||
|
- `last_run_status` (TEXT) – "success" / "error"
|
||||||
|
- `last_run_error` (TEXT) – Fehlermeldung falls error
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
|
2. **`CronjobService`** (IHostedService / BackgroundService):
|
||||||
|
- Startet mit der App als `AddHostedService<CronjobService>()`
|
||||||
|
- Timer prüft jede Minute: "Gibt es Cronjobs deren `next_run_at` <= jetzt?"
|
||||||
|
- Wenn ja: Prompt an die Session senden (wie ein normaler Chat-Request, aber ohne SSE – fire & forget, Result in DB speichern)
|
||||||
|
- Nach Ausführung: `last_run_at` updaten, `next_run_at` neu berechnen
|
||||||
|
|
||||||
|
3. **`CronjobEndpoint`** (REST API):
|
||||||
|
- `GET /api/cronjobs` – alle Cronjobs des Users
|
||||||
|
- `POST /api/cronjobs` – neuen Cronjob anlegen
|
||||||
|
- `PUT /api/cronjobs/{id}` – bearbeiten
|
||||||
|
- `DELETE /api/cronjobs/{id}` – löschen
|
||||||
|
- `POST /api/cronjobs/{id}/toggle` – aktivieren/deaktivieren
|
||||||
|
- `POST /api/cronjobs/{id}/run` – manuell sofort ausführen (zum Testen)
|
||||||
|
|
||||||
|
4. **Cron-Expression Parser**: Nutzen wir `Cronos` NuGet-Package (leichtgewichtig, kein Overhead) oder schreiben wir einen eigenen einfachen Parser?
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
|
||||||
|
5. **Dashboard**: Neue Section "Cronjobs" mit Cards (ähnlich Sessions/Agents)
|
||||||
|
6. **Cronjob-Formular** (neue Page `tpl-cronjob`):
|
||||||
|
- Name
|
||||||
|
- Session (Dropdown)
|
||||||
|
- Cron-Expression (Input + Vorschau "Nächste Ausführung: ...")
|
||||||
|
- Prompt (Textarea)
|
||||||
|
- Aktiv/Inaktiv Toggle
|
||||||
|
7. **Cronjob-Card**: Name, Session-Name, Cron-Expression human-readable, Status (aktiv/inaktiv), letzte Ausführung
|
||||||
|
|
||||||
|
### Offene Fragen an dich:
|
||||||
|
|
||||||
|
1. **Cron-Expression oder vereinfacht?** Soll der User echte Cron-Expressions eingeben (`0 9 * * 1-5`) oder lieber ein vereinfachtes UI (Dropdown: "Täglich um X", "Wöchentlich am Y um Z", etc.)?
|
||||||
|
|
||||||
|
2. **Ausführung**: Soll der Cronjob die Antwort von Claude still in die Session-History schreiben (so als hätte der User die Nachricht geschrieben)? Oder soll es einen separaten "Cronjob-Log" geben?
|
||||||
|
|
||||||
|
3. **NuGet `Cronos`** für Cron-Parsing verwenden? (Ist der De-facto-Standard in .NET, ~50KB, keine Abhängigkeiten)
|
||||||
|
|
||||||
|
4. **Benachrichtigung**: Soll der User benachrichtigt werden wenn ein Cronjob ausgeführt wurde? (z.B. über die StatusBar, oder Telegram?)
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
Antworten auf die offenen Fragen:
|
||||||
|
|
||||||
|
1. Cron-Expression
|
||||||
|
2. Einfach direkt in die Session-History schreiben (als hätte der User die Nachricht geschrieben)
|
||||||
|
3. Cronos NuGet passt
|
||||||
|
4. Telegram, falls in der Session, in der der Cronjob läuft, Telegram eingerichtet und verbunden ist
|
||||||
+14
-3
@@ -33,16 +33,26 @@ builder.Services.AddSingleton(toolService);
|
|||||||
var chatService = new ChatService(dbService, toolService, memoryService, skillService);
|
var chatService = new ChatService(dbService, toolService, memoryService, skillService);
|
||||||
builder.Services.AddSingleton(chatService);
|
builder.Services.AddSingleton(chatService);
|
||||||
|
|
||||||
// Telegram Bot (pro Session – Token aus Session-Config)
|
// AnthropicService (Singleton – shared zwischen ChatEndpoint und TelegramService)
|
||||||
var anthropicToken = builder.Configuration["AnthropicToken"] ?? "";
|
var anthropicToken = builder.Configuration["AnthropicToken"] ?? "";
|
||||||
|
builder.Services.AddSingleton<AnthropicService>(sp =>
|
||||||
|
new AnthropicService(
|
||||||
|
sp.GetRequiredService<IHttpClientFactory>(),
|
||||||
|
anthropicToken, toolService, dbService, memoryService));
|
||||||
|
|
||||||
|
// Telegram Bot (pro Session – Token aus Session-Config)
|
||||||
var maxIterations = builder.Configuration.GetValue<int>("MaxIterations", 100);
|
var maxIterations = builder.Configuration.GetValue<int>("MaxIterations", 100);
|
||||||
builder.Services.AddSingleton<TelegramService>(sp =>
|
builder.Services.AddSingleton<TelegramService>(sp =>
|
||||||
new TelegramService(
|
new TelegramService(
|
||||||
anthropicToken,
|
|
||||||
sp.GetRequiredService<IHttpClientFactory>(),
|
sp.GetRequiredService<IHttpClientFactory>(),
|
||||||
dbService, toolService, memoryService, chatService,
|
dbService, chatService,
|
||||||
|
sp.GetRequiredService<AnthropicService>(),
|
||||||
maxIterations));
|
maxIterations));
|
||||||
|
|
||||||
|
// Cronjob-Service (BackgroundService)
|
||||||
|
builder.Services.AddSingleton<CronjobService>();
|
||||||
|
builder.Services.AddHostedService<CronjobService>(sp => sp.GetRequiredService<CronjobService>());
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
@@ -60,5 +70,6 @@ app.MapSkillEndpoints();
|
|||||||
app.MapProfileEndpoints();
|
app.MapProfileEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
app.MapSettingsEndpoints();
|
||||||
app.MapTelegramEndpoints();
|
app.MapTelegramEndpoints();
|
||||||
|
app.MapCronjobEndpoints();
|
||||||
|
|
||||||
app.Run("http://0.0.0.0:19000");
|
app.Run("http://0.0.0.0:19000");
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Cronos;
|
||||||
|
using Gott.Models;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Gott.Services;
|
||||||
|
|
||||||
|
public class CronjobService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly DatabaseService _db;
|
||||||
|
private readonly ChatService _chatService;
|
||||||
|
private readonly AnthropicService _anthropicService;
|
||||||
|
private readonly TelegramService _telegramService;
|
||||||
|
private readonly ILogger<CronjobService> _logger;
|
||||||
|
|
||||||
|
public CronjobService(
|
||||||
|
DatabaseService db,
|
||||||
|
ChatService chatService,
|
||||||
|
AnthropicService anthropicService,
|
||||||
|
TelegramService telegramService,
|
||||||
|
ILogger<CronjobService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_chatService = chatService;
|
||||||
|
_anthropicService = anthropicService;
|
||||||
|
_telegramService = telegramService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("CronjobService gestartet");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessDueCronjobs();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Fehler beim Verarbeiten der Cronjobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jede Minute prüfen
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessDueCronjobs()
|
||||||
|
{
|
||||||
|
var dueJobs = await _db.GetDueCronjobs();
|
||||||
|
|
||||||
|
foreach (var job in dueJobs)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Cronjob '{Name}' ({Id}) wird ausgeführt", job.Name, job.CronjobId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ExecuteCronjob(job);
|
||||||
|
|
||||||
|
var nextRun = CalculateNextRun(job.CronExpression);
|
||||||
|
await _db.UpdateCronjobRun(job.CronjobId, "success", null, nextRun);
|
||||||
|
|
||||||
|
_logger.LogInformation("Cronjob '{Name}' erfolgreich. Nächste Ausführung: {Next}", job.Name, nextRun);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Cronjob '{Name}' fehlgeschlagen", job.Name);
|
||||||
|
|
||||||
|
var nextRun = CalculateNextRun(job.CronExpression);
|
||||||
|
await _db.UpdateCronjobRun(job.CronjobId, "error", ex.Message, nextRun);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteCronjob(CronjobRecord job)
|
||||||
|
{
|
||||||
|
// Chat-Context vorbereiten (wie bei normalem Chat)
|
||||||
|
var ctx = await _chatService.PrepareContext(job.SessionId, job.UserId);
|
||||||
|
if (ctx == null)
|
||||||
|
throw new Exception("Session oder Agent nicht gefunden / nicht konfiguriert");
|
||||||
|
|
||||||
|
// History aus DB laden
|
||||||
|
var history = await _chatService.BuildHistoryFromDb(job.SessionId);
|
||||||
|
|
||||||
|
// User-Nachricht hinzufügen
|
||||||
|
var userContent = new List<object> { new { type = "text", text = job.Prompt } };
|
||||||
|
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = userContent });
|
||||||
|
|
||||||
|
// User-Nachricht in DB speichern
|
||||||
|
await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = ctx.ChainId,
|
||||||
|
Iteration = 0,
|
||||||
|
Role = "user",
|
||||||
|
Type = "text",
|
||||||
|
Content = job.Prompt
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-Streaming Chat ausführen
|
||||||
|
var responseText = await _anthropicService.ChatNonStreaming(ctx, history);
|
||||||
|
|
||||||
|
// Session-Timestamp aktualisieren
|
||||||
|
await _db.UpdateSessionTimestamp(job.SessionId);
|
||||||
|
|
||||||
|
// Telegram-Benachrichtigung auf der Arbeits-Session senden, falls sie Telegram hat
|
||||||
|
if (!string.IsNullOrEmpty(responseText))
|
||||||
|
{
|
||||||
|
await _telegramService.SendNotification(job.SessionId,
|
||||||
|
$"⏰ Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{responseText}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benachrichtigungs-Session: Zusammenfassung in eine andere Session schreiben
|
||||||
|
if (!string.IsNullOrEmpty(job.NotifySessionId) && job.NotifySessionId != job.SessionId)
|
||||||
|
{
|
||||||
|
await SendCronjobNotification(job, responseText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendCronjobNotification(CronjobRecord job, string responseText)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var notifyCtx = await _chatService.PrepareContext(job.NotifySessionId, job.UserId);
|
||||||
|
if (notifyCtx == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Notification-Session {SessionId} für Cronjob '{Name}' nicht gefunden", job.NotifySessionId, job.Name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurze Zusammenfassung als User-Nachricht in die Notify-Session schreiben
|
||||||
|
var summary = $"[Cronjob \"{job.Name}\" wurde in Session \"{job.SessionName}\" ausgeführt]\n\nAntwort:\n{Truncate(responseText, 2000)}";
|
||||||
|
|
||||||
|
// In Notify-Session als Nachricht speichern
|
||||||
|
await _db.SaveMessage(new MessageRecord
|
||||||
|
{
|
||||||
|
ChainId = notifyCtx.ChainId,
|
||||||
|
Iteration = 0,
|
||||||
|
Role = "user",
|
||||||
|
Type = "text",
|
||||||
|
Content = summary
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nur die Benachrichtigung als History senden (nicht volle Session-History,
|
||||||
|
// da diese mit einer assistant-Nachricht enden könnte → prefill-Fehler)
|
||||||
|
var notifyHistory = new List<object>
|
||||||
|
{
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["role"] = "user",
|
||||||
|
["content"] = new List<object> { new { type = "text", text = summary } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var notifyResponse = await _anthropicService.ChatNonStreaming(notifyCtx, notifyHistory);
|
||||||
|
|
||||||
|
await _db.UpdateSessionTimestamp(job.NotifySessionId);
|
||||||
|
|
||||||
|
// Telegram auf Notify-Session senden
|
||||||
|
if (!string.IsNullOrEmpty(notifyResponse))
|
||||||
|
{
|
||||||
|
await _telegramService.SendNotification(job.NotifySessionId,
|
||||||
|
$"📋 Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{notifyResponse}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Fehler beim Senden der Cronjob-Benachrichtigung an Session {SessionId}", job.NotifySessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string text, int maxLength)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text ?? "";
|
||||||
|
return text.Substring(0, maxLength) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CalculateNextRun(string cronExpression)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cron = CronExpression.Parse(cronExpression, CronFormat.Standard);
|
||||||
|
// Cron-Expression in Server-Lokalzeit interpretieren
|
||||||
|
var next = cron.GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local);
|
||||||
|
return next?.UtcDateTime.ToString("o") ?? "";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -166,6 +166,7 @@ public class DatabaseService
|
|||||||
TryAddColumn(conn, "users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
TryAddColumn(conn, "users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||||
TryAddColumn(conn, "users", "persona", "TEXT");
|
TryAddColumn(conn, "users", "persona", "TEXT");
|
||||||
TryAddColumn(conn, "users", "settings", "TEXT");
|
TryAddColumn(conn, "users", "settings", "TEXT");
|
||||||
|
TryAddColumn(conn, "cronjobs", "notify_session_id", "TEXT");
|
||||||
|
|
||||||
conn.Execute(@"
|
conn.Execute(@"
|
||||||
CREATE TABLE IF NOT EXISTS agent_skills (
|
CREATE TABLE IF NOT EXISTS agent_skills (
|
||||||
@@ -205,6 +206,28 @@ public class DatabaseService
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_telegram_links_code ON telegram_links(link_code);
|
CREATE INDEX IF NOT EXISTS idx_telegram_links_code ON telegram_links(link_code);
|
||||||
");
|
");
|
||||||
|
|
||||||
|
conn.Execute(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS cronjobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cronjob_id TEXT NOT NULL UNIQUE,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
cron_expression TEXT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
notify_session_id TEXT,
|
||||||
|
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_run_at TEXT,
|
||||||
|
next_run_at TEXT,
|
||||||
|
last_run_status TEXT,
|
||||||
|
last_run_error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cronjobs_user ON cronjobs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cronjobs_next_run ON cronjobs(next_run_at);
|
||||||
|
");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryAddColumn(SqliteConnection conn, string table, string column, string type)
|
private void TryAddColumn(SqliteConnection conn, string table, string column, string type)
|
||||||
@@ -862,4 +885,87 @@ public class DatabaseService
|
|||||||
"SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC",
|
"SELECT * FROM sessions WHERE user_id = @userId AND is_deleted = 0 ORDER BY updated_at DESC",
|
||||||
new { userId });
|
new { userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cronjobs ──
|
||||||
|
|
||||||
|
public async Task<CronjobRecord> CreateCronjob(CronjobRecord cronjob)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
var now = DateTime.UtcNow.ToString("o");
|
||||||
|
cronjob.CronjobId = Guid.NewGuid().ToString("N");
|
||||||
|
cronjob.CreatedAt = now;
|
||||||
|
cronjob.UpdatedAt = now;
|
||||||
|
await conn.ExecuteAsync(@"
|
||||||
|
INSERT INTO cronjobs (cronjob_id, user_id, session_id, name, cron_expression, prompt, notify_session_id, is_enabled, next_run_at, created_at, updated_at)
|
||||||
|
VALUES (@CronjobId, @UserId, @SessionId, @Name, @CronExpression, @Prompt, @NotifySessionId, @IsEnabled, @NextRunAt, @CreatedAt, @UpdatedAt)
|
||||||
|
", cronjob);
|
||||||
|
return cronjob;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<CronjobRecord>> GetCronjobs(string userId)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
return await conn.QueryAsync<CronjobRecord>(@"
|
||||||
|
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
|
||||||
|
FROM cronjobs c
|
||||||
|
LEFT JOIN sessions s ON c.session_id = s.session_id
|
||||||
|
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
|
||||||
|
WHERE c.user_id = @userId
|
||||||
|
ORDER BY c.name",
|
||||||
|
new { userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CronjobRecord> GetCronjob(string cronjobId)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
return await conn.QuerySingleOrDefaultAsync<CronjobRecord>(@"
|
||||||
|
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
|
||||||
|
FROM cronjobs c
|
||||||
|
LEFT JOIN sessions s ON c.session_id = s.session_id
|
||||||
|
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
|
||||||
|
WHERE c.cronjob_id = @cronjobId",
|
||||||
|
new { cronjobId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateCronjob(CronjobRecord cronjob)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
cronjob.UpdatedAt = DateTime.UtcNow.ToString("o");
|
||||||
|
await conn.ExecuteAsync(@"
|
||||||
|
UPDATE cronjobs SET name = @Name, session_id = @SessionId, cron_expression = @CronExpression,
|
||||||
|
prompt = @Prompt, notify_session_id = @NotifySessionId, is_enabled = @IsEnabled, next_run_at = @NextRunAt, updated_at = @UpdatedAt
|
||||||
|
WHERE cronjob_id = @CronjobId AND user_id = @UserId",
|
||||||
|
cronjob);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteCronjob(string cronjobId, string userId)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM cronjobs WHERE cronjob_id = @cronjobId AND user_id = @userId",
|
||||||
|
new { cronjobId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateCronjobRun(string cronjobId, string status, string error, string nextRunAt)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
await conn.ExecuteAsync(@"
|
||||||
|
UPDATE cronjobs SET last_run_at = @now, last_run_status = @status,
|
||||||
|
last_run_error = @error, next_run_at = @nextRunAt, updated_at = @now
|
||||||
|
WHERE cronjob_id = @cronjobId",
|
||||||
|
new { cronjobId, status, error, nextRunAt, now = DateTime.UtcNow.ToString("o") });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<CronjobRecord>> GetDueCronjobs()
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
var now = DateTime.UtcNow.ToString("o");
|
||||||
|
return await conn.QueryAsync<CronjobRecord>(@"
|
||||||
|
SELECT c.*, s.name AS session_name, ns.name AS notify_session_name
|
||||||
|
FROM cronjobs c
|
||||||
|
LEFT JOIN sessions s ON c.session_id = s.session_id
|
||||||
|
LEFT JOIN sessions ns ON c.notify_session_id = ns.session_id
|
||||||
|
WHERE c.is_enabled = 1 AND c.next_run_at IS NOT NULL AND c.next_run_at <= @now",
|
||||||
|
new { now });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,29 +10,23 @@ namespace Gott.Services;
|
|||||||
|
|
||||||
public class TelegramService
|
public class TelegramService
|
||||||
{
|
{
|
||||||
private readonly string _anthropicToken;
|
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly DatabaseService _db;
|
private readonly DatabaseService _db;
|
||||||
private readonly ToolService _toolService;
|
|
||||||
private readonly MemoryService _memoryService;
|
|
||||||
private readonly ChatService _chatService;
|
private readonly ChatService _chatService;
|
||||||
|
private readonly AnthropicService _anthropic;
|
||||||
private readonly int _maxIterations;
|
private readonly int _maxIterations;
|
||||||
|
|
||||||
public TelegramService(
|
public TelegramService(
|
||||||
string anthropicToken,
|
|
||||||
IHttpClientFactory httpFactory,
|
IHttpClientFactory httpFactory,
|
||||||
DatabaseService db,
|
DatabaseService db,
|
||||||
ToolService toolService,
|
|
||||||
MemoryService memoryService,
|
|
||||||
ChatService chatService,
|
ChatService chatService,
|
||||||
|
AnthropicService anthropic,
|
||||||
int maxIterations = 100)
|
int maxIterations = 100)
|
||||||
{
|
{
|
||||||
_anthropicToken = anthropicToken;
|
|
||||||
_httpFactory = httpFactory;
|
_httpFactory = httpFactory;
|
||||||
_db = db;
|
_db = db;
|
||||||
_toolService = toolService;
|
|
||||||
_memoryService = memoryService;
|
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
|
_anthropic = anthropic;
|
||||||
_maxIterations = maxIterations;
|
_maxIterations = maxIterations;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +159,7 @@ public class TelegramService
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AnthropicService für Non-Streaming-Chat nutzen
|
// AnthropicService für Non-Streaming-Chat nutzen
|
||||||
var anthropic = new AnthropicService(_httpFactory, _anthropicToken, _toolService, _db, _memoryService);
|
var reply = await _anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
|
||||||
var reply = await anthropic.ChatNonStreaming(ctx, history, onIteration: async (iteration) =>
|
|
||||||
{
|
{
|
||||||
if (iteration % 3 == 0)
|
if (iteration % 3 == 0)
|
||||||
await SendChatAction(chatId, botToken, "typing");
|
await SendChatAction(chatId, botToken, "typing");
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
- [x] Anfrage/Stream abbrechen
|
- [x] Anfrage/Stream abbrechen
|
||||||
- [x] Telegram-Intergration
|
- [x] Telegram-Intergration
|
||||||
- [x] Refactoring: Agent/Session-Einstellungen, die den System-Prompt bzw. das Tool Use bearbeiten
|
- [x] Refactoring: Agent/Session-Einstellungen, die den System-Prompt bzw. das Tool Use bearbeiten
|
||||||
- [ ] Cronjobs: Planung, ob cron von Debian/Linux verwendet werden soll, oder ein eigener Worker/Service in der App selbst, der das übernimmt
|
- [x] Cronjobs: Planung, ob cron von Debian/Linux verwendet werden soll, oder ein eigener Worker/Service in der App selbst, der das übernimmt
|
||||||
- [x] Weiteres Modell hinzufügen: "Haiku 4.5"
|
- [x] Weiteres Modell hinzufügen: "Haiku 4.5"
|
||||||
- [x] Weitere Tools: Glob? Search? Todo List?
|
- [x] Weitere Tools: Glob? Search? Todo List?
|
||||||
- [ ] Settings in Datenbank speichern (users table) anstelle von localStorage
|
- [x] Settings in Datenbank speichern (users table) anstelle von localStorage
|
||||||
- [ ] Einzelne Nachicht als Link teilen
|
- [ ] Einzelne Nachicht als Link teilen
|
||||||
|
|
||||||
## Bugs
|
## Bugs
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Anthropic" Version="12.8.0" />
|
<PackageReference Include="Anthropic" Version="12.8.0" />
|
||||||
|
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
|
|||||||
@@ -255,3 +255,17 @@
|
|||||||
background: rgb(from var(--text) r g b / .15);
|
background: rgb(from var(--text) r g b / .15);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact cards (no image) */
|
||||||
|
.card-compact {
|
||||||
|
padding: var(--box-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-compact .card-meta {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-compact .card-meta-item {
|
||||||
|
margin-right: var(--box-spacing);
|
||||||
|
}
|
||||||
@@ -291,3 +291,66 @@ label.skill-item {
|
|||||||
.telegram-actions {
|
.telegram-actions {
|
||||||
margin-top: var(--badge-spacing);
|
margin-top: var(--badge-spacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Cronjob Status ── */
|
||||||
|
|
||||||
|
.form-status-box {
|
||||||
|
padding: var(--box-padding);
|
||||||
|
background: var(--f3-bg);
|
||||||
|
border: var(--border-width) solid var(--f2-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--badge-spacing);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-box .status-active {
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-box .status-inactive {
|
||||||
|
opacity: var(--semi-transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status-box .status-error {
|
||||||
|
color: var(--dangerous);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--badge-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--badge-spacing);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-group input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: var(--font-size);
|
||||||
|
opacity: var(--semi-transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
color: var(--secondary);
|
||||||
|
border-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn:hover {
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--f1-bg);
|
||||||
|
}
|
||||||
@@ -73,6 +73,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-grid" id="agentCards"></div>
|
<div class="card-grid" id="agentCards"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Cronjobs</h2>
|
||||||
|
<button id="newCronjobBtn" class="section-add-btn"><i class="fa fa-plus"></i> Neuer Cronjob</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-grid" id="cronjobCards"></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -208,6 +215,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="tpl-cronjob">
|
||||||
|
<div class="page form-page">
|
||||||
|
<header>
|
||||||
|
<button class="back-btn icon-btn" data-page="dashboard"><i class="fa fa-arrow-left"></i></button>
|
||||||
|
<h1 id="cronjobPageTitle">Neuer Cronjob</h1>
|
||||||
|
</header>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="form-container">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cronjobName">Name</label>
|
||||||
|
<input type="text" id="cronjobName" placeholder="z.B. Täglicher Report">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cronjobSession">Session</label>
|
||||||
|
<select id="cronjobSession"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cronjobCron">Cron-Expression</label>
|
||||||
|
<input type="text" id="cronjobCron" placeholder="z.B. 0 9 * * *">
|
||||||
|
<small id="cronjobCronPreview" class="form-hint"></small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cronjobPrompt">Prompt</label>
|
||||||
|
<textarea id="cronjobPrompt" rows="6" placeholder="Die Nachricht, die an den Agent gesendet wird..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cronjobNotifySession">Benachrichtigungs-Session <small>(optional)</small></label>
|
||||||
|
<select id="cronjobNotifySession"></select>
|
||||||
|
<small class="form-hint">Wenn gesetzt, wird eine Zusammenfassung in diese Session geschrieben (z.B. eine mit Telegram).</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-check-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="cronjobEnabled" checked>
|
||||||
|
Aktiv
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cronjobStatusBox" class="form-status-box" hidden></div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="cronjobSaveBtn" class="primary-btn"><i class="fa fa-save"></i> Speichern</button>
|
||||||
|
<button id="cronjobRunBtn" class="secondary-btn" hidden><i class="fa fa-play"></i> Jetzt ausführen</button>
|
||||||
|
<button id="cronjobDeleteBtn" class="danger-btn" hidden><i class="fa fa-trash"></i> Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template id="tpl-chat">
|
<template id="tpl-chat">
|
||||||
<div class="page chat-page">
|
<div class="page chat-page">
|
||||||
<header>
|
<header>
|
||||||
@@ -390,6 +446,7 @@
|
|||||||
<script src="js/pages/chat-page.js"></script>
|
<script src="js/pages/chat-page.js"></script>
|
||||||
<script src="js/pages/login-page.js"></script>
|
<script src="js/pages/login-page.js"></script>
|
||||||
<script src="js/pages/settings-page.js"></script>
|
<script src="js/pages/settings-page.js"></script>
|
||||||
|
<script src="js/pages/cronjob-page.js"></script>
|
||||||
<script src="js/pages/profile-page.js"></script>
|
<script src="js/pages/profile-page.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -321,6 +321,61 @@ async function apiSaveSettings(settings) {
|
|||||||
return resp.json();
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cronjob API ──
|
||||||
|
|
||||||
|
async function apiGetCronjobs() {
|
||||||
|
const resp = await fetch('/api/cronjobs');
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiGetCronjob(id) {
|
||||||
|
const resp = await fetch(`/api/cronjobs/${id}`);
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiCreateCronjob(data) {
|
||||||
|
const resp = await fetch('/api/cronjobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Cronjob erstellen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiUpdateCronjob(id, data) {
|
||||||
|
const resp = await fetch(`/api/cronjobs/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Cronjob speichern fehlgeschlagen');
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiDeleteCronjob(id) {
|
||||||
|
return fetch(`/api/cronjobs/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiToggleCronjob(id) {
|
||||||
|
const resp = await fetch(`/api/cronjobs/${id}/toggle`, { method: 'POST' });
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRunCronjob(id) {
|
||||||
|
const resp = await fetch(`/api/cronjobs/${id}/run`, { method: 'POST' });
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backup API ──
|
||||||
|
|
||||||
async function apiBackupDb() {
|
async function apiBackupDb() {
|
||||||
const resp = await fetch('/api/backup', { method: 'POST', credentials: 'include' });
|
const resp = await fetch('/api/backup', { method: 'POST', credentials: 'include' });
|
||||||
if (!resp.ok) throw new Error('Backup fehlgeschlagen');
|
if (!resp.ok) throw new Error('Backup fehlgeschlagen');
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// ── Cronjob Page ──
|
||||||
|
|
||||||
|
registerPage('cronjob', {
|
||||||
|
async init(params) {
|
||||||
|
const isEdit = params && params.id;
|
||||||
|
const title = document.getElementById('cronjobPageTitle');
|
||||||
|
const nameInput = document.getElementById('cronjobName');
|
||||||
|
const sessionSelect = document.getElementById('cronjobSession');
|
||||||
|
const cronInput = document.getElementById('cronjobCron');
|
||||||
|
const cronPreview = document.getElementById('cronjobCronPreview');
|
||||||
|
const promptInput = document.getElementById('cronjobPrompt');
|
||||||
|
const notifySelect = document.getElementById('cronjobNotifySession');
|
||||||
|
const enabledCheckbox = document.getElementById('cronjobEnabled');
|
||||||
|
const statusBox = document.getElementById('cronjobStatusBox');
|
||||||
|
const saveBtn = document.getElementById('cronjobSaveBtn');
|
||||||
|
const runBtn = document.getElementById('cronjobRunBtn');
|
||||||
|
const deleteBtn = document.getElementById('cronjobDeleteBtn');
|
||||||
|
|
||||||
|
// Sessions laden für Dropdown
|
||||||
|
const sessions = await apiGetSessions();
|
||||||
|
sessionSelect.innerHTML = '<option value="">– Session wählen –</option>';
|
||||||
|
notifySelect.innerHTML = '<option value="">– Keine (Antwort bleibt in Arbeits-Session) –</option>';
|
||||||
|
for (const s of sessions) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.sessionId;
|
||||||
|
opt.textContent = s.name || 'Unbenannt';
|
||||||
|
if (!s.agentId) {
|
||||||
|
opt.textContent += ' (kein Agent)';
|
||||||
|
opt.disabled = true;
|
||||||
|
}
|
||||||
|
sessionSelect.appendChild(opt);
|
||||||
|
|
||||||
|
// Notify-Dropdown: alle Sessions (auch ohne Agent), Telegram-Sessions markieren
|
||||||
|
const nOpt = document.createElement('option');
|
||||||
|
nOpt.value = s.sessionId;
|
||||||
|
nOpt.textContent = s.name || 'Unbenannt';
|
||||||
|
if (s.telegramChatId) nOpt.textContent += ' 📱';
|
||||||
|
notifySelect.appendChild(nOpt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cron-Expression Vorschau
|
||||||
|
cronInput.addEventListener('input', () => updateCronPreview(cronInput, cronPreview));
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
title.textContent = 'Cronjob bearbeiten';
|
||||||
|
runBtn.hidden = false;
|
||||||
|
deleteBtn.hidden = false;
|
||||||
|
|
||||||
|
const job = await apiGetCronjob(params.id);
|
||||||
|
if (!job) {
|
||||||
|
showToast('Cronjob nicht gefunden', 'error');
|
||||||
|
navigateTo('dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nameInput.value = job.name || '';
|
||||||
|
sessionSelect.value = job.sessionId || '';
|
||||||
|
cronInput.value = job.cronExpression || '';
|
||||||
|
promptInput.value = job.prompt || '';
|
||||||
|
notifySelect.value = job.notifySessionId || '';
|
||||||
|
enabledCheckbox.checked = job.isEnabled;
|
||||||
|
|
||||||
|
updateCronPreview(cronInput, cronPreview);
|
||||||
|
showCronjobStatus(statusBox, job);
|
||||||
|
|
||||||
|
// Löschen
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Cronjob wirklich löschen?')) return;
|
||||||
|
await apiDeleteCronjob(params.id);
|
||||||
|
showToast('Cronjob gelöscht');
|
||||||
|
navigateTo('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Jetzt ausführen
|
||||||
|
runBtn.addEventListener('click', async () => {
|
||||||
|
runBtn.disabled = true;
|
||||||
|
runBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Läuft...';
|
||||||
|
try {
|
||||||
|
const result = await apiRunCronjob(params.id);
|
||||||
|
if (result.status === 'success') {
|
||||||
|
showToast('Cronjob erfolgreich ausgeführt');
|
||||||
|
} else {
|
||||||
|
showToast('Fehler: ' + (result.error || 'Unbekannt'), 'error');
|
||||||
|
}
|
||||||
|
// Status neu laden
|
||||||
|
const updated = await apiGetCronjob(params.id);
|
||||||
|
if (updated) showCronjobStatus(statusBox, updated);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Fehler: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
runBtn.disabled = false;
|
||||||
|
runBtn.innerHTML = '<i class="fa fa-play"></i> Jetzt ausführen';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speichern
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
const data = {
|
||||||
|
name: nameInput.value.trim(),
|
||||||
|
sessionId: sessionSelect.value,
|
||||||
|
cronExpression: cronInput.value.trim(),
|
||||||
|
prompt: promptInput.value.trim(),
|
||||||
|
notifySessionId: notifySelect.value,
|
||||||
|
isEnabled: enabledCheckbox.checked
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.name || !data.sessionId || !data.cronExpression || !data.prompt) {
|
||||||
|
showToast('Bitte alle Felder ausfüllen', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await apiUpdateCronjob(params.id, data);
|
||||||
|
showToast('Cronjob gespeichert');
|
||||||
|
const updated = await apiGetCronjob(params.id);
|
||||||
|
if (updated) showCronjobStatus(statusBox, updated);
|
||||||
|
} else {
|
||||||
|
const created = await apiCreateCronjob(data);
|
||||||
|
showToast('Cronjob erstellt');
|
||||||
|
navigateTo('cronjob', { id: created.cronjobId });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Fehler: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateCronPreview(input, preview) {
|
||||||
|
const expr = input.value.trim();
|
||||||
|
if (!expr) {
|
||||||
|
preview.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const desc = describeCron(expr);
|
||||||
|
if (desc) {
|
||||||
|
preview.textContent = desc;
|
||||||
|
preview.style.color = '';
|
||||||
|
} else {
|
||||||
|
preview.textContent = 'Ungültige Cron-Expression';
|
||||||
|
preview.style.color = 'var(--color-error)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeCron(expr) {
|
||||||
|
try {
|
||||||
|
const parts = expr.split(/\s+/);
|
||||||
|
if (parts.length < 5 || parts.length > 5) return null;
|
||||||
|
|
||||||
|
const [min, hour, dom, month, dow] = parts;
|
||||||
|
|
||||||
|
// Einfache Fälle beschreiben
|
||||||
|
const dowNames = { '0': 'So', '1': 'Mo', '2': 'Di', '3': 'Mi', '4': 'Do', '5': 'Fr', '6': 'Sa', '7': 'So' };
|
||||||
|
|
||||||
|
let desc = '';
|
||||||
|
|
||||||
|
const tz = 'Europe/Berlin';
|
||||||
|
|
||||||
|
if (dom === '*' && month === '*' && dow === '*') {
|
||||||
|
if (min !== '*' && hour !== '*') {
|
||||||
|
desc = `Täglich um ${hour.padStart(2, '0')}:${min.padStart(2, '0')} Uhr`;
|
||||||
|
} else if (hour === '*' && min === '0') {
|
||||||
|
desc = 'Jede Stunde';
|
||||||
|
} else if (hour === '*' && min === '*') {
|
||||||
|
desc = 'Jede Minute';
|
||||||
|
} else if (hour !== '*') {
|
||||||
|
desc = `Täglich, stündlich um ${hour} Uhr`;
|
||||||
|
}
|
||||||
|
} else if (dom === '*' && month === '*' && dow !== '*') {
|
||||||
|
// Ranges wie 1-5 auflösen
|
||||||
|
let dayParts = [];
|
||||||
|
for (const part of dow.split(',')) {
|
||||||
|
if (part.includes('-')) {
|
||||||
|
const [from, to] = part.split('-');
|
||||||
|
dayParts.push(`${dowNames[from] || from}–${dowNames[to] || to}`);
|
||||||
|
} else {
|
||||||
|
dayParts.push(dowNames[part] || part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const days = dayParts.join(', ');
|
||||||
|
if (min !== '*' && hour !== '*') {
|
||||||
|
desc = `${days} um ${hour.padStart(2, '0')}:${min.padStart(2, '0')} Uhr`;
|
||||||
|
} else {
|
||||||
|
desc = `An: ${days}`;
|
||||||
|
}
|
||||||
|
} else if (dow === '*' && month === '*' && dom !== '*') {
|
||||||
|
desc = `Am ${dom}. jedes Monats`;
|
||||||
|
if (min !== '*' && hour !== '*') {
|
||||||
|
desc += ` um ${hour.padStart(2, '0')}:${min.padStart(2, '0')} Uhr`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!desc) {
|
||||||
|
desc = `${expr} (5-Feld Cron)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return desc;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCronjobStatus(box, job) {
|
||||||
|
box.hidden = false;
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (job.isEnabled) {
|
||||||
|
parts.push('<span class="status-active"><i class="fa fa-check-circle"></i> Aktiv</span>');
|
||||||
|
} else {
|
||||||
|
parts.push('<span class="status-inactive"><i class="fa fa-pause-circle"></i> Pausiert</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.nextRunAt) {
|
||||||
|
const next = new Date(job.nextRunAt);
|
||||||
|
parts.push(`<span><i class="fa fa-clock-o"></i> Nächste Ausführung: ${next.toLocaleString('de-AT')}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.lastRunAt) {
|
||||||
|
const last = new Date(job.lastRunAt);
|
||||||
|
const statusIcon = job.lastRunStatus === 'success' ? '✅' : '❌';
|
||||||
|
parts.push(`<span>${statusIcon} Letzte Ausführung: ${last.toLocaleString('de-AT')}</span>`);
|
||||||
|
if (job.lastRunStatus === 'error' && job.lastRunError) {
|
||||||
|
parts.push(`<span class="status-error"><i class="fa fa-exclamation-triangle"></i> ${escapeHtml(job.lastRunError)}</span>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.sessionName) {
|
||||||
|
parts.push(`<span><i class="fa fa-comment"></i> Session: ${escapeHtml(job.sessionName)}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.notifySessionName) {
|
||||||
|
parts.push(`<span><i class="fa fa-bell"></i> Benachrichtigung → ${escapeHtml(job.notifySessionName)}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
box.innerHTML = parts.join('<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s || '';
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
@@ -235,6 +235,96 @@ registerPage('dashboard', {
|
|||||||
|
|
||||||
// Neuer Agent Button
|
// Neuer Agent Button
|
||||||
document.getElementById('newAgentBtn').addEventListener('click', () => navigateTo('agent'));
|
document.getElementById('newAgentBtn').addEventListener('click', () => navigateTo('agent'));
|
||||||
|
|
||||||
|
// Cronjobs
|
||||||
|
const cronjobs = await apiGetCronjobs();
|
||||||
|
const cronjobGrid = document.getElementById('cronjobCards');
|
||||||
|
cronjobGrid.innerHTML = '';
|
||||||
|
|
||||||
|
for (const cj of cronjobs) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card card-compact';
|
||||||
|
|
||||||
|
const statusBadge = cj.isEnabled
|
||||||
|
? '<span class="badge badge-feature"><i class="fa fa-check-circle"></i> Aktiv</span>'
|
||||||
|
: '<span class="badge badge-off"><i class="fa fa-pause-circle"></i> Pausiert</span>';
|
||||||
|
|
||||||
|
const lastRunIcon = cj.lastRunStatus === 'success' ? '✅'
|
||||||
|
: cj.lastRunStatus === 'error' ? '❌' : '';
|
||||||
|
|
||||||
|
const cronDesc = describeCron(cj.cronExpression) || cj.cronExpression;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title-row">
|
||||||
|
<span class="card-title">${esc(cj.name)}</span>
|
||||||
|
${statusBadge}
|
||||||
|
<div class="card-menu">
|
||||||
|
<button class="card-menu-trigger icon-btn"><i class="fa fa-ellipsis-v"></i></button>
|
||||||
|
<div class="card-menu-dropdown" hidden>
|
||||||
|
<button class="card-menu-item" data-action="edit"><i class="fa fa-pencil"></i> Bearbeiten</button>
|
||||||
|
<button class="card-menu-item" data-action="toggle"><i class="fa fa-${cj.isEnabled ? 'pause' : 'play'}"></i> ${cj.isEnabled ? 'Pausieren' : 'Aktivieren'}</button>
|
||||||
|
<button class="card-menu-item" data-action="run"><i class="fa fa-play-circle"></i> Jetzt ausführen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="card-meta-item"><i class="fa fa-clock-o"></i> ${esc(cronDesc)}</span>
|
||||||
|
${cj.sessionName ? `<span class="card-meta-item"><i class="fa fa-comment"></i> ${esc(cj.sessionName)}</span>` : ''}
|
||||||
|
${lastRunIcon ? `<span class="card-meta-item">${lastRunIcon} ${cj.lastRunAt ? formatDate(cj.lastRunAt) : ''}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const menuTrigger = card.querySelector('.card-menu-trigger');
|
||||||
|
const menuDropdown = card.querySelector('.card-menu-dropdown');
|
||||||
|
menuTrigger.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeAllCardMenus();
|
||||||
|
const rect = menuTrigger.getBoundingClientRect();
|
||||||
|
menuDropdown.hidden = false;
|
||||||
|
const dropRect = menuDropdown.getBoundingClientRect();
|
||||||
|
menuDropdown.style.top = `${rect.bottom + 4}px`;
|
||||||
|
menuDropdown.style.left = `${rect.right - dropRect.width}px`;
|
||||||
|
if (rect.bottom + 4 + dropRect.height > window.innerHeight) {
|
||||||
|
menuDropdown.style.top = `${rect.top - dropRect.height - 4}px`;
|
||||||
|
}
|
||||||
|
const finalRect = menuDropdown.getBoundingClientRect();
|
||||||
|
if (finalRect.left < 0) menuDropdown.style.left = '8px';
|
||||||
|
});
|
||||||
|
|
||||||
|
menuDropdown.querySelector('[data-action="edit"]').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menuDropdown.hidden = true;
|
||||||
|
navigateTo('cronjob', { id: cj.cronjobId });
|
||||||
|
});
|
||||||
|
|
||||||
|
menuDropdown.querySelector('[data-action="toggle"]').addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menuDropdown.hidden = true;
|
||||||
|
await apiToggleCronjob(cj.cronjobId);
|
||||||
|
showToast(cj.isEnabled ? 'Cronjob pausiert' : 'Cronjob aktiviert');
|
||||||
|
navigateTo('dashboard', {}, { replace: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
menuDropdown.querySelector('[data-action="run"]').addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menuDropdown.hidden = true;
|
||||||
|
showToast('Cronjob wird ausgeführt...');
|
||||||
|
const result = await apiRunCronjob(cj.cronjobId);
|
||||||
|
if (result.status === 'success') {
|
||||||
|
showToast('Cronjob erfolgreich ausgeführt');
|
||||||
|
} else {
|
||||||
|
showToast('Fehler: ' + (result.error || 'Unbekannt'), 'error');
|
||||||
|
}
|
||||||
|
navigateTo('dashboard', {}, { replace: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
cronjobGrid.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuer Cronjob Button
|
||||||
|
document.getElementById('newCronjobBtn').addEventListener('click', () => navigateTo('cronjob'));
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this._closeCardMenus) {
|
if (this._closeCardMenus) {
|
||||||
|
|||||||
Reference in new issue
Block a user