eindelijk weer eens een push
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
FROM ubuntu:24.04
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Layer 1: OS packages - only rebuilds if this changes
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 python3-pip x3270 s3270 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Layer 2: Python deps - only rebuilds if requirements.txt changes
|
||||
COPY requirements.txt .
|
||||
RUN pip3 install --break-system-packages -r requirements.txt
|
||||
|
||||
# Layer 3: Python code - rebuilds on every code change
|
||||
COPY *.py .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
|
||||
@@ -0,0 +1,125 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from contextlib import asynccontextmanager
|
||||
from tn3270_service import TN3270Session
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
session = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
global session
|
||||
session = TN3270Session()
|
||||
try:
|
||||
session.connect()
|
||||
logger.info("TN3270 session ready")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect: {e}")
|
||||
yield
|
||||
if session:
|
||||
session.disconnect()
|
||||
|
||||
app = FastAPI(title="z/OS Web API", version="1.0.0", lifespan=lifespan)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
def check():
|
||||
if not session or not session.connected:
|
||||
raise HTTPException(503, "Not connected to z/OS")
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "connected": session.connected if session else False}
|
||||
|
||||
@app.get("/screen")
|
||||
def get_screen():
|
||||
check()
|
||||
return {"screen": session.get_screen()}
|
||||
|
||||
@app.post("/command")
|
||||
def send_command(req: CommandRequest):
|
||||
check()
|
||||
return {"screen": session.send_command(req.text)}
|
||||
|
||||
@app.post("/enter")
|
||||
def send_enter():
|
||||
check()
|
||||
return {"screen": session.send_enter()}
|
||||
|
||||
@app.post("/pf/{key}")
|
||||
def send_pf(key: int):
|
||||
check()
|
||||
if not 1 <= key <= 24:
|
||||
raise HTTPException(400, "PF key must be 1-24")
|
||||
return {"screen": session.send_pf(key)}
|
||||
|
||||
@app.post("/tab")
|
||||
def send_tab():
|
||||
check()
|
||||
return {"screen": session.send_tab()}
|
||||
|
||||
class KeyRequest(BaseModel):
|
||||
key: str
|
||||
|
||||
class CharRequest(BaseModel):
|
||||
char: str
|
||||
|
||||
@app.post("/key")
|
||||
def send_key(req: KeyRequest):
|
||||
check()
|
||||
# Map key names to s3270 commands
|
||||
key_map = {
|
||||
"Tab": "Tab()",
|
||||
"BackTab": "BackTab()",
|
||||
"Backspace": "BackSpace()",
|
||||
"Delete": "Delete()",
|
||||
"Home": "Home()",
|
||||
"End": "FieldEnd()",
|
||||
"Insert": "Insert()",
|
||||
"Clear": "Clear()",
|
||||
"Attn": "Attn()",
|
||||
"PA1": "PA(1)",
|
||||
"PA2": "PA(2)",
|
||||
}
|
||||
s3270_key = key_map.get(req.key, req.key)
|
||||
return {"screen": session.send_key(s3270_key)}
|
||||
|
||||
@app.post("/char")
|
||||
def send_char(req: CharRequest):
|
||||
check()
|
||||
return {"screen": session.send_char(req.char)}
|
||||
|
||||
@app.get("/stream/screen")
|
||||
async def stream_screen():
|
||||
async def event_generator():
|
||||
last_screen = ""
|
||||
while True:
|
||||
try:
|
||||
if session and session.connected:
|
||||
if not session.lock.locked():
|
||||
screen = session.get_screen()
|
||||
if screen != last_screen:
|
||||
data = json.dumps({"screen": screen})
|
||||
yield f"data: {data}\n\n"
|
||||
last_screen = screen
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception as e:
|
||||
logger.error(f"Stream error: {e}")
|
||||
break
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.30.6
|
||||
pydantic==2.9.2
|
||||
websockets==13.1
|
||||
@@ -0,0 +1,109 @@
|
||||
import subprocess
|
||||
import time
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TN3270Session:
|
||||
def __init__(self):
|
||||
self.host = os.environ.get("ZOS_HOST", "192.168.2.243")
|
||||
self.port = os.environ.get("ZOS_PORT", "23")
|
||||
self.s3270 = None
|
||||
self.lock = threading.Lock()
|
||||
self.connected = False
|
||||
|
||||
def _start_s3270(self):
|
||||
self.s3270 = subprocess.Popen(
|
||||
["s3270", "-model", "3279-2", f"{self.host}:{self.port}"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
time.sleep(2)
|
||||
|
||||
def _send(self, command):
|
||||
self.s3270.stdin.write(command + "\n")
|
||||
self.s3270.stdin.flush()
|
||||
return self._read_response()
|
||||
|
||||
def _read_response(self):
|
||||
lines = []
|
||||
while True:
|
||||
line = self.s3270.stdout.readline()
|
||||
if not line:
|
||||
return False, lines
|
||||
line = line.rstrip("\n")
|
||||
if line == "ok":
|
||||
return True, lines
|
||||
if line == "error":
|
||||
return False, lines
|
||||
if line.startswith("data: "):
|
||||
line = line[6:]
|
||||
lines.append(line)
|
||||
|
||||
def connect(self):
|
||||
logger.info(f"Connecting to {self.host}:{self.port}")
|
||||
self._start_s3270()
|
||||
time.sleep(2)
|
||||
self.connected = True
|
||||
logger.info("TN3270 connected - waiting for user input")
|
||||
|
||||
def disconnect(self):
|
||||
try:
|
||||
self.s3270.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self.connected = False
|
||||
|
||||
def get_screen(self):
|
||||
with self.lock:
|
||||
ok, lines = self._send("Ascii()")
|
||||
return "\n".join(lines)
|
||||
|
||||
def send_command(self, command):
|
||||
with self.lock:
|
||||
self._send(f'String("{command}")')
|
||||
self._send("Enter()")
|
||||
time.sleep(0.5)
|
||||
ok, lines = self._send("Ascii()")
|
||||
return "\n".join(lines)
|
||||
|
||||
def send_pf(self, key):
|
||||
with self.lock:
|
||||
self._send(f"PF({key})")
|
||||
time.sleep(0.3)
|
||||
ok, lines = self._send("Ascii()")
|
||||
return "\n".join(lines)
|
||||
|
||||
def send_tab(self):
|
||||
with self.lock:
|
||||
self._send("Tab()")
|
||||
ok, lines = self._send("Ascii()")
|
||||
return "\n".join(lines)
|
||||
|
||||
def send_enter(self):
|
||||
with self.lock:
|
||||
self._send("Enter()")
|
||||
time.sleep(0.5)
|
||||
ok, lines = self._send("Ascii()")
|
||||
return "\n".join(lines)
|
||||
|
||||
def send_key(self, key):
|
||||
with self.lock:
|
||||
self._send(key)
|
||||
time.sleep(0.1)
|
||||
ok, lines = self._send("Ascii()")
|
||||
return "\n".join(lines)
|
||||
|
||||
def send_char(self, char):
|
||||
with self.lock:
|
||||
escaped = char.replace('"', '\"')
|
||||
self._send(f'String("{escaped}")')
|
||||
ok, lines = self._send("Ascii()")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "zos-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-scripts": "5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [">0.2%", "not dead"],
|
||||
"development": ["last 1 chrome version"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>z/OS Web Console</title>
|
||||
<style>* { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0a0f0a; }</style>
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
</html>
|
||||
@@ -0,0 +1,337 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<React.StrictMode><App /></React.StrictMode>);
|
||||
@@ -0,0 +1,179 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: zos-web
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: tso-credentials
|
||||
namespace: zos-web
|
||||
type: Opaque
|
||||
stringData:
|
||||
ZOS_HOST: "192.168.2.243"
|
||||
ZOS_PORT: "23"
|
||||
ZOS_USERID: "IBMUSER"
|
||||
ZOS_PASSWORD: "allard"
|
||||
ZOS_PROC: "DBSPROC9"
|
||||
ZOS_ACCT: "ACCT#"
|
||||
ZOS_SIZE: "2048000"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: zos-backend
|
||||
namespace: zos-web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: zos-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: zos-backend
|
||||
spec:
|
||||
containers:
|
||||
- name: zos-backend
|
||||
image: allardkrings/zos-backend:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: tso-credentials
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: zos-backend
|
||||
namespace: zos-web
|
||||
spec:
|
||||
selector:
|
||||
app: zos-backend
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8000
|
||||
targetPort: 8000
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: zos-frontend
|
||||
namespace: zos-web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: zos-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: zos-frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: zos-frontend
|
||||
image: allardkrings/zos-frontend:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: zos-frontend
|
||||
namespace: zos-web
|
||||
spec:
|
||||
selector:
|
||||
app: zos-frontend
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: strip-api-prefix
|
||||
namespace: zos-web
|
||||
spec:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- /api
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: redirect-to-https
|
||||
namespace: zos-web
|
||||
spec:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: zos-http
|
||||
namespace: zos-web
|
||||
spec:
|
||||
entryPoints:
|
||||
- web
|
||||
routes:
|
||||
- match: Host(`zos-dev.allarddcs.nl`)
|
||||
kind: Rule
|
||||
middlewares:
|
||||
- name: redirect-to-https
|
||||
services:
|
||||
- name: zos-frontend
|
||||
port: 80
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: zos-https
|
||||
namespace: zos-web
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`zos-dev.allarddcs.nl`) && PathPrefix(`/api`)
|
||||
kind: Rule
|
||||
middlewares:
|
||||
- name: strip-api-prefix
|
||||
services:
|
||||
- name: zos-backend
|
||||
port: 8000
|
||||
- match: Host(`zos-dev.allarddcs.nl`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: zos-frontend
|
||||
port: 80
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
Reference in New Issue
Block a user