Dev-Tools: YAML raus, Crontab-Generator rein (Erklären + Erstellen)
This commit is contained in:
@@ -7,7 +7,6 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
|
||||
@@ -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)';
|
||||
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 valueColor = (v) => {
|
||||
if (v === null || v === undefined) return nullColor;
|
||||
if (typeof v === 'boolean') return boolColor;
|
||||
if (typeof v === 'number') return numColor;
|
||||
return strColor;
|
||||
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 renderValue = (v) => {
|
||||
if (v === null) return <span style={{color:nullColor}}>null</span>;
|
||||
if (v === undefined) return <span style={{color:nullColor}}>~</span>;
|
||||
if (typeof v === 'boolean') return <span style={{color:boolColor}}>{String(v)}</span>;
|
||||
if (typeof v === 'number') return <span style={{color:numColor}}>{v}</span>;
|
||||
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 <span style={{color:strColor}}>{needsQuotes ? `"${s.replace(/"/g,'\\"')}"` : s}</span>;
|
||||
};
|
||||
|
||||
// Leaf: key: value
|
||||
if (isLeaf) {
|
||||
return (
|
||||
<div style={{display:'flex',alignItems:'baseline',minHeight:20,position:'relative'}}>
|
||||
{/* Vertical indent lines */}
|
||||
{Array.from({length:depth},(_,i)=>(
|
||||
<div key={i} style={{position:'absolute',left:i*18+6,top:0,bottom:0,
|
||||
borderLeft:`1px solid ${indentColor}`,pointerEvents:'none'}}/>
|
||||
))}
|
||||
<div style={{paddingLeft:indent,fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7,whiteSpace:'pre-wrap',wordBreak:'break-all'}}>
|
||||
{keyName!==null&&<span style={{color:keyColor}}>{keyName}<span style={{color:'rgba(255,255,255,0.4)'}}>: </span></span>}
|
||||
{renderValue(data)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const results=[], d=new Date();
|
||||
d.setSeconds(0); d.setMilliseconds(0); d.setMinutes(d.getMinutes()+1);
|
||||
for (let i=0;i<50000&&results.length<count;i++){
|
||||
if (match(fMon,d.getMonth()+1)&&match(fDom,d.getDate())&&
|
||||
(fDow.type==='all'||match(fDow,d.getDay()))&&
|
||||
match(fHr,d.getHours())&&match(fMin,d.getMinutes())) results.push(new Date(d));
|
||||
d.setMinutes(d.getMinutes()+1);
|
||||
}
|
||||
return results;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
const count = isObj ? Object.keys(data).length : data.length;
|
||||
const summary = isObj ? `{${count}}` : `[${count}]`;
|
||||
|
||||
return (
|
||||
<div style={{position:'relative'}}>
|
||||
{/* Vertical indent lines for this level */}
|
||||
{Array.from({length:depth},(_,i)=>(
|
||||
<div key={i} style={{position:'absolute',left:i*18+6,top:0,bottom:0,
|
||||
borderLeft:`1px solid ${indentColor}`,pointerEvents:'none'}}/>
|
||||
))}
|
||||
|
||||
{/* Header row */}
|
||||
<div style={{display:'flex',alignItems:'center',minHeight:20,cursor:hasChildren?'pointer':'default',
|
||||
paddingLeft:indent,userSelect:'none'}}
|
||||
onClick={()=>hasChildren&&onToggle(nodeKey)}>
|
||||
{hasChildren&&(
|
||||
<span style={{color:'rgba(255,255,255,0.4)',fontSize:9,marginRight:5,flexShrink:0,
|
||||
transform:isCollapsed?'rotate(-90deg)':'rotate(0)',display:'inline-block',transition:'transform 0.15s'}}>▼</span>
|
||||
)}
|
||||
<span style={{fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7}}>
|
||||
{keyName!==null&&<span style={{color:keyColor}}>{keyName}<span style={{color:'rgba(255,255,255,0.4)'}}>: </span></span>}
|
||||
{isCollapsed&&<span style={{color:'rgba(255,255,255,0.3)',fontSize:10}}>{summary}</span>}
|
||||
{!isCollapsed&&isArr&&<span style={{color:'rgba(255,255,255,0.25)',fontSize:10}}># {count} Einträge</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
{isObj && Object.entries(data).map(([k,v],i)=>(
|
||||
<YamlNode key={k} data={v} depth={depth+1} keyName={k}
|
||||
collapsed={collapsed} onToggle={onToggle} path={`${path}.${k}`}/>
|
||||
))}
|
||||
{isArr && data.map((v,i)=>(
|
||||
<div key={i} style={{position:'relative'}}>
|
||||
<div style={{position:'absolute',left:(depth)*18+6,top:10,width:8,
|
||||
borderTop:`1px solid ${indentColor}`}}/>
|
||||
<div style={{paddingLeft:(depth+1)*18,minHeight:20,display:'flex',alignItems:'baseline'}}>
|
||||
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:"'Courier New',monospace",fontSize:12,
|
||||
position:'absolute',left:(depth)*18+8}}>-</span>
|
||||
{typeof v === 'object' && v !== null
|
||||
? <YamlNode data={v} depth={depth+1} keyName={null}
|
||||
collapsed={collapsed} onToggle={onToggle} path={`${path}[${i}]`}/>
|
||||
: <div style={{fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7}}>
|
||||
{v===null?<span style={{color:nullColor}}>null</span>:typeof v==='boolean'?<span style={{color:boolColor}}>{String(v)}</span>:typeof v==='number'?<span style={{color:numColor}}>{v}</span>:<span style={{color:strColor}}>{String(v)}</span>}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
}
|
||||
|
||||
function shortError(e) {
|
||||
const first = e.message.split('\n')[0];
|
||||
return first.length > 120 ? first.slice(0, 120) + '…' : first;
|
||||
}
|
||||
|
||||
// ── YAML Tool ─────────────────────────────────────────────────────────────────
|
||||
function YamlTool({ mobile }) {
|
||||
// ── Erklärer ──────────────────────────────────────────────────────────────────
|
||||
function CronExplainer() {
|
||||
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 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 expr = input.trim();
|
||||
const result = expr ? explainCron(expr) : null;
|
||||
const runs = result?.ok ? nextRuns(expr) : [];
|
||||
const fmtDt = d => 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 (
|
||||
<div>
|
||||
<div style={{display:'grid',gridTemplateColumns:colCols,gap:12,marginBottom:10}}>
|
||||
{/* Input */}
|
||||
<div>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6}}>
|
||||
<label style={{...S.head,marginBottom:0,fontSize:10}}>EINGABE</label>
|
||||
{input&&<button onClick={()=>{setInput('');setParsed(null);setFormatted('');setError('');}}
|
||||
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.3)',cursor:'pointer',fontFamily:'monospace',fontSize:10}}>✕</button>}
|
||||
<div style={{marginBottom:10}}>
|
||||
<label style={{...S.head,display:'block',marginBottom:6,fontSize:10}}>CRONTAB AUSDRUCK</label>
|
||||
<input value={input} onChange={e=>setInput(e.target.value)} placeholder="z.B. 0 8 * * 1"
|
||||
autoCapitalize="none" style={{...S.inp,fontFamily:"'Courier New',monospace",fontSize:16}}/>
|
||||
</div>
|
||||
<textarea value={input} onChange={e=>onInput(e.target.value)}
|
||||
placeholder={'name: Beispiel\nversion: 1.0\nkonfiguration:\n host: localhost\n port: 8080\nliste:\n - item1\n - item2'}
|
||||
rows={mobile?10:18}
|
||||
style={{...S.inp,resize:'vertical',fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7,
|
||||
width:'100%',boxSizing:'border-box'}}/>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<div>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6,flexWrap:'wrap',gap:6}}>
|
||||
<label style={{...S.head,marginBottom:0,fontSize:10}}>ERGEBNIS</label>
|
||||
<div style={{display:'flex',gap:5,flexWrap:'wrap'}}>
|
||||
{/* View toggle */}
|
||||
{['tree','raw'].map(v=>(
|
||||
<button key={v} onClick={()=>setView(v)} style={{
|
||||
padding:'2px 8px',borderRadius:5,fontFamily:'monospace',fontSize:9,cursor:'pointer',
|
||||
background:view===v?'rgba(78,205,196,0.15)':'transparent',
|
||||
border:`1px solid ${view===v?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
|
||||
color:view===v?'#4ecdc4':'rgba(255,255,255,0.4)'}}>
|
||||
{v==='tree'?'🌲 Baum':'📄 Raw'}
|
||||
<div style={{display:'flex',gap:5,flexWrap:'wrap',marginBottom:16}}>
|
||||
{EXAMPLES.map(([ex,l])=>(
|
||||
<button key={ex} onClick={()=>setInput(ex)} style={{padding:'4px 10px',borderRadius:6,
|
||||
fontFamily:'monospace',fontSize:10,cursor:'pointer',
|
||||
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',color:'rgba(255,255,255,0.5)'}}>
|
||||
<code>{ex}</code> <span style={{color:'rgba(255,255,255,0.25)',fontSize:9}}>({l})</span>
|
||||
</button>
|
||||
))}
|
||||
{parsed&&view==='tree'&&<>
|
||||
<button onClick={collapseAll} style={{padding:'2px 8px',borderRadius:5,fontFamily:'monospace',fontSize:9,cursor:'pointer',background:'transparent',border:'1px solid rgba(255,255,255,0.1)',color:'rgba(255,255,255,0.4)'}}>− Alle</button>
|
||||
<button onClick={expandAll} style={{padding:'2px 8px',borderRadius:5,fontFamily:'monospace',fontSize:9,cursor:'pointer',background:'transparent',border:'1px solid rgba(255,255,255,0.1)',color:'rgba(255,255,255,0.4)'}}>+ Alle</button>
|
||||
</>}
|
||||
{formatted&&<button onClick={copy} style={{
|
||||
padding:'2px 9px',borderRadius:5,fontFamily:'monospace',fontSize:9,cursor:'pointer',
|
||||
background:copied?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.05)',
|
||||
border:`1px solid ${copied?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
|
||||
color:copied?'#4ecdc4':'rgba(255,255,255,0.5)'}}>
|
||||
{copied?'✓':'⎘'} Kopieren
|
||||
</button>}
|
||||
</div>
|
||||
{result && (result.ok ? (
|
||||
<div>
|
||||
<div style={{background:'rgba(78,205,196,0.08)',border:'1px solid rgba(78,205,196,0.2)',
|
||||
borderRadius:10,padding:'14px 16px',marginBottom:12}}>
|
||||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,marginBottom:5}}>BEDEUTET</div>
|
||||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:16,fontWeight:700}}>{result.human}</div>
|
||||
</div>
|
||||
{result.fields && (
|
||||
<div style={{display:'grid',gridTemplateColumns:'repeat(5,1fr)',gap:8,marginBottom:12}}>
|
||||
{[['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 (
|
||||
<div key={lbl} style={{background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.07)',
|
||||
borderRadius:8,padding:'8px 10px',textAlign:'center'}}>
|
||||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:8,marginBottom:4}}>{lbl}</div>
|
||||
<div style={{color:'#4ecdc4',fontFamily:"'Courier New',monospace",fontSize:14,fontWeight:700}}>{raw}</div>
|
||||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:9,marginTop:3}}>
|
||||
{fd?.type==='all'?'jede/r':(describeField(fd,fNames)||'–')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{runs.length>0&&(
|
||||
<div>
|
||||
<div style={{...S.head,marginBottom:6,fontSize:10}}>NÄCHSTE AUSFÜHRUNGEN</div>
|
||||
{runs.map((d,i)=>(
|
||||
<div key={i} style={{display:'flex',alignItems:'center',gap:10,padding:'5px 0',borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
|
||||
<span style={{color:'rgba(78,205,196,0.5)',fontFamily:'monospace',fontSize:10,minWidth:16,textAlign:'right'}}>{i+1}.</span>
|
||||
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:11}}>{fmtDt(d)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{background:'rgba(255,107,157,0.08)',border:'1px solid rgba(255,107,157,0.2)',
|
||||
borderRadius:8,padding:12,color:'#ff6b9d',fontFamily:'monospace',fontSize:11}}>⚠ {result.error}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
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'});
|
||||
|
||||
const copy = async () => { try { await navigator.clipboard.writeText(cron); setCopied(true); setTimeout(()=>setCopied(false),1500); } catch {} };
|
||||
|
||||
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','*','*','*']},
|
||||
];
|
||||
|
||||
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 Lbl = ({c}) => <div style={{...S.head,marginBottom:4,fontSize:9}}>{c}</div>;
|
||||
const Sel = ({value,onChange,children}) => (
|
||||
<select value={value} onChange={e=>{onChange(e.target.value);setPreset('');}}
|
||||
style={{...S.inp,fontSize:12,padding:'6px 8px',width:'100%'}}>{children}</select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{marginBottom:14}}>
|
||||
<Lbl c="SCHNELLAUSWAHL"/>
|
||||
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
|
||||
{PRESETS.map(p=>(
|
||||
<button key={p.k} onClick={()=>applyPreset(p)} style={{
|
||||
padding:'5px 11px',borderRadius:6,fontFamily:'monospace',fontSize:10,cursor:'pointer',
|
||||
background:preset===p.k?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.05)',
|
||||
border:`1px solid ${preset===p.k?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
|
||||
color:preset===p.k?'#4ecdc4':'rgba(255,255,255,0.55)',
|
||||
}}>{p.l}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !formatted ? (
|
||||
<div style={{background:'rgba(255,107,157,0.08)',border:'1px solid rgba(255,107,157,0.2)',
|
||||
borderRadius:8,padding:14,color:'#ff6b9d',fontFamily:'monospace',fontSize:11,lineHeight:1.7}}>
|
||||
⚠ {error}
|
||||
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:10,marginBottom:14}}>
|
||||
<div><Lbl c="MINUTE"/>
|
||||
<Sel value={minute} onChange={setMinute}>
|
||||
<option value="*">* jede</option>
|
||||
{[5,10,15,20,30].map(n=><option key={n} value={`*/${n}`}>alle {n} min</option>)}
|
||||
{Array.from({length:60},(_,i)=><option key={i} value={i}>{String(i).padStart(2,'0')}</option>)}
|
||||
</Sel>
|
||||
</div>
|
||||
) : (<>
|
||||
{warning && (
|
||||
<div style={{background: error ? 'rgba(255,230,109,0.07)' : 'rgba(78,205,196,0.07)',
|
||||
border:`1px solid ${error ? 'rgba(255,230,109,0.25)' : 'rgba(78,205,196,0.2)'}`,
|
||||
borderRadius:7,padding:'7px 12px',
|
||||
color: error ? '#ffe66d' : '#4ecdc4',
|
||||
fontFamily:'monospace',fontSize:10,lineHeight:1.6,marginBottom:8}}>
|
||||
{error ? '⚠ ' : '✓ '}{warning}
|
||||
<div><Lbl c="STUNDE"/>
|
||||
<Sel value={hour} onChange={setHour}>
|
||||
<option value="*">* jede</option>
|
||||
{Array.from({length:24},(_,i)=><option key={i} value={i}>{String(i).padStart(2,'0')}:00</option>)}
|
||||
<option value="9,17">9 & 17 Uhr</option>
|
||||
<option value="8,12,17">8, 12 & 17 Uhr</option>
|
||||
</Sel>
|
||||
</div>
|
||||
<div><Lbl c="WOCHENTAG"/>
|
||||
<Sel value={dow} onChange={setDow}>
|
||||
<option value="*">* jeder</option>
|
||||
<option value="1-5">Mo–Fr (Werktage)</option>
|
||||
<option value="6,0">Sa & So (Wochenende)</option>
|
||||
{WEEKDAYS.map((d,i)=><option key={i} value={i}>{d}</option>)}
|
||||
</Sel>
|
||||
</div>
|
||||
<div><Lbl c="TAG DES MONATS"/>
|
||||
<Sel value={dom} onChange={setDom}>
|
||||
<option value="*">* jeder</option>
|
||||
{Array.from({length:31},(_,i)=><option key={i+1} value={i+1}>{i+1}.</option>)}
|
||||
<option value="1,15">1. & 15.</option>
|
||||
</Sel>
|
||||
</div>
|
||||
<div><Lbl c="MONAT"/>
|
||||
<Sel value={month} onChange={setMonth}>
|
||||
<option value="*">* jeder</option>
|
||||
{MONTHS.map((m,i)=><option key={i+1} value={i+1}>{m}</option>)}
|
||||
</Sel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{background:'rgba(0,0,0,0.3)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:10,padding:'14px 16px'}}>
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:10,flexWrap:'wrap',gap:8}}>
|
||||
<code style={{color:'#4ecdc4',fontFamily:"'Courier New',monospace",fontSize:22,fontWeight:700,letterSpacing:3}}>{cron}</code>
|
||||
<button onClick={copy} style={{
|
||||
background:copied?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.06)',
|
||||
border:`1px solid ${copied?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
|
||||
borderRadius:7,color:copied?'#4ecdc4':'rgba(255,255,255,0.5)',
|
||||
cursor:'pointer',fontFamily:'monospace',fontSize:11,padding:'6px 14px'}}>
|
||||
{copied?'✓ Kopiert':'⎘ Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
{result?.ok&&<div style={{color:'rgba(255,255,255,0.65)',fontFamily:'monospace',fontSize:13,marginBottom:10}}>{result.human}</div>}
|
||||
{runs.length>0&&(
|
||||
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
|
||||
{runs.slice(0,3).map((d,i)=>(
|
||||
<span key={i} style={{background:'rgba(255,255,255,0.04)',borderRadius:5,padding:'3px 8px',
|
||||
color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>{fmtDt(d)}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && formatted && (
|
||||
<div style={{background:'rgba(255,107,157,0.06)',border:'1px solid rgba(255,107,157,0.15)',
|
||||
borderRadius:7,padding:'6px 12px',color:'rgba(255,107,157,0.7)',
|
||||
fontFamily:'monospace',fontSize:9,lineHeight:1.6,marginBottom:8}}>
|
||||
⚠ {error}
|
||||
</div>
|
||||
)}
|
||||
{view==='raw' ? (
|
||||
<pre style={{margin:0,padding:14,minHeight:200,background:'rgba(0,0,0,0.35)',
|
||||
border:'1px solid rgba(255,255,255,0.07)',borderRadius:8,
|
||||
color:formatted?'#d4e6b5':'rgba(255,255,255,0.15)',
|
||||
fontFamily:"'Courier New',monospace",fontSize:12,lineHeight:1.7,
|
||||
overflowX:'auto',whiteSpace:'pre',maxHeight:440,overflowY:'auto'}}>
|
||||
{formatted||'# Ergebnis erscheint hier'}
|
||||
</pre>
|
||||
) : parsed!==null ? (
|
||||
<div style={{background:'rgba(0,0,0,0.35)',border:'1px solid rgba(255,255,255,0.07)',
|
||||
borderRadius:8,padding:'10px 10px 10px 12px',minHeight:200,maxHeight:440,overflowY:'auto',
|
||||
overflowX:'auto',position:'relative'}}>
|
||||
<YamlNode data={parsed} depth={0} keyName={null}
|
||||
collapsed={collapsed} onToggle={toggleCollapse} path="root"/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{background:'rgba(0,0,0,0.2)',border:'1px solid rgba(255,255,255,0.06)',
|
||||
borderRadius:8,padding:30,textAlign:'center',color:'rgba(255,255,255,0.15)',
|
||||
fontFamily:'monospace',fontSize:11,minHeight:100,display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||||
YAML eingeben → wird live formatiert
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{color:'rgba(255,255,255,0.15)',fontFamily:'monospace',fontSize:9}}>
|
||||
Powered by js-yaml · 2-Space-Einrückung · YAML 1.2 kompatibel
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -332,50 +271,63 @@ function YamlTool({ mobile }) {
|
||||
|
||||
// ── Tool-Definitionen ─────────────────────────────────────────────────────────
|
||||
const TOOL_DEFS = [
|
||||
{ id:'yaml', label:'📄 YAML Formatter', desc:'YAML korrekt einrücken und formatieren', ready:true, component:YamlTool },
|
||||
{ id:'json', label:'{ } JSON Formatter', desc:'JSON formatieren und validieren', ready:false },
|
||||
{ id:'base64', label:'🔐 Base64', desc:'Text ↔ Base64 kodieren/dekodieren', ready:false },
|
||||
{ id:'regex', label:'🔍 Regex Tester', desc:'Reguläre Ausdrücke live testen', ready:false },
|
||||
{ id:'diff', label:'± Text Diff', desc:'Zwei Texte vergleichen', ready:false },
|
||||
{ id:'jwt', label:'🔑 JWT Decoder', desc:'JSON Web Tokens dekodieren', ready:false },
|
||||
{id:'cron', label:'⏱ Crontab', desc:'Crontab erklären und erstellen', ready:true,
|
||||
tabs:[{id:'explain',label:'Erklären'},{id:'build',label:'Erstellen'}],
|
||||
components:{explain:CronExplainer,build:CronBuilder}},
|
||||
{id:'json', label:'{ } JSON', desc:'JSON formatieren', ready:false},
|
||||
{id:'base64', label:'🔐 Base64', desc:'Text ↔ Base64', ready:false},
|
||||
{id:'regex', label:'🔍 Regex', desc:'Regex testen', ready:false},
|
||||
{id:'diff', label:'± Text Diff', desc:'Texte vergleichen', ready:false},
|
||||
{id:'jwt', label:'🔑 JWT', desc:'JWT dekodieren', ready:false},
|
||||
{id:'timestamp', label:'🕐 Timestamp', desc:'Unix Timestamps umrechnen', ready:false},
|
||||
{ id:'color', label:'🎨 Farben', desc:'HEX ↔ RGB ↔ HSL umrechnen', ready:false },
|
||||
{id:'color', label:'🎨 Farben', desc:'HEX/RGB/HSL umrechnen', ready:false},
|
||||
];
|
||||
|
||||
export default function DevTools({ toast, mobile }) {
|
||||
const [activeTool, setActiveTool] = useState('yaml');
|
||||
const [activeTool, setActiveTool] = useState('cron');
|
||||
const [activeTab, setActiveTab] = useState('explain');
|
||||
const active = TOOL_DEFS.find(t=>t.id===activeTool);
|
||||
const ActiveComp = active?.tabs ? active.components[activeTab] : active?.component;
|
||||
|
||||
return (
|
||||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:1100}}>
|
||||
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:860}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:12,marginBottom:20,flexWrap:'wrap'}}>
|
||||
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Dev-Tools</h1>
|
||||
<span style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:10}}>Konverter · Formatter · Helfer</span>
|
||||
</div>
|
||||
|
||||
<div style={{display:'flex',gap:8,flexWrap:'wrap',marginBottom:20}}>
|
||||
<div style={{display:'flex',gap:7,flexWrap:'wrap',marginBottom:20}}>
|
||||
{TOOL_DEFS.map(t=>(
|
||||
<button key={t.id}
|
||||
onClick={()=>t.ready ? setActiveTool(t.id) : toast('Kommt bald 🚧','error')}
|
||||
title={t.desc}
|
||||
style={{
|
||||
padding:'8px 14px',borderRadius:10,fontFamily:'monospace',fontSize:11,cursor:t.ready?'pointer':'default',
|
||||
<button key={t.id} title={t.desc}
|
||||
onClick={()=>{ if(t.ready){setActiveTool(t.id);setActiveTab(t.tabs?.[0]?.id||'');} else toast('Kommt bald 🚧'); }}
|
||||
style={{padding:'8px 14px',borderRadius:10,fontFamily:'monospace',fontSize:11,cursor:t.ready?'pointer':'default',
|
||||
background:activeTool===t.id?'#4ecdc4':t.ready?'rgba(255,255,255,0.06)':'rgba(255,255,255,0.02)',
|
||||
color: activeTool===t.id?'#0d0d0f':t.ready?'rgba(255,255,255,0.65)':'rgba(255,255,255,0.2)',
|
||||
border: activeTool===t.id?'none':t.ready?'1px solid rgba(255,255,255,0.1)':'1px solid rgba(255,255,255,0.05)',
|
||||
fontWeight: activeTool===t.id?700:400,
|
||||
}}>
|
||||
{t.label}{!t.ready&&<span style={{marginLeft:4,fontSize:8,opacity:0.5}}>bald</span>}
|
||||
fontWeight: activeTool===t.id?700:400}}>
|
||||
{t.label}{!t.ready&&<span style={{marginLeft:4,fontSize:8,opacity:0.4}}>bald</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{...S.card}}>
|
||||
<div style={{display:'flex',alignItems:'baseline',gap:10,marginBottom:14}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:14,flexWrap:'wrap'}}>
|
||||
<div style={{...S.head,marginBottom:0}}>{active?.label}</div>
|
||||
<span style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:10}}>{active?.desc}</span>
|
||||
</div>
|
||||
{active?.component && <active.component toast={toast} mobile={mobile}/>}
|
||||
{active?.tabs&&(
|
||||
<div style={{display:'flex',gap:6,marginBottom:16}}>
|
||||
{active.tabs.map(tab=>(
|
||||
<button key={tab.id} onClick={()=>setActiveTab(tab.id)} style={{
|
||||
padding:'6px 18px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
|
||||
background:activeTab===tab.id?'#4ecdc4':'rgba(255,255,255,0.05)',
|
||||
color: activeTab===tab.id?'#0d0d0f':'rgba(255,255,255,0.5)',
|
||||
border: activeTab===tab.id?'none':'1px solid rgba(255,255,255,0.1)',
|
||||
fontWeight: activeTab===tab.id?700:400}}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ActiveComp&&<ActiveComp toast={toast} mobile={mobile}/>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user