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