init
This commit is contained in:
502
main.go
Normal file
502
main.go
Normal file
@@ -0,0 +1,502 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
/* ====== Konfiguration & Datenmodelle ====== */
|
||||
|
||||
type Config struct {
|
||||
Listen string `json:"listen"` // z.B. "127.0.0.1:8080"
|
||||
Username string `json:"username"` // Basic Auth
|
||||
Password string `json:"password"` // Basic Auth
|
||||
ScriptsDir string `json:"scripts_dir"` // optional: Basisordner für Skripte
|
||||
Tasks []*Task `json:"tasks"`
|
||||
// Optionales CSRF-Secret; wenn leer, wird eins generiert (nur pro Prozesslauf)
|
||||
CSRFSecret string `json:"csrf_secret,omitempty"`
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"` // .ps1
|
||||
Interval string `json:"interval,omitempty"` // z.B. "5m", "1h", "0" = aus
|
||||
Enabled bool `json:"enabled,omitempty"` // ob Intervall aktiv ist
|
||||
Timeout string `json:"timeout,omitempty"` // optional, z.B. "30m"
|
||||
|
||||
// Laufzeitfelder
|
||||
nextRun time.Time `json:"-"`
|
||||
running bool `json:"-"`
|
||||
timer *time.Timer `json:"-"`
|
||||
mutex sync.Mutex `json:"-"`
|
||||
history []RunEntry `json:"-"`
|
||||
maxLogs int `json:"-"`
|
||||
cancelRun context.CancelFunc `json:"-"`
|
||||
}
|
||||
|
||||
type RunEntry struct {
|
||||
Time time.Time `json:"time"`
|
||||
Manual bool `json:"manual"`
|
||||
Success bool `json:"success"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Output string `json:"output"` // stdout+stderr
|
||||
}
|
||||
|
||||
var (
|
||||
cfg Config
|
||||
taskIndex = map[string]*Task{}
|
||||
started time.Time
|
||||
csrfKey []byte
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var webFS embed.FS
|
||||
|
||||
/* ====== Utilities ====== */
|
||||
|
||||
func must[T any](v T, err error) T {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseDurationOrZero(s string) time.Duration {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (t *Task) scheduleNext() {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
if !t.Enabled {
|
||||
if t.timer != nil {
|
||||
t.timer.Stop()
|
||||
t.timer = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
ival := parseDurationOrZero(t.Interval)
|
||||
if ival <= 0 {
|
||||
// Deaktiviert
|
||||
if t.timer != nil {
|
||||
t.timer.Stop()
|
||||
t.timer = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
// Planen
|
||||
now := time.Now()
|
||||
t.nextRun = now.Add(ival)
|
||||
if t.timer != nil {
|
||||
t.timer.Stop()
|
||||
}
|
||||
t.timer = time.AfterFunc(ival, func() {
|
||||
go func() {
|
||||
_ = runTask(t, false) // geplante Ausführung
|
||||
t.scheduleNext() // danach erneut planen
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (t *Task) setInterval(newInterval string, enabled bool) {
|
||||
t.mutex.Lock()
|
||||
t.Interval = newInterval
|
||||
t.Enabled = enabled
|
||||
t.mutex.Unlock()
|
||||
t.scheduleNext()
|
||||
}
|
||||
|
||||
func (t *Task) timeout() time.Duration {
|
||||
d := parseDurationOrZero(t.Timeout)
|
||||
if d == 0 {
|
||||
return 2 * time.Hour // Default großzügig
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func powershellPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Bevorzugt pwsh, wenn vorhanden
|
||||
if pwsh, err := exec.LookPath("pwsh.exe"); err == nil {
|
||||
return pwsh
|
||||
}
|
||||
if psh, err := exec.LookPath("powershell.exe"); err == nil {
|
||||
return psh
|
||||
}
|
||||
// Fallback – häufiges Standardpfad
|
||||
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
|
||||
}
|
||||
// Für Tests auf anderen OS (WSL/CI/etc.)
|
||||
return "pwsh"
|
||||
}
|
||||
|
||||
func runTask(t *Task, manual bool) error {
|
||||
t.mutex.Lock()
|
||||
if t.running {
|
||||
t.mutex.Unlock()
|
||||
return errors.New("Task läuft bereits")
|
||||
}
|
||||
t.running = true
|
||||
t.mutex.Unlock()
|
||||
|
||||
defer func() {
|
||||
t.mutex.Lock()
|
||||
t.running = false
|
||||
t.cancelRun = nil
|
||||
t.mutex.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), t.timeout())
|
||||
t.mutex.Lock()
|
||||
t.cancelRun = cancel
|
||||
t.mutex.Unlock()
|
||||
|
||||
defer cancel()
|
||||
|
||||
ps := powershellPath()
|
||||
// Sicherstellen, dass die Datei existiert
|
||||
path := t.Path
|
||||
if !filepath.IsAbs(path) && cfg.ScriptsDir != "" {
|
||||
path = filepath.Join(cfg.ScriptsDir, path)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
out := "Script nicht gefunden: " + path + " (" + err.Error() + ")"
|
||||
t.appendLog(RunEntry{Time: time.Now(), Manual: manual, Success: false, Duration: 0, Output: out})
|
||||
return err
|
||||
}
|
||||
|
||||
args := []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path}
|
||||
cmd := exec.CommandContext(ctx, ps, args...)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
|
||||
start := time.Now()
|
||||
err := cmd.Run()
|
||||
dur := time.Since(start)
|
||||
outStr := sanitizeOutput(buf.String())
|
||||
|
||||
success := err == nil && ctx.Err() == nil
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
outStr += "\n[Abgebrochen: Timeout]"
|
||||
}
|
||||
if err != nil && !success {
|
||||
outStr += "\n[Fehler] " + err.Error()
|
||||
}
|
||||
t.appendLog(RunEntry{Time: start, Manual: manual, Success: success, Duration: dur, Output: outStr})
|
||||
return err
|
||||
}
|
||||
|
||||
func sanitizeOutput(s string) string {
|
||||
// Trimmen und begrenzen (z.B. 1 MB)
|
||||
const max = 1 << 20
|
||||
if len(s) > max {
|
||||
return s[:max] + "\n...[gekürzt]..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (t *Task) appendLog(e RunEntry) {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
if t.maxLogs == 0 {
|
||||
t.maxLogs = 200
|
||||
}
|
||||
t.history = append([]RunEntry{e}, t.history...) // neu oben
|
||||
if len(t.history) > t.maxLogs {
|
||||
t.history = t.history[:t.maxLogs]
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== Web (UI + API) ====== */
|
||||
|
||||
func basicAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || u != cfg.Username || p != cfg.Password {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func requirePOST(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "CSRF token invalid", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func validateCSRF(r *http.Request) bool {
|
||||
// simpler stateless Ansatz: Token in Form/FETCH muss mit serverseitigem Secret beginnen
|
||||
// (nicht mega-sicher, aber besser als nix bei Basic Auth + localhost)
|
||||
formToken := r.FormValue("csrf_token")
|
||||
if formToken == "" {
|
||||
// Header-Fallback für fetch()
|
||||
formToken = r.Header.Get("X-CSRF-Token")
|
||||
}
|
||||
return formToken != "" && strings.HasPrefix(formToken, string(csrfKey))
|
||||
}
|
||||
|
||||
func csrfToken() string {
|
||||
return string(csrfKey) + ":1"
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
Started time.Time
|
||||
Tasks []*Task
|
||||
Now time.Time
|
||||
CSRFToken string
|
||||
ListenHost string
|
||||
}
|
||||
|
||||
// Templates aus embed
|
||||
var (
|
||||
tmplIndex = template.Must(template.ParseFS(webFS, "web/index.html"))
|
||||
)
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// kleine Gesundheitsprüfung: nur lokale Adresse?
|
||||
host, _, _ := net.SplitHostPort(cfg.Listen)
|
||||
_ = host
|
||||
data := pageData{
|
||||
Title: "Script Runner",
|
||||
Started: started,
|
||||
Tasks: cfg.Tasks,
|
||||
Now: time.Now(),
|
||||
CSRFToken: csrfToken(),
|
||||
ListenHost: cfg.Listen,
|
||||
}
|
||||
if err := tmplIndex.Execute(w, data); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRun(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.FormValue("name")
|
||||
t := taskIndex[name]
|
||||
if t == nil {
|
||||
http.Error(w, "Task not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := runTask(t, true); err != nil {
|
||||
http.Error(w, "Run failed: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
io.WriteString(w, "OK")
|
||||
}
|
||||
|
||||
func handleSetInterval(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.FormValue("name")
|
||||
interval := strings.TrimSpace(r.FormValue("interval"))
|
||||
enable := r.FormValue("enable") == "1"
|
||||
t := taskIndex[name]
|
||||
if t == nil {
|
||||
http.Error(w, "Task not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
t.setInterval(interval, enable)
|
||||
io.WriteString(w, "OK")
|
||||
}
|
||||
|
||||
func handleToggle(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.FormValue("name")
|
||||
enable := r.FormValue("enable") == "1"
|
||||
t := taskIndex[name]
|
||||
if t == nil {
|
||||
http.Error(w, "Task not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
t.mutex.Lock()
|
||||
t.Enabled = enable
|
||||
t.mutex.Unlock()
|
||||
t.scheduleNext()
|
||||
io.WriteString(w, "OK")
|
||||
}
|
||||
|
||||
func handleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.FormValue("name")
|
||||
t := taskIndex[name]
|
||||
if t == nil {
|
||||
http.Error(w, "Task not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
t.mutex.Lock()
|
||||
cancel := t.cancelRun
|
||||
t.mutex.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
io.WriteString(w, "Cancel signal sent")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, "Nothing to cancel")
|
||||
}
|
||||
|
||||
func handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
t := taskIndex[name]
|
||||
if t == nil {
|
||||
http.Error(w, "Task not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
type resp struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
NextRun *time.Time `json:"next_run,omitempty"`
|
||||
History []RunEntry `json:"history"`
|
||||
}
|
||||
var nr *time.Time
|
||||
if !t.nextRun.IsZero() {
|
||||
tmp := t.nextRun
|
||||
nr = &tmp
|
||||
}
|
||||
out := resp{
|
||||
Name: t.Name,
|
||||
Enabled: t.Enabled,
|
||||
Running: t.running,
|
||||
NextRun: nr,
|
||||
History: t.history,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
/* ====== Init/Load/Serve ====== */
|
||||
|
||||
func loadConfig() {
|
||||
f, err := os.Open("tasks.json")
|
||||
if err != nil {
|
||||
log.Fatalf("tasks.json nicht gefunden: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
dec := json.NewDecoder(f)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&cfg); err != nil {
|
||||
log.Fatalf("tasks.json fehlerhaft: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Listen == "" {
|
||||
cfg.Listen = "127.0.0.1:8080"
|
||||
}
|
||||
if cfg.Username == "" || cfg.Password == "" {
|
||||
log.Fatal("Bitte Username/Password in tasks.json setzen (Basic Auth).")
|
||||
}
|
||||
if cfg.CSRFSecret == "" {
|
||||
// pro Start generieren (hier simpel)
|
||||
cfg.CSRFSecret = randomString(24)
|
||||
}
|
||||
csrfKey = []byte(cfg.CSRFSecret)
|
||||
|
||||
taskIndex = map[string]*Task{}
|
||||
for _, t := range cfg.Tasks {
|
||||
if t.Name == "" || t.Path == "" {
|
||||
log.Fatalf("Task ohne Name oder Path: %+v", t)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(t.Path), ".ps1") {
|
||||
log.Printf("Warnung: Task %q hat keinen .ps1 Pfad: %s", t.Name, t.Path)
|
||||
}
|
||||
if t.maxLogs == 0 {
|
||||
t.maxLogs = 200
|
||||
}
|
||||
taskIndex[t.Name] = t
|
||||
}
|
||||
}
|
||||
|
||||
func randomString(n int) string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
f, _ := os.OpenFile("/dev/urandom", os.O_RDONLY, 0)
|
||||
defer func() {
|
||||
if f != nil {
|
||||
f.Close()
|
||||
}
|
||||
}()
|
||||
if f != nil {
|
||||
_, _ = io.ReadFull(f, b)
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
// Fallback
|
||||
for i := range b {
|
||||
b[i] = alphabet[time.Now().Nanosecond()%len(alphabet)]
|
||||
time.Sleep(time.Nanosecond)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if runtime.GOOS != "windows" {
|
||||
log.Println("Hinweis: Dieses Programm ist für Windows/Powershell optimiert, läuft aber eingeschränkt auch anderswo.")
|
||||
}
|
||||
loadConfig()
|
||||
started = time.Now()
|
||||
|
||||
// Scheduler starten
|
||||
for _, t := range cfg.Tasks {
|
||||
if t.Enabled && parseDurationOrZero(t.Interval) > 0 {
|
||||
t.scheduleNext()
|
||||
}
|
||||
}
|
||||
|
||||
// Routen
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", handleIndex)
|
||||
mux.HandleFunc("/api/run", requirePOST(handleRun))
|
||||
mux.HandleFunc("/api/set-interval", requirePOST(handleSetInterval))
|
||||
mux.HandleFunc("/api/toggle", requirePOST(handleToggle))
|
||||
mux.HandleFunc("/api/cancel", requirePOST(handleCancel))
|
||||
mux.HandleFunc("/api/logs", handleLogs)
|
||||
|
||||
// Statische Dateien (CSS/JS aus embed)
|
||||
fs := http.FS(webFS)
|
||||
mux.Handle("/static/", http.FileServer(fs))
|
||||
|
||||
addr := cfg.Listen
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: basicAuth(mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
log.Printf("Weboberfläche: http://%s (Basic Auth aktiv)", addr)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
}
|
||||
|
||||
/* ====== Embedded HTML (Tailwind-lite via CDN) ====== */
|
||||
53
nssm.txt
Normal file
53
nssm.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
# ======= Einstellungen anpassen =======
|
||||
$SvcName = "ScriptRunner"
|
||||
$Display = "Script Runner"
|
||||
$InstallDir = "C:\apps\script-runner" # Ordner mit EXE & tasks.json
|
||||
$ExePath = "$InstallDir\script-runner.exe"
|
||||
$LogsDir = "$InstallDir\logs"
|
||||
$NSSM = "C:\tools\nssm\nssm.exe" # Pfad zu nssm.exe
|
||||
$AutoStart = "SERVICE_AUTO_START" # oder SERVICE_DEMAND_START
|
||||
$Port = 8080 # nur für Firewall-Beispiel
|
||||
|
||||
# (optional) Dienstkonto statt LocalSystem nutzen – empfohlen:
|
||||
# Einmalig Benutzer anlegen (lokal):
|
||||
# $SvcUser = "$env:COMPUTERNAME\svc-scriptrunner"
|
||||
# $SvcPwd = Read-Host -AsSecureString "Passwort für $SvcUser"
|
||||
# New-LocalUser -Name "svc-scriptrunner" -Password $SvcPwd -NoPasswordExpiration -AccountNeverExpires
|
||||
# $PlainPwd = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($SvcPwd))
|
||||
|
||||
# ======= Ordner/Logs anlegen =======
|
||||
New-Item -Type Directory -Force $InstallDir | Out-Null
|
||||
New-Item -Type Directory -Force $LogsDir | Out-Null
|
||||
|
||||
# ======= Dienst installieren =======
|
||||
& $NSSM install $SvcName $ExePath
|
||||
& $NSSM set $SvcName AppDirectory $InstallDir
|
||||
& $NSSM set $SvcName DisplayName $Display
|
||||
& $NSSM set $SvcName Start $AutoStart
|
||||
|
||||
# (optional) Dienstkonto setzen:
|
||||
# & $NSSM set $SvcName ObjectName $SvcUser $PlainPwd
|
||||
|
||||
# ======= Logs & Rotation =======
|
||||
& $NSSM set $SvcName AppStdout "$LogsDir\out.log"
|
||||
& $NSSM set $SvcName AppStderr "$LogsDir\err.log"
|
||||
& $NSSM set $SvcName AppRotateFiles 1
|
||||
& $NSSM set $SvcName AppRotateOnline 1
|
||||
& $NSSM set $SvcName AppRotateBytes 10485760 # 10 MB
|
||||
& $NSSM set $SvcName AppRotateSeconds 86400 # 1 Tag
|
||||
|
||||
# ======= Sauberes Stop/Restart-Verhalten =======
|
||||
& $NSSM set $SvcName AppStopMethodConsole 1500 # CTRL+C nach 1,5s
|
||||
& $NSSM set $SvcName AppStopMethodSkip 0
|
||||
& $NSSM set $SvcName AppKillProcessTree 1
|
||||
& $NSSM set $SvcName AppExit Default Restart # bei Fehler neu starten
|
||||
|
||||
# ======= (optional) Firewallregel – nur nötig, wenn NICHT auf 127.0.0.1 gebunden =======
|
||||
# New-NetFirewallRule -DisplayName "Script Runner $Port" -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port
|
||||
|
||||
# ======= Start & Check =======
|
||||
Start-Service $SvcName
|
||||
Start-Sleep 1
|
||||
Get-Service $SvcName | Format-Table -Auto
|
||||
|
||||
Write-Host "Logs: $LogsDir"
|
||||
23
tasks.json
Normal file
23
tasks.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"listen": "127.0.0.1:8080",
|
||||
"username": "admin",
|
||||
"password": "change-me",
|
||||
"scripts_dir": "C:\\scripts",
|
||||
"csrf_secret": "ersetzenMitEigenemSecret",
|
||||
"tasks": [
|
||||
{
|
||||
"name": "Nacht-Backup",
|
||||
"path": "C:\\scripts\\backup.ps1",
|
||||
"interval": "24h",
|
||||
"enabled": true,
|
||||
"timeout": "2h"
|
||||
},
|
||||
{
|
||||
"name": "IIS-Log-Rotation",
|
||||
"path": "C:\\scripts\\rotate.ps1",
|
||||
"interval": "0",
|
||||
"enabled": false,
|
||||
"timeout": "30m"
|
||||
}
|
||||
]
|
||||
}
|
||||
204
web/index.html
Normal file
204
web/index.html
Normal file
@@ -0,0 +1,204 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 2rem; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||
th, td { padding: .6rem .5rem; border-bottom: 1px solid #ddd; vertical-align: top; }
|
||||
.btn { padding: .4rem .7rem; border: 1px solid #888; border-radius: .5rem; background:#f8f8f8; cursor:pointer; }
|
||||
.pill { padding:.15rem .45rem; border-radius:999px; font-size:.85rem; display:inline-block; }
|
||||
.pill.on { background:#e6ffed; border:1px solid #b7ebc6; color:#165b31; }
|
||||
.pill.off { background:#fff2e5; border:1px solid #ffd4a8; color:#8a4b16; }
|
||||
.pill.run { background:#e7efff; border:1px solid #b9c8ff; color:#1f3d8a; }
|
||||
.btn:hover { background:#eee; }
|
||||
.pill { padding:.15rem .45rem; border-radius:999px; font-size:.85rem; }
|
||||
.ok { background:#e6ffed; border:1px solid #b7ebc6; }
|
||||
.bad { background:#ffecec; border:1px solid #ffb3b3; }
|
||||
.muted { color:#666; }
|
||||
pre { white-space: pre-wrap; background:#fafafa; border:1px solid #eee; padding:.5rem; border-radius:.5rem; }
|
||||
.row { display:flex; gap:.4rem; align-items:center; flex-wrap: wrap; }
|
||||
.grow { flex:1 }
|
||||
.small { font-size: .9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Script Runner</h1>
|
||||
<p class="muted small">Gestartet: {{.Started}} · Lauscht auf: {{.ListenHost}}</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:18%">Name</th>
|
||||
<th>Pfad</th>
|
||||
<th style="width:10%">Status</th>
|
||||
<th style="width:14%">Intervall</th>
|
||||
<th style="width:10%">Nächster Lauf</th>
|
||||
<th style="width:26%">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Tasks}}
|
||||
<tr id="row-{{.Name}}">
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td><code>{{.Path}}</code></td>
|
||||
<td>
|
||||
<span
|
||||
id="status-{{.Name}}"
|
||||
class="pill {{if .Enabled}}on{{else}}off{{end}}">
|
||||
{{if .Enabled}}Aktiv{{else}}Deaktiviert{{end}}
|
||||
</span>
|
||||
<span id="running-{{.Name}}" class="pill run" style="display:none;">Läuft…</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row">
|
||||
<input class="grow" type="text" id="ival-{{.Name}}" value="{{.Interval}}" placeholder="z.B. 5m, 1h"/>
|
||||
<button class="btn" onclick="setInterval('{{.Name}}', document.getElementById('ival-{{.Name}}').value, 1)">Setzen</button>
|
||||
</div>
|
||||
<div class="small muted">leer/0 = aus</div>
|
||||
</td>
|
||||
<td>
|
||||
<span id="next-{{.Name}}" class="muted">–</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row">
|
||||
<button class="btn" onclick="runNow('{{.Name}}')">Jetzt starten</button>
|
||||
<button class="btn" onclick="toggle('{{.Name}}', true)">Aktivieren</button>
|
||||
<button class="btn" onclick="toggle('{{.Name}}', false)">Deaktivieren</button>
|
||||
<button class="btn" onclick="cancelRun('{{.Name}}')">Abbrechen</button>
|
||||
<button class="btn" onclick="openLogs('{{.Name}}')">Logs</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<dialog id="logdlg">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;">
|
||||
<h3 id="logtitle">Logs</h3>
|
||||
<button class="btn" onclick="document.getElementById('logdlg').close()">Schließen</button>
|
||||
</div>
|
||||
<div id="logcontent"></div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
const CSRF = "{{.CSRFToken}}";
|
||||
|
||||
async function api(path, form) {
|
||||
const body = new URLSearchParams(form || {});
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: {"X-CSRF-Token": CSRF},
|
||||
body
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function runNow(name) {
|
||||
try { await api("/api/run", {name, csrf_token: CSRF}); alert("Gestartet."); }
|
||||
catch(e){ alert(e.message); }
|
||||
}
|
||||
|
||||
async function setInterval(name, interval, enable) {
|
||||
try { await api("/api/set-interval", {name, interval, enable: enable?1:0, csrf_token: CSRF}); alert("Gespeichert."); }
|
||||
catch(e){ alert(e.message); }
|
||||
await refreshRow(name);
|
||||
}
|
||||
|
||||
async function toggle(name, enable) {
|
||||
try { await api("/api/toggle", {name, enable: enable?1:0, csrf_token: CSRF}); }
|
||||
catch(e){ alert(e.message); }
|
||||
await refreshRow(name);
|
||||
}
|
||||
|
||||
async function cancelRun(name) {
|
||||
try { await api("/api/cancel", {name, csrf_token: CSRF}); }
|
||||
catch(e){ alert(e.message); }
|
||||
}
|
||||
|
||||
async function refreshRow(name) {
|
||||
const res = await fetch("/api/logs?name="+encodeURIComponent(name));
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
// Next run
|
||||
document.getElementById("next-"+name).textContent =
|
||||
data.next_run ? new Date(data.next_run).toLocaleString() : "–";
|
||||
|
||||
// Status-Badge (Aktiv/Deaktiviert)
|
||||
const st = document.getElementById("status-"+name);
|
||||
st.textContent = data.enabled ? "Aktiv" : "Deaktiviert";
|
||||
st.classList.remove("on","off");
|
||||
st.classList.add(data.enabled ? "on" : "off");
|
||||
|
||||
// Running-Badge
|
||||
const run = document.getElementById("running-"+name);
|
||||
if (data.running) {
|
||||
run.style.display = "inline-block";
|
||||
} else {
|
||||
run.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
async function openLogs(name) {
|
||||
const res = await fetch("/api/logs?name="+encodeURIComponent(name));
|
||||
if (!res.ok) { alert(await res.text()); return; }
|
||||
const data = await res.json();
|
||||
document.getElementById("logtitle").textContent = "Logs – " + name;
|
||||
const box = document.getElementById("logcontent");
|
||||
box.innerHTML = "";
|
||||
if (!data.history || !data.history.length) {
|
||||
box.textContent = "Keine Einträge.";
|
||||
} else {
|
||||
data.history.forEach(e => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.margin = "1rem 0";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "pill " + (e.success ? "ok" : "bad");
|
||||
badge.textContent = e.success ? "OK" : "Fehler";
|
||||
const head = document.createElement("div");
|
||||
head.className = "row";
|
||||
const meta = document.createElement("div");
|
||||
meta.textContent = new Date(e.time).toLocaleString() + " · " + (e.manual ? "manuell" : "geplant") + " · " + ms(e.duration);
|
||||
head.appendChild(badge);
|
||||
head.appendChild(meta);
|
||||
const pre = document.createElement("pre");
|
||||
pre.textContent = e.output || "(kein Output)";
|
||||
wrap.appendChild(head);
|
||||
wrap.appendChild(pre);
|
||||
box.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
document.getElementById("logdlg").showModal();
|
||||
}
|
||||
|
||||
function ms(ns) {
|
||||
// ns ist eigentlich Duration in ns; Browser interpretiert ggf. anders – zur Sicherheit:
|
||||
if (typeof ns === "number") {
|
||||
const ms = ns / 1e6;
|
||||
if (ms < 1000) return ms.toFixed(0) + " ms";
|
||||
const s = ms/1000;
|
||||
if (s < 60) return s.toFixed(2) + " s";
|
||||
const m = Math.floor(s/60);
|
||||
const rest = (s%60).toFixed(0);
|
||||
return m + " m " + rest + " s";
|
||||
}
|
||||
return String(ns);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// erste NextRun-Anzeige laden
|
||||
const rows = document.querySelectorAll("tbody tr[id^='row-']");
|
||||
for (const tr of rows) {
|
||||
const name = tr.id.replace("row-","");
|
||||
await refreshRow(name);
|
||||
}
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user