Initial commit
This commit is contained in:
commit
8ad5b590de
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.venv
|
||||
.history
|
||||
__pycache__
|
21
Makefile
Normal file
21
Makefile
Normal file
@ -0,0 +1,21 @@
|
||||
all: ensure_env format build install test
|
||||
|
||||
format:
|
||||
./.venv/bin/python -m black .
|
||||
|
||||
ensure_env:
|
||||
-@python3 -m venv .venv
|
||||
./.venv/bin/python -m pip install black
|
||||
./.venv/bin/python -m pip install build
|
||||
|
||||
build:
|
||||
./.venv/bin/python -m build .
|
||||
|
||||
install:
|
||||
./.venv/bin/python -m pip install -e .
|
||||
|
||||
run:
|
||||
python -m zhurnal "ping -c 1000 google.nl"
|
||||
|
||||
test:
|
||||
./.venv/bin/python -m unittest zhurnal.tests
|
BIN
dist/Zhurnal-1.3.37-py3-none-any.whl
vendored
Normal file
BIN
dist/Zhurnal-1.3.37-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
dist/zhurnal-0.0.0-py3-none-any.whl
vendored
Normal file
BIN
dist/zhurnal-0.0.0-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
dist/zhurnal-0.0.0.tar.gz
vendored
Normal file
BIN
dist/zhurnal-0.0.0.tar.gz
vendored
Normal file
Binary file not shown.
BIN
dist/zhurnal-1.3.37.tar.gz
vendored
Normal file
BIN
dist/zhurnal-1.3.37.tar.gz
vendored
Normal file
Binary file not shown.
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
24
setup.cfg
Normal file
24
setup.cfg
Normal file
@ -0,0 +1,24 @@
|
||||
[metadata]
|
||||
name = Zhurnal
|
||||
version = 1.3.37
|
||||
description = Web executor and logger
|
||||
author = retoor
|
||||
author_email = retoor@molodetz.nl
|
||||
license = MIT
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
package_dir =
|
||||
= src
|
||||
python_requires = >=3.7
|
||||
install_requires =
|
||||
aiohttp
|
||||
|
||||
[options.packages.find]
|
||||
where = src
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
zhurnal = zhurnal.app:cli
|
10
src/Zhurnal.egg-info/PKG-INFO
Normal file
10
src/Zhurnal.egg-info/PKG-INFO
Normal file
@ -0,0 +1,10 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: Zhurnal
|
||||
Version: 1.3.37
|
||||
Summary: Web executor and logger
|
||||
Author: retoor
|
||||
Author-email: retoor@molodetz.nl
|
||||
License: MIT
|
||||
Requires-Python: >=3.7
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: aiohttp
|
8
src/Zhurnal.egg-info/SOURCES.txt
Normal file
8
src/Zhurnal.egg-info/SOURCES.txt
Normal file
@ -0,0 +1,8 @@
|
||||
pyproject.toml
|
||||
setup.cfg
|
||||
src/Zhurnal.egg-info/PKG-INFO
|
||||
src/Zhurnal.egg-info/SOURCES.txt
|
||||
src/Zhurnal.egg-info/dependency_links.txt
|
||||
src/Zhurnal.egg-info/entry_points.txt
|
||||
src/Zhurnal.egg-info/requires.txt
|
||||
src/Zhurnal.egg-info/top_level.txt
|
1
src/Zhurnal.egg-info/dependency_links.txt
Normal file
1
src/Zhurnal.egg-info/dependency_links.txt
Normal file
@ -0,0 +1 @@
|
||||
|
2
src/Zhurnal.egg-info/entry_points.txt
Normal file
2
src/Zhurnal.egg-info/entry_points.txt
Normal file
@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
zhurnal = zhurnal.app:cli
|
1
src/Zhurnal.egg-info/requires.txt
Normal file
1
src/Zhurnal.egg-info/requires.txt
Normal file
@ -0,0 +1 @@
|
||||
aiohttp
|
1
src/Zhurnal.egg-info/top_level.txt
Normal file
1
src/Zhurnal.egg-info/top_level.txt
Normal file
@ -0,0 +1 @@
|
||||
|
3
src/zhurnal.egg-info/PKG-INFO
Normal file
3
src/zhurnal.egg-info/PKG-INFO
Normal file
@ -0,0 +1,3 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: zhurnal
|
||||
Version: 0.0.0
|
6
src/zhurnal.egg-info/SOURCES.txt
Normal file
6
src/zhurnal.egg-info/SOURCES.txt
Normal file
@ -0,0 +1,6 @@
|
||||
pyproject.toml
|
||||
src/zhurnal/app.py
|
||||
src/zhurnal.egg-info/PKG-INFO
|
||||
src/zhurnal.egg-info/SOURCES.txt
|
||||
src/zhurnal.egg-info/dependency_links.txt
|
||||
src/zhurnal.egg-info/top_level.txt
|
1
src/zhurnal.egg-info/dependency_links.txt
Normal file
1
src/zhurnal.egg-info/dependency_links.txt
Normal file
@ -0,0 +1 @@
|
||||
|
1
src/zhurnal.egg-info/top_level.txt
Normal file
1
src/zhurnal.egg-info/top_level.txt
Normal file
@ -0,0 +1 @@
|
||||
zhurnal
|
12
src/zhurnal/__init__.py
Normal file
12
src/zhurnal/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
0
src/zhurnal/__init__py
Normal file
0
src/zhurnal/__init__py
Normal file
0
src/zhurnal/__main__py
Normal file
0
src/zhurnal/__main__py
Normal file
319
src/zhurnal/app.py
Normal file
319
src/zhurnal/app.py
Normal file
@ -0,0 +1,319 @@
|
||||
from aiohttp import web
|
||||
from typing import List
|
||||
import shlex
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from zhurnal import log
|
||||
import time
|
||||
|
||||
|
||||
index_html = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Zhurnal</title>
|
||||
<style>
|
||||
* {
|
||||
padding:0;
|
||||
margin: 0;
|
||||
}.tooltip-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
width:50%;
|
||||
}
|
||||
.tooltip-text {
|
||||
visibility: hidden;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 125%;
|
||||
left: 10%;
|
||||
margin-left: -60px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
.tooltip-text::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent rgba(0, 0, 0, 0.7) transparent;
|
||||
}
|
||||
|
||||
.tooltip-container:hover .tooltip-text {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #010101;
|
||||
color: green;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
overflow: y;
|
||||
}
|
||||
.line {
|
||||
width: 100%;
|
||||
}
|
||||
.line:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.line-stdout {
|
||||
color #white;
|
||||
}
|
||||
.line-stderr {
|
||||
color: #red;
|
||||
}
|
||||
.terminal {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="messages" class="terminal"></div>
|
||||
<script>
|
||||
const scrollContainer = document.body;
|
||||
function autoScroll() {
|
||||
document.body.scrollTop = document.getElementById("messages").clientHeight;
|
||||
if (scrollContainer.scrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight) {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight; // Scroll to the bottom
|
||||
}
|
||||
}
|
||||
|
||||
const colors = ["#F0F8FF",
|
||||
"#FAEBD7",
|
||||
"#00FFFF",
|
||||
"#7FFFD4",
|
||||
"#F0FFFF",
|
||||
"#F5F5DC",
|
||||
"#FFE4C4"
|
||||
]
|
||||
let processColorIndex = 0;
|
||||
|
||||
let processColors = {}
|
||||
function getProcessColor(processName){
|
||||
if(processColors[processName])
|
||||
return processColors[processName]
|
||||
processColors[processName] = colors[processColorIndex]
|
||||
processColorIndex++
|
||||
return processColors[processName]
|
||||
}
|
||||
|
||||
function isElementInView(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||
);
|
||||
}
|
||||
|
||||
function isLastLineVisible() {
|
||||
const messagesDiv = document.getElementById("messages")
|
||||
if(!messagesDiv.children.length)
|
||||
return true;
|
||||
|
||||
const lastLine = messagesDiv.children[messagesDiv.children.length - 1]
|
||||
return isElementInView(lastLine)
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
let url = (window.location.protocol == 'http' ? 'ws://': 'ws:///') + window.location.host + "/ws"
|
||||
const ws = new WebSocket(url);
|
||||
const messagesDiv = document.getElementById("messages");
|
||||
ws.onmessage = (event) => {
|
||||
const obj = JSON.parse(event.data)
|
||||
const lineDiv = document.createElement("div")
|
||||
lineDiv.addEventListener("click", (event)=>{
|
||||
navigator.clipboard.writeText(event.target.innerText)
|
||||
const toolTipElement = event.target.querySelector(".tooltip-text")
|
||||
const originalInnerHTML = event.target.querySelector(".tooltip-text").innerHTML
|
||||
toolTipElement.innerText = "Line copied!"
|
||||
setTimeout(()=>{
|
||||
toolTipElement.innerHTML = originalInnerHTML
|
||||
}, 1000)
|
||||
});
|
||||
lineDiv.classList.add("line")
|
||||
lineDiv.classList.add("tooltip-container")
|
||||
lineDiv.innerText = obj.line
|
||||
const toolTipDiv = document.createElement('div')
|
||||
toolTipDiv.classList.add("tooltip-text")
|
||||
toolTipDiv.innerHTML = "<b>Process: " + obj.name + "</b><br /><i>Elapsed: " + obj.elapsed.toString() +"s</i><br />Command: " + obj.command
|
||||
lineDiv.appendChild(toolTipDiv)
|
||||
lineDiv.setAttribute("title", obj.name + " " + obj.command)
|
||||
console.info(getProcessColor(obj.name))
|
||||
lineDiv.style.color = getProcessColor(obj.name)
|
||||
if(obj.name.search("stderr") > 0)
|
||||
lineDiv.style.fontStyle = "italic";;
|
||||
|
||||
const scrollDown = isLastLineVisible()
|
||||
messagesDiv.appendChild(lineDiv)
|
||||
if(scrollDown)
|
||||
lineDiv.scrollIntoViewIfNeeded()
|
||||
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log("WebSocket connection closed");
|
||||
messagesDiv.innerHTML += "<p>Connection closed</p>";
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
messagesDiv.innerHTML += `<p>Error: ${error}</p>`;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Zhurnal(web.Application):
|
||||
|
||||
def __init__(self, commands: List[str], *args, **kwargs):
|
||||
self.commands = commands or []
|
||||
self.processes = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
self.on_startup.append(self.start_processes)
|
||||
self.clients = []
|
||||
self.router.add_get("/ws", self.websocket_handler)
|
||||
self.router.add_get("/", self.index_handler)
|
||||
log.info("Application created")
|
||||
|
||||
async def index_handler(self, request):
|
||||
return web.Response(text=index_html, content_type="text/html")
|
||||
|
||||
async def websocket_handler(self, request):
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
|
||||
log.info("WebSocket connection established")
|
||||
self.clients.append(ws)
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.TEXT:
|
||||
log.info(f"Received message: {msg.data}")
|
||||
if msg.data == "close":
|
||||
await ws.close()
|
||||
else:
|
||||
await ws.send_str(f"Echo: {msg.data}")
|
||||
elif msg.type == web.WSMsgType.ERROR:
|
||||
self.clients.remove(ws)
|
||||
log.info(
|
||||
f"WebSocket connection closed with exception: {ws.exception()}"
|
||||
)
|
||||
|
||||
log.info("WebSocket connection closed")
|
||||
if ws in self.clients:
|
||||
self.clients.remove(ws)
|
||||
return ws
|
||||
|
||||
async def run_process(self, process_name, command):
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*shlex.split(command),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
log.info("Running process {}: {}".format(process_name, command))
|
||||
|
||||
async def read_output(app, name, process, f):
|
||||
time_previous = 0
|
||||
async for line in f:
|
||||
time_current = time.time()
|
||||
time_elapsed = round(
|
||||
time_previous and time_current - time_previous or 0, 4
|
||||
)
|
||||
for client in app.clients:
|
||||
await client.send_str(
|
||||
json.dumps(
|
||||
dict(
|
||||
elapsed=time_elapsed,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
line=line.decode().strip(),
|
||||
name=name,
|
||||
command=command,
|
||||
)
|
||||
)
|
||||
)
|
||||
time_previous = time.time()
|
||||
|
||||
await asyncio.gather(
|
||||
read_output(
|
||||
self, "{}:stdout".format(process_name), command, process.stdout
|
||||
),
|
||||
read_output(
|
||||
self, "{}:stderr".format(process_name), process, process.stderr
|
||||
),
|
||||
)
|
||||
if process.returncode == 0:
|
||||
log.info(
|
||||
"Process {}:{} exited with {}.".format(
|
||||
process_name, command, process.returncode
|
||||
)
|
||||
)
|
||||
else:
|
||||
log.error(
|
||||
"Process {}:{} exited with {}.".format(
|
||||
process_name, command, process.returncode
|
||||
)
|
||||
)
|
||||
|
||||
async def start_processes(self, app):
|
||||
print(app)
|
||||
for x, command in enumerate(self.commands):
|
||||
self.processes[command] = asyncio.create_task(
|
||||
self.run_process("process-{}".format(x), command)
|
||||
)
|
||||
# asyncio.create_task(asyncio.gather(*self.processes.values()))
|
||||
|
||||
|
||||
def parse_args():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Executle proccesses and monitor trough web interface."
|
||||
)
|
||||
parser.add_argument(
|
||||
"commands", nargs="+", help="List of files to commands to execute and monitor."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
type=str,
|
||||
default="localhost",
|
||||
help="Hostname or IP address (default: localhost).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=8080, help="Port number (default: 8080)."
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def cli():
|
||||
args = parse_args()
|
||||
app = Zhurnal(commands=args.commands)
|
||||
for command in args.commands:
|
||||
log.info("Preparing execution of {}.".format(command))
|
||||
log.info("Host: {} Port: {}".format(args.host, args.port))
|
||||
web.run_app(app, host=args.host, port=args.port)
|
1
src/zhurnal/tests/__init__.py
Normal file
1
src/zhurnal/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .app import ZhurnalTestCase
|
12
src/zhurnal/tests/app.py
Normal file
12
src/zhurnal/tests/app.py
Normal file
@ -0,0 +1,12 @@
|
||||
from zhurnal.app import Zhurnal
|
||||
import unittest
|
||||
|
||||
|
||||
class ZhurnalTestCase(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.commands = ["ls"]
|
||||
self.app = Zhurnal(commands=self.commands)
|
||||
|
||||
def test_app_available(self):
|
||||
self.assertTrue(self.app)
|
Loading…
Reference in New Issue
Block a user