From 7a76c3b86c92332b90bbe044bb52761c9e804227 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sat, 30 May 2026 00:55:40 +0200 Subject: [PATCH] =?UTF-8?q?Dev-Tools:=20YAML=20raus,=20Crontab-Generator?= =?UTF-8?q?=20rein=20(Erkl=C3=A4ren=20+=20Erstellen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 3 +- frontend/src/tools/devtools.jsx | 602 +++++++++++++++----------------- 2 files changed, 278 insertions(+), 327 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 9c7c6cf..27688e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,6 @@ "build": "vite build" }, "dependencies": { - "js-yaml": "^4.1.1", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -15,4 +14,4 @@ "@vitejs/plugin-react": "^4.0.0", "vite": "^5.0.0" } -} +} \ No newline at end of file diff --git a/frontend/src/tools/devtools.jsx b/frontend/src/tools/devtools.jsx index 0710b33..1bd78ba 100644 --- a/frontend/src/tools/devtools.jsx +++ b/frontend/src/tools/devtools.jsx @@ -1,330 +1,269 @@ -import { useState, useCallback } from 'react'; +import { useState } from 'react'; import { S } from '../lib.js'; -import jsyaml from 'js-yaml'; -// ── YAML Tree Node (collapsible, mit Einrück-Linien) ───────────────────────── -function YamlNode({ data, depth = 0, keyName = null, collapsed, onToggle, path }) { - const indent = depth * 18; // px pro Level - const isObj = data !== null && typeof data === 'object' && !Array.isArray(data); - const isArr = Array.isArray(data); - const isLeaf = !isObj && !isArr; +const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; +const WEEKDAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; - const nodeKey = path; - const isCollapsed = collapsed.has(nodeKey); - const hasChildren = isObj || isArr; +function parseField(val) { + val = String(val).trim(); + if (val==='*'||val==='?') return {type:'all'}; + if (val.startsWith('*/')) { const n=parseInt(val.slice(2)); return {type:'step',step:n}; } + if (val.includes('/')&&!val.startsWith('*/')) { const [s,st]=val.split('/'); return {type:'stepFrom',from:parseInt(s),step:parseInt(st)}; } + if (val.includes(',')) return {type:'list',vals:val.split(',').map(v=>parseInt(v))}; + if (val.includes('-')) { const [a,b]=val.split('-').map(Number); return {type:'range',from:a,to:b}; } + const n=parseInt(val); if (!isNaN(n)) return {type:'single',val:n}; + return {type:'raw',val}; +} - const keyColor = '#7ecaff'; - const strColor = '#ce9178'; - const numColor = '#b5cea8'; - const boolColor = '#569cd6'; - const nullColor = '#808080'; - const indentColor= 'rgba(255,255,255,0.08)'; - - const valueColor = (v) => { - if (v === null || v === undefined) return nullColor; - if (typeof v === 'boolean') return boolColor; - if (typeof v === 'number') return numColor; - return strColor; - }; - - const renderValue = (v) => { - if (v === null) return null; - if (v === undefined) return ~; - if (typeof v === 'boolean') return {String(v)}; - if (typeof v === 'number') return {v}; - const s = String(v); - // Quote strings that need it - const needsQuotes = /[:#\[\]{}&*!|>'"%@`]/.test(s) || s.trim()!==s || s==='' || /^(true|false|null|yes|no|on|off)$/i.test(s); - return {needsQuotes ? `"${s.replace(/"/g,'\\"')}"` : s}; - }; - - // Leaf: key: value - if (isLeaf) { - return ( -
- {/* Vertical indent lines */} - {Array.from({length:depth},(_,i)=>( -
- ))} -
- {keyName!==null&&{keyName}: } - {renderValue(data)} -
-
- ); +function describeField(f, names) { + switch(f.type) { + case 'all': return null; + case 'step': return `alle ${f.step}`; + case 'stepFrom': return `ab ${f.from} alle ${f.step}`; + case 'list': return f.vals.map(v=>names?.[v]||v).join(', '); + case 'range': return `${names?.[f.from]||f.from}–${names?.[f.to]||f.to}`; + case 'single': return names?.[f.val] || String(f.val); + default: return f.val; } +} - const count = isObj ? Object.keys(data).length : data.length; - const summary = isObj ? `{${count}}` : `[${count}]`; +function explainCron(expr) { + const parts = expr.trim().split(/\s+/); + const specials = {'@yearly':'0 0 1 1 *','@annually':'0 0 1 1 *','@monthly':'0 0 1 * *', + '@weekly':'0 0 * * 0','@daily':'0 0 * * *','@midnight':'0 0 * * *','@hourly':'0 * * * *'}; + if (parts.length===1) { + if (parts[0]==='@reboot') return {ok:true,human:'Beim Systemstart',fields:null}; + if (specials[parts[0]]) return explainCron(specials[parts[0]]); + } + if (parts.length!==5) return {ok:false,error:'Ein Crontab besteht aus 5 Feldern: Minute Stunde Tag Monat Wochentag'}; + const [minP,hrP,domP,monP,dowP] = parts; + const min=parseField(minP), hr=parseField(hrP), dom=parseField(domP), mon=parseField(monP), dow=parseField(dowP); + const out = []; + if (hr.type==='all'&&min.type==='all') out.push('Jede Minute'); + else if (hr.type==='all'&&min.type==='step') out.push(`Alle ${min.step} Minuten`); + else if (hr.type==='all') out.push(`Jede Stunde um :${String(describeField(min)).padStart(2,'0')}`); + else if (min.type==='all') out.push(`Jede Minute der Stunde(n) ${describeField(hr)}`); + else { const m=describeField(min), h=describeField(hr); out.push(`${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} Uhr`); } + if (dow.type!=='all') out.push(`jeden ${describeField(dow,WEEKDAYS)}`); + if (dom.type!=='all') { const d=describeField(dom); out.push(`am ${d}. des Monats`); } + if (mon.type!=='all') out.push(`im ${describeField(mon,MONTHS)}`); + return {ok:true,human:out.join(', '),fields:{min,hr,dom,mon,dow},raw:parts}; +} +function nextRuns(expr, count=5) { + try { + const parts = expr.trim().split(/\s+/); + if (parts.length!==5) return []; + const [minP,hrP,domP,monP,dowP] = parts; + const fMin=parseField(minP), fHr=parseField(hrP), fDom=parseField(domP), fMon=parseField(monP), fDow=parseField(dowP); + const match = (f, v) => { + switch(f.type){ + case 'all': return true; + case 'single': return v===f.val||(f.val===7&&v===0); + case 'range': return v>=f.from&&v<=f.to; + case 'step': return v%f.step===0; + case 'stepFrom': return v>=f.from&&(v-f.from)%f.step===0; + case 'list': return f.vals.includes(v); + default: return false; + } + }; + const results=[], d=new Date(); + d.setSeconds(0); d.setMilliseconds(0); d.setMinutes(d.getMinutes()+1); + for (let i=0;i<50000&&results.length d.toLocaleString('de-DE',{weekday:'short',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}); + const EXAMPLES = [['0 8 * * 1','Mo 8:00'],['*/15 * * * *','alle 15 Min'],['0 0 1 * *','1. jeden Monat'],['30 18 * * 1-5','Mo–Fr 18:30'],['0 9,17 * * *','9 & 17 Uhr'],['0 0 * * 0','So 0:00']]; return ( -
- {/* Vertical indent lines for this level */} - {Array.from({length:depth},(_,i)=>( -
- ))} - - {/* Header row */} -
hasChildren&&onToggle(nodeKey)}> - {hasChildren&&( - - )} - - {keyName!==null&&{keyName}: } - {isCollapsed&&{summary}} - {!isCollapsed&&isArr&&# {count} Einträge} - +
+
+ + setInput(e.target.value)} placeholder="z.B. 0 8 * * 1" + autoCapitalize="none" style={{...S.inp,fontFamily:"'Courier New',monospace",fontSize:16}}/>
- - {/* Children */} - {!isCollapsed && ( +
+ {EXAMPLES.map(([ex,l])=>( + + ))} +
+ {result && (result.ok ? (
- {isObj && Object.entries(data).map(([k,v],i)=>( - - ))} - {isArr && data.map((v,i)=>( -
-
-
- - - {typeof v === 'object' && v !== null - ? - :
- {v===null?null:typeof v==='boolean'?{String(v)}:typeof v==='number'?{v}:{String(v)}} +
+
BEDEUTET
+
{result.human}
+
+ {result.fields && ( +
+ {[['MIN',0],[' STD',1],['TAG',2],['MON',3],['WTAG',4]].map(([lbl,i])=>{ + const raw=expr.split(/\s+/)[i]; + const fNames=i===3?MONTHS:i===4?WEEKDAYS:null; + const fd=Object.values(result.fields)[i]; + return ( +
+
{lbl}
+
{raw}
+
+ {fd?.type==='all'?'jede/r':(describeField(fd,fNames)||'–')}
- } -
+
+ ); + })}
- ))} + )} + {runs.length>0&&( +
+
NÄCHSTE AUSFÜHRUNGEN
+ {runs.map((d,i)=>( +
+ {i+1}. + {fmtDt(d)} +
+ ))} +
+ )}
- )} + ) : ( +
⚠ {result.error}
+ ))}
); } -function collectPaths(data, path='root', paths=[]) { - if (data !== null && typeof data === 'object') { - paths.push(path); - if (Array.isArray(data)) data.forEach((_,i)=>collectPaths(data[i],`${path}[${i}]`,paths)); - else Object.entries(data).forEach(([k,v])=>collectPaths(v,`${path}.${k}`,paths)); - } - return paths; -} +// ── Builder ─────────────────────────────────────────────────────────────────── +function CronBuilder() { + const [minute, setMinute] = useState('0'); + const [hour, setHour] = useState('8'); + const [dom, setDom] = useState('*'); + const [month, setMonth] = useState('*'); + const [dow, setDow] = useState('*'); + const [preset, setPreset] = useState(''); + const [copied, setCopied] = useState(false); -// ── YAML Auto-Fix Stufe 1: Einheits-Normalisierung (kleine Fehler) ──────────── -function fixByUnit(text) { - const lines = text.split('\n').map(l => l.replace(/\t/g, ' ')); - const indents = lines - .filter(l => l.trim() && !l.trim().startsWith('#')) - .map(l => l.search(/\S/)) - .filter(n => n > 0); - if (!indents.length) return text; - const unit = Math.min(...indents); - if (unit === 2) return lines.join('\n'); // bereits 2-space - return lines.map(line => { - if (!line.trim()) return ''; - if (line.trim().startsWith('#')) return line.trimEnd(); - const spaces = line.search(/\S/); - if (spaces <= 0) return line.trimEnd(); - return ' '.repeat(Math.round(spaces / unit)) + line.trimStart().trimEnd(); - }).join('\n'); -} + const cron = `${minute} ${hour} ${dom} ${month} ${dow}`; + const result = explainCron(cron); + const runs = result?.ok ? nextRuns(cron) : []; + const fmtDt = d => d.toLocaleString('de-DE',{weekday:'short',day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); -// ── YAML Auto-Fix Stufe 2: Stack-basiert (starke Schäden) ──────────────────── -function fixByStack(text) { - const lines = text.split('\n').map(l => l.replace(/\t/g, ' ')); - const stack = [{ indent: -1, depth: -1 }]; - const result = []; - for (const line of lines) { - if (!line.trim()) { result.push(''); continue; } - const indent = line.search(/\S/); - const content = line.trim(); - if (content.startsWith('#')) { - result.push(' '.repeat(Math.max(0, stack[stack.length-1].depth + 1)) + content); - continue; - } - while (stack.length > 1 && stack[stack.length-1].indent >= indent) stack.pop(); - const depth = stack[stack.length-1].depth + 1; - stack.push({ indent, depth }); - result.push(' '.repeat(depth) + content); - } - return result.join('\n'); -} + const copy = async () => { try { await navigator.clipboard.writeText(cron); setCopied(true); setTimeout(()=>setCopied(false),1500); } catch {} }; -function shortError(e) { - const first = e.message.split('\n')[0]; - return first.length > 120 ? first.slice(0, 120) + '…' : first; -} + const PRESETS = [ + {k:'every_min', l:'Jede Minute', v:['*','*','*','*','*']}, + {k:'every_hour', l:'Jede Stunde', v:['0','*','*','*','*']}, + {k:'every_day_8', l:'Tägl. 8:00', v:['0','8','*','*','*']}, + {k:'every_mon', l:'Montag 8:00', v:['0','8','*','*','1']}, + {k:'every_fri', l:'Freitag 17:00', v:['0','17','*','*','5']}, + {k:'weekdays_9', l:'Mo–Fr 9:00', v:['0','9','*','*','1-5']}, + {k:'first_month', l:'1. des Monats', v:['0','0','1','*','*']}, + {k:'midnight', l:'Mitternacht', v:['0','0','*','*','*']}, + ]; -// ── YAML Tool ───────────────────────────────────────────────────────────────── -function YamlTool({ mobile }) { - const [input, setInput] = useState(''); - const [parsed, setParsed] = useState(null); - const [formatted,setFormatted]= useState(''); - const [error, setError] = useState(''); - const [warning, setWarning] = useState(''); - const [copied, setCopied] = useState(false); - const [view, setView] = useState('tree'); - const [collapsed,setCollapsed]= useState(new Set()); - - const process = useCallback((text) => { - if (!text.trim()) { setParsed(null); setFormatted(''); setError(''); setWarning(''); return; } - - const tryParse = (t) => { try { return { obj: jsyaml.load(t), err: null }; } catch(e) { return { obj: null, err: e }; } }; - - // Stufe 1: direkt - const r1 = tryParse(text); - if (!r1.err) { - setParsed(r1.obj); setFormatted(jsyaml.dump(r1.obj, {indent:2,lineWidth:-1,noRefs:true})); - setError(''); setWarning(''); setCollapsed(new Set()); return; - } - - // Stufe 2: Unit-Normalisierung (für kleine Fehler wie 4-space statt 2-space) - const fixed1 = fixByUnit(text); - const r2 = tryParse(fixed1); - if (!r2.err) { - setParsed(r2.obj); setFormatted(jsyaml.dump(r2.obj, {indent:2,lineWidth:-1,noRefs:true})); - setWarning('✓ Einrückung normalisiert'); setError(''); setCollapsed(new Set()); return; - } - - // Stufe 3: Stack-Fix (für stärker beschädigte Struktur) - const fixed2 = fixByStack(text); - const r3 = tryParse(fixed2); - if (!r3.err) { - setParsed(r3.obj); setFormatted(jsyaml.dump(r3.obj, {indent:2,lineWidth:-1,noRefs:true})); - setWarning('⚠ Einrückung stark korrigiert – bitte Ergebnis prüfen'); setError(''); setCollapsed(new Set()); return; - } - - // Stufe 4: best-effort – zeige korrigierten Text zum manuellen Nachbessern - setError(shortError(r1.err)); - setWarning('Struktur nicht automatisch reparierbar – normalisierter Text zum manuellen Korrigieren:'); - setParsed(null); - setFormatted(fixed2); - }, []); - - const onInput = v => { setInput(v); process(v); }; - - const copy = async () => { - try { await navigator.clipboard.writeText(formatted); setCopied(true); setTimeout(()=>setCopied(false),1500); } catch {} + const applyPreset = p => { + setPreset(p.k); + const [mi,hr,dm,mo,dw] = p.v; + setMinute(mi); setHour(hr); setDom(dm); setMonth(mo); setDow(dw); }; - const toggleCollapse = path => { - setCollapsed(prev => { - const n = new Set(prev); - n.has(path) ? n.delete(path) : n.add(path); - return n; - }); - }; - - const collapseAll = () => { if(parsed) setCollapsed(new Set(collectPaths(parsed))); }; - const expandAll = () => setCollapsed(new Set()); - - const colCols = mobile ? '1fr' : '1fr 1fr'; + const Lbl = ({c}) =>
{c}
; + const Sel = ({value,onChange,children}) => ( + + ); return (
-
- {/* Input */} -
-
- - {input&&} -
-