338 lines
14 KiB
React
338 lines
14 KiB
React
import { useState, useEffect, useRef, useCallback } from "react";
|
|
|
|
const API = "https://zos-dev.allarddcs.nl/api";
|
|
const STREAM = "https://zos-dev.allarddcs.nl/api/stream/screen";
|
|
|
|
const C = {
|
|
bg: "#0a0f0a",
|
|
bgPanel: "#0d140d",
|
|
green: "#00ff41",
|
|
greenDim: "#00aa2a",
|
|
greenDark: "#004d10",
|
|
amber: "#ffb000",
|
|
red: "#ff3333",
|
|
border: "#1a3a1a",
|
|
};
|
|
|
|
async function api(path, body) {
|
|
const opts = body
|
|
? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
|
|
: { method: "GET" };
|
|
const r = await fetch(API + path, opts);
|
|
return r.json();
|
|
}
|
|
|
|
function TerminalScreen({ screen, onKey, onChar }) {
|
|
const ref = useRef(null);
|
|
const [focused, setFocused] = useState(false);
|
|
|
|
const handleKeyDown = e => {
|
|
e.preventDefault();
|
|
const pfKeys = {
|
|
F1:1,F2:2,F3:3,F4:4,F5:5,F6:6,
|
|
F7:7,F8:8,F9:9,F10:10,F11:11,F12:12,
|
|
};
|
|
if (pfKeys[e.key]) { onKey(`PF(${pfKeys[e.key]})`); return; }
|
|
if (e.key === "Enter") { onKey("Enter()"); return; }
|
|
if (e.key === "Tab") { onKey("Tab()"); return; }
|
|
if (e.key === "Backspace") { onKey("BackSpace()"); return; }
|
|
if (e.key === "Delete") { onKey("Delete()"); return; }
|
|
if (e.key === "Home") { onKey("Home()"); return; }
|
|
if (e.key === "End") { onKey("FieldEnd()"); return; }
|
|
if (e.key === "Insert") { onKey("Insert()"); return; }
|
|
if (e.key === "Escape") { onKey("Clear()"); return; }
|
|
if (e.key === "ArrowUp") { onKey("PF(7)"); return; }
|
|
if (e.key === "ArrowDown") { onKey("PF(8)"); return; }
|
|
// Regular printable character
|
|
if (e.key.length === 1) { onChar(e.key); return; }
|
|
};
|
|
|
|
return (
|
|
<div style={{ position: "relative" }}>
|
|
<pre
|
|
ref={ref}
|
|
tabIndex={0}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={() => setFocused(true)}
|
|
onBlur={() => setFocused(false)}
|
|
onClick={() => ref.current?.focus()}
|
|
style={{
|
|
fontFamily: "'Courier New', Courier, monospace",
|
|
fontSize: "13px", lineHeight: "1.4",
|
|
color: C.green, background: C.bg,
|
|
padding: "12px 16px", margin: 0,
|
|
whiteSpace: "pre", overflowX: "auto",
|
|
minHeight: "340px",
|
|
textShadow: `0 0 8px ${C.green}`,
|
|
border: `2px solid ${focused ? C.green : C.greenDark}`,
|
|
borderRadius: "4px",
|
|
outline: "none",
|
|
cursor: "text",
|
|
}}>
|
|
{screen || "Connecting to z/OS..."}
|
|
</pre>
|
|
<div style={{
|
|
position: "absolute", bottom: "8px", right: "12px",
|
|
fontSize: "10px", color: focused ? C.green : C.greenDark,
|
|
fontFamily: "monospace",
|
|
}}>
|
|
{focused ? "● KEYBOARD ACTIVE" : "○ CLICK TO TYPE"}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusBar({ connected, message }) {
|
|
return (
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: "12px",
|
|
padding: "6px 16px", background: C.bgPanel,
|
|
borderBottom: `1px solid ${C.border}`,
|
|
fontSize: "11px", fontFamily: "monospace", color: C.greenDim,
|
|
}}>
|
|
<span style={{ color: connected ? C.green : C.red, textShadow: "0 0 6px currentColor" }}>
|
|
{connected ? "● ONLINE" : "● OFFLINE"}
|
|
</span>
|
|
<span>z/OS 192.168.2.243</span>
|
|
<span>|</span><span>IBMUSER</span>
|
|
<span>|</span><span>DBSPROC9</span>
|
|
{message && <><span>|</span><span style={{ color: C.amber }}>{message}</span></>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PFBar({ onPF }) {
|
|
return (
|
|
<div style={{
|
|
display: "flex", flexWrap: "wrap", gap: "4px",
|
|
padding: "8px 12px", background: C.bgPanel,
|
|
borderTop: `1px solid ${C.border}`,
|
|
}}>
|
|
{[1,2,3,4,5,6,7,8,9,10,11,12].map(k => (
|
|
<button key={k}
|
|
onClick={() => onPF(k)}
|
|
onTouchStart={e => { e.currentTarget.style.color = C.green; e.currentTarget.style.borderColor = C.green; }}
|
|
onTouchEnd={e => { e.currentTarget.style.color = C.greenDim; e.currentTarget.style.borderColor = C.greenDark; onPF(k); }}
|
|
onMouseEnter={e => { e.currentTarget.style.color = C.green; e.currentTarget.style.borderColor = C.green; }}
|
|
onMouseLeave={e => { e.currentTarget.style.color = C.greenDim; e.currentTarget.style.borderColor = C.greenDark; }}
|
|
style={{
|
|
background: "transparent", border: `1px solid ${C.greenDark}`,
|
|
color: C.greenDim, fontFamily: "monospace",
|
|
fontSize: "11px", padding: "2px 8px", cursor: "pointer", borderRadius: "2px",
|
|
}}>
|
|
F{k}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommandInput({ onSend, onEnter, disabled }) {
|
|
const [cmd, setCmd] = useState("");
|
|
const [history, setHistory] = useState([]);
|
|
const [hIdx, setHIdx] = useState(-1);
|
|
|
|
const submit = () => {
|
|
if (!cmd.trim()) { onEnter(); return; }
|
|
setHistory(h => [cmd, ...h].slice(0, 50));
|
|
setHIdx(-1); onSend(cmd); setCmd("");
|
|
};
|
|
|
|
const onKey = e => {
|
|
if (e.key === "Enter") { e.preventDefault(); submit(); }
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
const i = hIdx + 1;
|
|
if (i < history.length) { setHIdx(i); setCmd(history[i]); }
|
|
}
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
const i = hIdx - 1;
|
|
if (i >= 0) { setHIdx(i); setCmd(history[i]); }
|
|
else { setHIdx(-1); setCmd(""); }
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: "8px",
|
|
padding: "8px 12px", background: C.bgPanel,
|
|
borderTop: `1px solid ${C.border}`,
|
|
}}>
|
|
<span style={{ color: C.greenDim, fontFamily: "monospace", fontSize: "13px" }}>{"COMMAND ===>"}</span>
|
|
<input value={cmd} onChange={e => setCmd(e.target.value)} onKeyDown={onKey} disabled={disabled}
|
|
style={{
|
|
flex: 1, background: "transparent", border: "none",
|
|
borderBottom: `1px solid ${C.greenDark}`,
|
|
color: C.green, fontFamily: "monospace", fontSize: "13px",
|
|
outline: "none", padding: "2px 4px", caretColor: C.green,
|
|
}}
|
|
placeholder="Type command or ISPF option (e.g. =3.4)..."
|
|
autoFocus
|
|
/>
|
|
<button onClick={submit} disabled={disabled} style={{
|
|
background: "transparent", border: `1px solid ${C.green}`,
|
|
color: C.green, fontFamily: "monospace",
|
|
fontSize: "12px", padding: "4px 12px", cursor: "pointer", borderRadius: "2px",
|
|
}}>ENTER</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JCLPanel({ onClose }) {
|
|
const defaultJCL = "//MYJOB JOB (ACCT#),'MY JOB',CLASS=A,MSGCLASS=X\n//STEP1 EXEC PGM=IEFBR14\n//";
|
|
const [jcl, setJcl] = useState(defaultJCL);
|
|
const [result, setResult] = useState("");
|
|
const submit = async () => {
|
|
const r = await api("/jcl/submit", { jcl: jcl.split("\n") });
|
|
setResult(r.screen);
|
|
};
|
|
return (
|
|
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}>
|
|
<div style={{ width: "700px", background: C.bgPanel, border: `1px solid ${C.green}`, borderRadius: "4px", padding: "20px", fontFamily: "monospace" }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "12px" }}>
|
|
<span style={{ color: C.green }}>{"── JCL SUBMIT ──"}</span>
|
|
<button onClick={onClose} style={{ background: "transparent", border: "none", color: C.red, cursor: "pointer", fontSize: "16px" }}>X</button>
|
|
</div>
|
|
<textarea value={jcl} onChange={e => setJcl(e.target.value)} rows={12}
|
|
style={{ width: "100%", boxSizing: "border-box", background: C.bg, color: C.green, border: `1px solid ${C.greenDark}`, fontFamily: "monospace", fontSize: "13px", padding: "8px", resize: "vertical" }} />
|
|
<button onClick={submit} style={{ background: "transparent", border: `1px solid ${C.green}`, color: C.green, fontFamily: "monospace", padding: "6px 16px", cursor: "pointer", marginTop: "8px" }}>SUBMIT JCL</button>
|
|
{result && <TerminalScreen screen={result} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DatasetPanel({ onClose }) {
|
|
const [hlq, setHlq] = useState("IBMUSER");
|
|
const browse = async () => { await api("/datasets/list", { hlq }); onClose(); };
|
|
return (
|
|
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}>
|
|
<div style={{ width: "420px", background: C.bgPanel, border: `1px solid ${C.green}`, borderRadius: "4px", padding: "20px", fontFamily: "monospace" }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "12px" }}>
|
|
<span style={{ color: C.green }}>{"── DATASET LIST (ISPF 3.4) ──"}</span>
|
|
<button onClick={onClose} style={{ background: "transparent", border: "none", color: C.red, cursor: "pointer" }}>X</button>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "12px" }}>
|
|
<span style={{ color: C.greenDim }}>{"HLQ ===>"}</span>
|
|
<input value={hlq} onChange={e => setHlq(e.target.value)}
|
|
style={{ flex: 1, background: "transparent", border: "none", borderBottom: `1px solid ${C.greenDark}`, color: C.green, fontFamily: "monospace", fontSize: "13px", outline: "none", padding: "2px 4px" }} />
|
|
</div>
|
|
<button onClick={browse} style={{ background: "transparent", border: `1px solid ${C.green}`, color: C.green, fontFamily: "monospace", padding: "6px 16px", cursor: "pointer" }}>BROWSE DATASETS</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Toolbar({ onJCL, onDatasets, onSDSF }) {
|
|
const btn = (label, onClick) => (
|
|
<button onClick={onClick} style={{
|
|
background: "transparent", border: `1px solid ${C.greenDark}`,
|
|
color: C.greenDim, fontFamily: "monospace",
|
|
fontSize: "11px", padding: "4px 12px", cursor: "pointer", borderRadius: "2px",
|
|
}}
|
|
onTouchEnd={e => { e.preventDefault(); onClick(); }}
|
|
onMouseEnter={e => { e.target.style.color = C.green; e.target.style.borderColor = C.green; }}
|
|
onMouseLeave={e => { e.target.style.color = C.greenDim; e.target.style.borderColor = C.greenDark; }}>
|
|
{label}
|
|
</button>
|
|
);
|
|
return (
|
|
<div style={{ display: "flex", gap: "8px", padding: "6px 12px", background: C.bgPanel, borderBottom: `1px solid ${C.border}` }}>
|
|
<span style={{ color: C.greenDark, fontSize: "11px", fontFamily: "monospace" }}>QUICK:</span>
|
|
{btn("3.4 DATASETS", onDatasets)}
|
|
{btn("SDSF", onSDSF)}
|
|
{btn("SUBMIT JCL", onJCL)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
const [screen, setScreen] = useState("");
|
|
const [connected, setConnected] = useState(false);
|
|
const [message, setMessage] = useState("Connecting...");
|
|
const [showJCL, setShowJCL] = useState(false);
|
|
const [showDS, setShowDS] = useState(false);
|
|
const wsRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
const check = async () => {
|
|
try {
|
|
const r = await api("/health");
|
|
setConnected(r.connected);
|
|
setMessage(r.connected ? "" : "Backend disconnected from z/OS");
|
|
} catch { setConnected(false); setMessage("Cannot reach backend"); }
|
|
};
|
|
check();
|
|
const t = setInterval(check, 10000);
|
|
return () => clearInterval(t);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const connect = () => {
|
|
const es = new EventSource(STREAM);
|
|
es.onmessage = e => {
|
|
const d = JSON.parse(e.data);
|
|
if (d.screen) setScreen(d.screen);
|
|
setMessage("");
|
|
};
|
|
es.onerror = () => {
|
|
es.close();
|
|
setTimeout(connect, 3000);
|
|
};
|
|
wsRef.current = es;
|
|
};
|
|
connect();
|
|
return () => wsRef.current?.close();
|
|
}, []);
|
|
|
|
const sendCommand = useCallback(async cmd => {
|
|
const r = await api("/command", { text: cmd });
|
|
if (r.screen) setScreen(r.screen);
|
|
}, []);
|
|
|
|
const sendKey = useCallback(async (key) => {
|
|
const r = await api("/key", { key });
|
|
if (r && r.screen) setScreen(r.screen);
|
|
}, []);
|
|
|
|
const sendChar = useCallback(async (char) => {
|
|
const r = await api("/char", { char });
|
|
if (r && r.screen) setScreen(r.screen);
|
|
}, []);
|
|
|
|
const sendEnter = useCallback(async () => {
|
|
const r = await api("/enter", {});
|
|
if (r && r.screen) setScreen(r.screen);
|
|
}, []);
|
|
|
|
const sendPF = useCallback(async key => {
|
|
const r = await api(`/pf/${key}`, {});
|
|
if (r.screen) setScreen(r.screen);
|
|
}, []);
|
|
|
|
const openSDSF = async () => {
|
|
const r = await api("/sdsf");
|
|
if (r.screen) setScreen(r.screen);
|
|
};
|
|
|
|
return (
|
|
<div style={{ minHeight: "100vh", background: C.bg, color: C.green, fontFamily: "monospace", display: "flex", flexDirection: "column" }}>
|
|
<div style={{ padding: "10px 16px", background: C.bgPanel, borderBottom: `2px solid ${C.greenDark}`, display: "flex", alignItems: "center", gap: "16px" }}>
|
|
<span style={{ fontSize: "18px", fontWeight: "bold", color: C.green, textShadow: `0 0 12px ${C.green}`, letterSpacing: "2px" }}>z/OS WEB CONSOLE</span>
|
|
<span style={{ color: C.greenDark, fontSize: "11px" }}>TSO/ISPF - Hercules z/OS - 192.168.2.243</span>
|
|
</div>
|
|
<StatusBar connected={connected} message={message} />
|
|
<Toolbar onJCL={() => setShowJCL(true)} onDatasets={() => setShowDS(true)} onSDSF={openSDSF} />
|
|
<div style={{ flex: 1, padding: "12px", overflow: "auto" }}>
|
|
<TerminalScreen screen={screen} onKey={sendKey} onChar={sendChar} />
|
|
</div>
|
|
<CommandInput onSend={sendCommand} onEnter={sendEnter} disabled={false} />
|
|
<PFBar onPF={sendPF} />
|
|
{showJCL && <JCLPanel onClose={() => setShowJCL(false)} />}
|
|
{showDS && <DatasetPanel onClose={() => setShowDS(false)} />}
|
|
<div style={{ position: "fixed", inset: 0, background: "repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,255,65,0.015) 2px, rgba(0,255,65,0.015) 4px)", pointerEvents: "none", zIndex: 999 }} />
|
|
</div>
|
|
);
|
|
}
|