Initial commit

This commit is contained in:
retoor 2024-11-27 15:28:30 +01:00
commit 8ad5b590de
24 changed files with 429 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.venv
.history
__pycache__

21
Makefile Normal file
View 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

Binary file not shown.

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

Binary file not shown.

BIN
dist/zhurnal-1.3.37.tar.gz vendored Normal file

Binary file not shown.

3
pyproject.toml Normal file
View File

@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

24
setup.cfg Normal file
View 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

View 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

View 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

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,2 @@
[console_scripts]
zhurnal = zhurnal.app:cli

View File

@ -0,0 +1 @@
aiohttp

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,3 @@
Metadata-Version: 2.1
Name: zhurnal
Version: 0.0.0

View 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

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@
zhurnal

12
src/zhurnal/__init__.py Normal file
View 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
View File

0
src/zhurnal/__main__py Normal file
View File

319
src/zhurnal/app.py Normal file
View 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)

View File

@ -0,0 +1 @@
from .app import ZhurnalTestCase

12
src/zhurnal/tests/app.py Normal file
View 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)