pywebexec 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pywebexec/pywebexec.py +135 -23
- pywebexec/static/css/style.css +112 -0
- pywebexec/static/images/favicon.svg +1 -0
- pywebexec/static/js/script.js +197 -0
- pywebexec/templates/index.html +6 -295
- pywebexec/version.py +2 -2
- {pywebexec-1.0.0.dist-info → pywebexec-1.1.0.dist-info}/METADATA +29 -12
- pywebexec-1.1.0.dist-info/RECORD +20 -0
- pywebexec-1.0.0.dist-info/RECORD +0 -17
- {pywebexec-1.0.0.dist-info → pywebexec-1.1.0.dist-info}/LICENSE +0 -0
- {pywebexec-1.0.0.dist-info → pywebexec-1.1.0.dist-info}/WHEEL +0 -0
- {pywebexec-1.0.0.dist-info → pywebexec-1.1.0.dist-info}/entry_points.txt +0 -0
- {pywebexec-1.0.0.dist-info → pywebexec-1.1.0.dist-info}/top_level.txt +0 -0
pywebexec/pywebexec.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import sys
|
|
2
|
-
from flask import Flask, request, jsonify, render_template
|
|
2
|
+
from flask import Flask, request, jsonify, render_template, session, redirect, url_for
|
|
3
3
|
from flask_httpauth import HTTPBasicAuth
|
|
4
4
|
import subprocess
|
|
5
5
|
import threading
|
|
@@ -9,16 +9,33 @@ import uuid
|
|
|
9
9
|
import argparse
|
|
10
10
|
import random
|
|
11
11
|
import string
|
|
12
|
-
from datetime import datetime
|
|
12
|
+
from datetime import datetime, timezone, timedelta
|
|
13
13
|
import shlex
|
|
14
14
|
from gunicorn.app.base import Application
|
|
15
|
-
from datetime import timezone, timedelta
|
|
16
15
|
import ipaddress
|
|
17
16
|
from socket import gethostname, gethostbyname_ex
|
|
17
|
+
import ssl
|
|
18
|
+
|
|
19
|
+
if os.environ.get('PYWEBEXEC_LDAP_SERVER'):
|
|
20
|
+
try:
|
|
21
|
+
from ldap3 import Server, Connection, ALL, SIMPLE, SUBTREE, Tls
|
|
22
|
+
except:
|
|
23
|
+
print("Need to install ldap3: pip install ldap3", file=sys.stderr)
|
|
24
|
+
sys.exit(1)
|
|
18
25
|
|
|
19
26
|
app = Flask(__name__)
|
|
27
|
+
app.secret_key = os.urandom(24) # Secret key for session management
|
|
28
|
+
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Add SameSite attribute to session cookies
|
|
20
29
|
auth = HTTPBasicAuth()
|
|
21
30
|
|
|
31
|
+
app.config['LDAP_SERVER'] = os.environ.get('PYWEBEXEC_LDAP_SERVER')
|
|
32
|
+
app.config['LDAP_USER_ID'] = os.environ.get('PYWEBEXEC_LDAP_USER_ID', "uid")
|
|
33
|
+
app.config['LDAP_GROUPS'] = os.environ.get('PYWEBEXEC_LDAP_GROUPS')
|
|
34
|
+
app.config['LDAP_BASE_DN'] = os.environ.get('PYWEBEXEC_LDAP_BASE_DN')
|
|
35
|
+
app.config['LDAP_BIND_DN'] = os.environ.get('PYWEBEXEC_LDAP_BIND_DN')
|
|
36
|
+
app.config['LDAP_BIND_PASSWORD'] = os.environ.get('PYWEBEXEC_LDAP_BIND_PASSWORD')
|
|
37
|
+
app.config['LDAP_USE_SSL'] = int(os.environ.get('PYWEBEXEC_LDAP_USE_SSL', False))
|
|
38
|
+
|
|
22
39
|
# Directory to store the command status and output
|
|
23
40
|
COMMAND_STATUS_DIR = '.web_status'
|
|
24
41
|
CONFDIR = os.path.expanduser("~/")
|
|
@@ -29,6 +46,9 @@ CONFDIR += "/.pywebexec"
|
|
|
29
46
|
if not os.path.exists(COMMAND_STATUS_DIR):
|
|
30
47
|
os.makedirs(COMMAND_STATUS_DIR)
|
|
31
48
|
|
|
49
|
+
# In-memory cache for command statuses
|
|
50
|
+
command_status_cache = {}
|
|
51
|
+
|
|
32
52
|
def generate_random_password(length=12):
|
|
33
53
|
characters = string.ascii_letters + string.digits + string.punctuation
|
|
34
54
|
return ''.join(random.choice(characters) for i in range(length))
|
|
@@ -124,6 +144,39 @@ class StandaloneApplication(Application):
|
|
|
124
144
|
return self.application
|
|
125
145
|
|
|
126
146
|
|
|
147
|
+
def decode_line(line: bytes) -> str:
|
|
148
|
+
"""try decode line exception on binary"""
|
|
149
|
+
try:
|
|
150
|
+
return line.decode()
|
|
151
|
+
except UnicodeDecodeError:
|
|
152
|
+
return ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def last_line(fd, maxline=1000):
|
|
156
|
+
"""last non empty line of file"""
|
|
157
|
+
line = "\n"
|
|
158
|
+
fd.seek(0, os.SEEK_END)
|
|
159
|
+
size = 0
|
|
160
|
+
while line in ["\n", "\r"] and size < maxline:
|
|
161
|
+
try: # catch if file empty / only empty lines
|
|
162
|
+
while fd.read(1) not in [b"\n", b"\r"]:
|
|
163
|
+
fd.seek(-2, os.SEEK_CUR)
|
|
164
|
+
size += 1
|
|
165
|
+
except OSError:
|
|
166
|
+
fd.seek(0)
|
|
167
|
+
line = decode_line(fd.readline())
|
|
168
|
+
break
|
|
169
|
+
line = decode_line(fd.readline())
|
|
170
|
+
fd.seek(-4, os.SEEK_CUR)
|
|
171
|
+
return line.strip()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_last_non_empty_line_of_file(file_path):
|
|
175
|
+
"""Get the last non-empty line of a file."""
|
|
176
|
+
with open(file_path, 'rb') as f:
|
|
177
|
+
return last_line(f)
|
|
178
|
+
|
|
179
|
+
|
|
127
180
|
def start_gunicorn(daemon=False, baselog=None):
|
|
128
181
|
if daemon:
|
|
129
182
|
errorlog = f"{baselog}.log"
|
|
@@ -186,8 +239,8 @@ def daemon_d(action, pidfilepath, hostname=None, args=None):
|
|
|
186
239
|
def parseargs():
|
|
187
240
|
global app, args
|
|
188
241
|
parser = argparse.ArgumentParser(description='Run the command execution server.')
|
|
189
|
-
parser.add_argument('--user', help='Username for basic auth')
|
|
190
|
-
parser.add_argument('--password', help='Password for basic auth')
|
|
242
|
+
parser.add_argument('-u', '--user', help='Username for basic auth')
|
|
243
|
+
parser.add_argument('-P', '--password', help='Password for basic auth')
|
|
191
244
|
parser.add_argument(
|
|
192
245
|
"-l", "--listen", type=str, default="0.0.0.0", help="HTTP server listen address"
|
|
193
246
|
)
|
|
@@ -241,6 +294,7 @@ def parseargs():
|
|
|
241
294
|
else:
|
|
242
295
|
app.config['USER'] = None
|
|
243
296
|
app.config['PASSWORD'] = None
|
|
297
|
+
|
|
244
298
|
return args
|
|
245
299
|
|
|
246
300
|
parseargs()
|
|
@@ -267,15 +321,35 @@ def update_command_status(command_id, status, command=None, params=None, start_t
|
|
|
267
321
|
status_data['exit_code'] = exit_code
|
|
268
322
|
if pid is not None:
|
|
269
323
|
status_data['pid'] = pid
|
|
324
|
+
if status != 'running':
|
|
325
|
+
output_file_path = get_output_file_path(command_id)
|
|
326
|
+
if os.path.exists(output_file_path):
|
|
327
|
+
status_data['last_output_line'] = get_last_non_empty_line_of_file(output_file_path)
|
|
270
328
|
with open(status_file_path, 'w') as f:
|
|
271
329
|
json.dump(status_data, f)
|
|
330
|
+
|
|
331
|
+
# Update cache if status is not "running"
|
|
332
|
+
if status != 'running':
|
|
333
|
+
command_status_cache[command_id] = status_data
|
|
334
|
+
elif command_id in command_status_cache:
|
|
335
|
+
del command_status_cache[command_id]
|
|
272
336
|
|
|
273
337
|
def read_command_status(command_id):
|
|
338
|
+
# Return cached status if available
|
|
339
|
+
if command_id in command_status_cache:
|
|
340
|
+
return command_status_cache[command_id]
|
|
341
|
+
|
|
274
342
|
status_file_path = get_status_file_path(command_id)
|
|
275
343
|
if not os.path.exists(status_file_path):
|
|
276
344
|
return None
|
|
277
345
|
with open(status_file_path, 'r') as f:
|
|
278
|
-
|
|
346
|
+
status_data = json.load(f)
|
|
347
|
+
|
|
348
|
+
# Cache the status if it is not "running"
|
|
349
|
+
if status_data['status'] != 'running':
|
|
350
|
+
command_status_cache[command_id] = status_data
|
|
351
|
+
|
|
352
|
+
return status_data
|
|
279
353
|
|
|
280
354
|
# Dictionary to store the process objects
|
|
281
355
|
processes = {}
|
|
@@ -307,13 +381,60 @@ def run_command(command, params, command_id):
|
|
|
307
381
|
with open(get_output_file_path(command_id), 'a') as output_file:
|
|
308
382
|
output_file.write(str(e))
|
|
309
383
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
384
|
+
@app.before_request
|
|
385
|
+
def check_authentication():
|
|
386
|
+
if 'username' not in session and request.endpoint not in ['login', 'static']:
|
|
387
|
+
return auth.login_required(lambda: None)()
|
|
388
|
+
|
|
389
|
+
@auth.verify_password
|
|
390
|
+
def verify_password(username, password):
|
|
391
|
+
if not username:
|
|
392
|
+
return False
|
|
393
|
+
if app.config['USER']:
|
|
394
|
+
if username == app.config['USER'] and password == app.config['PASSWORD']:
|
|
395
|
+
session['username'] = username
|
|
396
|
+
return True
|
|
397
|
+
elif app.config['LDAP_SERVER']:
|
|
398
|
+
if verify_ldap(username, password):
|
|
399
|
+
session['username'] = username
|
|
400
|
+
return True
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
def verify_ldap(username, password):
|
|
404
|
+
tls_configuration = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2) if app.config['LDAP_USE_SSL'] else None
|
|
405
|
+
server = Server(app.config['LDAP_SERVER'], use_ssl=app.config['LDAP_USE_SSL'], tls=tls_configuration, get_info=ALL)
|
|
406
|
+
user_filter = f"({app.config['LDAP_USER_ID']}={username})"
|
|
407
|
+
try:
|
|
408
|
+
# Bind with the bind DN and password
|
|
409
|
+
conn = Connection(server, user=app.config['LDAP_BIND_DN'], password=app.config['LDAP_BIND_PASSWORD'], authentication=SIMPLE, auto_bind=True)
|
|
410
|
+
try:
|
|
411
|
+
conn.search(search_base=app.config['LDAP_BASE_DN'], search_filter=user_filter)
|
|
412
|
+
if len(conn.entries) == 0:
|
|
413
|
+
print(f"User {username} not found in LDAP.")
|
|
414
|
+
return False
|
|
415
|
+
user_dn = conn.entries[0].entry_dn
|
|
416
|
+
finally:
|
|
417
|
+
conn.unbind()
|
|
418
|
+
|
|
419
|
+
# Bind with the user DN and password to verify credentials
|
|
420
|
+
conn = Connection(server, user=user_dn, password=password, authentication=SIMPLE, auto_bind=True)
|
|
421
|
+
try:
|
|
422
|
+
if not app.config['LDAP_GROUPS'] and conn.result["result"] == 0:
|
|
423
|
+
return True
|
|
424
|
+
group_filter = "".join([f'(ou={group})' for group in app.config['LDAP_GROUPS'].split(",")])
|
|
425
|
+
group_filter = f"(&{group_filter}(|(member={user_dn})(uniqueMember={user_dn})))"
|
|
426
|
+
conn.search(search_base=app.config['LDAP_BASE_DN'], search_filter=group_filter)
|
|
427
|
+
result = len(conn.entries) > 0
|
|
428
|
+
if not result:
|
|
429
|
+
print(f"User {username} is not a member of groups {app.config['LDAP_GROUPS']}.")
|
|
430
|
+
return result
|
|
431
|
+
finally:
|
|
432
|
+
conn.unbind()
|
|
433
|
+
except Exception as e:
|
|
434
|
+
print(f"LDAP authentication failed: {e}")
|
|
435
|
+
return False
|
|
314
436
|
|
|
315
437
|
@app.route('/run_command', methods=['POST'])
|
|
316
|
-
@auth_required
|
|
317
438
|
def run_command_endpoint():
|
|
318
439
|
data = request.json
|
|
319
440
|
command = data.get('command')
|
|
@@ -346,7 +467,6 @@ def run_command_endpoint():
|
|
|
346
467
|
return jsonify({'message': 'Command is running', 'command_id': command_id})
|
|
347
468
|
|
|
348
469
|
@app.route('/stop_command/<command_id>', methods=['POST'])
|
|
349
|
-
@auth_required
|
|
350
470
|
def stop_command(command_id):
|
|
351
471
|
status = read_command_status(command_id)
|
|
352
472
|
if not status or 'pid' not in status:
|
|
@@ -370,7 +490,6 @@ def stop_command(command_id):
|
|
|
370
490
|
return jsonify({'error': 'Failed to terminate command'}), 500
|
|
371
491
|
|
|
372
492
|
@app.route('/command_status/<command_id>', methods=['GET'])
|
|
373
|
-
@auth_required
|
|
374
493
|
def get_command_status(command_id):
|
|
375
494
|
status = read_command_status(command_id)
|
|
376
495
|
if not status:
|
|
@@ -385,12 +504,10 @@ def get_command_status(command_id):
|
|
|
385
504
|
return jsonify(status)
|
|
386
505
|
|
|
387
506
|
@app.route('/')
|
|
388
|
-
@auth_required
|
|
389
507
|
def index():
|
|
390
508
|
return render_template('index.html', title=args.title)
|
|
391
509
|
|
|
392
510
|
@app.route('/commands', methods=['GET'])
|
|
393
|
-
@auth_required
|
|
394
511
|
def list_commands():
|
|
395
512
|
commands = []
|
|
396
513
|
for filename in os.listdir(COMMAND_STATUS_DIR):
|
|
@@ -409,14 +526,14 @@ def list_commands():
|
|
|
409
526
|
'start_time': status.get('start_time', 'N/A'),
|
|
410
527
|
'end_time': status.get('end_time', 'N/A'),
|
|
411
528
|
'command': command,
|
|
412
|
-
'exit_code': status.get('exit_code', 'N/A')
|
|
529
|
+
'exit_code': status.get('exit_code', 'N/A'),
|
|
530
|
+
'last_output_line': status.get('last_output_line', get_last_non_empty_line_of_file(get_output_file_path(command_id))),
|
|
413
531
|
})
|
|
414
532
|
# Sort commands by start_time in descending order
|
|
415
533
|
commands.sort(key=lambda x: x['start_time'], reverse=True)
|
|
416
534
|
return jsonify(commands)
|
|
417
535
|
|
|
418
536
|
@app.route('/command_output/<command_id>', methods=['GET'])
|
|
419
|
-
@auth_required
|
|
420
537
|
def get_command_output(command_id):
|
|
421
538
|
output_file_path = get_output_file_path(command_id)
|
|
422
539
|
if os.path.exists(output_file_path):
|
|
@@ -427,15 +544,10 @@ def get_command_output(command_id):
|
|
|
427
544
|
return jsonify({'error': 'Invalid command_id'}), 404
|
|
428
545
|
|
|
429
546
|
@app.route('/executables', methods=['GET'])
|
|
430
|
-
@auth_required
|
|
431
547
|
def list_executables():
|
|
432
548
|
executables = [f for f in os.listdir('.') if os.path.isfile(f) and os.access(f, os.X_OK)]
|
|
433
549
|
return jsonify(executables)
|
|
434
550
|
|
|
435
|
-
@auth.verify_password
|
|
436
|
-
def verify_password(username, password):
|
|
437
|
-
return username == app.config['USER'] and password == app.config['PASSWORD']
|
|
438
|
-
|
|
439
551
|
def main():
|
|
440
552
|
basef = f"{CONFDIR}/pywebexec_{args.listen}:{args.port}"
|
|
441
553
|
if not os.path.exists(CONFDIR):
|
|
@@ -448,4 +560,4 @@ def main():
|
|
|
448
560
|
|
|
449
561
|
if __name__ == '__main__':
|
|
450
562
|
main()
|
|
451
|
-
# app.run(host='0.0.0.0', port=5000)
|
|
563
|
+
# app.run(host='0.0.0.0', port=5000)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
body { font-family: Arial, sans-serif; }
|
|
2
|
+
.table-container { height: 270px; overflow-y: auto; position: relative; }
|
|
3
|
+
table { width: 100%; border-collapse: collapse; }
|
|
4
|
+
th, td {
|
|
5
|
+
padding: 8px;
|
|
6
|
+
text-align: left;
|
|
7
|
+
border-bottom: 1px solid #ddd;
|
|
8
|
+
white-space: nowrap;
|
|
9
|
+
}
|
|
10
|
+
th { background-color: #f2f2f2; position: sticky; top: 0; z-index: 1; }
|
|
11
|
+
.outcol {
|
|
12
|
+
width: 100%;
|
|
13
|
+
}
|
|
14
|
+
.output {
|
|
15
|
+
white-space: pre-wrap;
|
|
16
|
+
background: #f0f0f0;
|
|
17
|
+
padding: 10px;
|
|
18
|
+
border: 1px solid #ccc;
|
|
19
|
+
font-family: monospace;
|
|
20
|
+
border-radius: 15px;
|
|
21
|
+
overflow-y: auto;
|
|
22
|
+
}
|
|
23
|
+
.copy-icon { cursor: pointer; }
|
|
24
|
+
.monospace { font-family: monospace; }
|
|
25
|
+
.copied { color: green; margin-left: 5px; }
|
|
26
|
+
button {
|
|
27
|
+
-webkit-appearance: none;
|
|
28
|
+
-webkit-border-radius: none;
|
|
29
|
+
appearance: none;
|
|
30
|
+
border-radius: 15px;
|
|
31
|
+
padding: 3px;
|
|
32
|
+
padding-right: 13px;
|
|
33
|
+
border: 1px #555 solid;
|
|
34
|
+
height: 22px;
|
|
35
|
+
font-size: 13px;
|
|
36
|
+
outline: none;
|
|
37
|
+
text-indent: 10px;
|
|
38
|
+
background-color: #eee;
|
|
39
|
+
}
|
|
40
|
+
form {
|
|
41
|
+
padding-bottom: 15px;
|
|
42
|
+
}
|
|
43
|
+
.status-icon {
|
|
44
|
+
display: inline-block;
|
|
45
|
+
width: 16px;
|
|
46
|
+
height: 16px;
|
|
47
|
+
margin-right: 5px;
|
|
48
|
+
background-size: contain;
|
|
49
|
+
background-repeat: no-repeat;
|
|
50
|
+
vertical-align: middle;
|
|
51
|
+
}
|
|
52
|
+
.status-running {
|
|
53
|
+
background-image: url("/static/images/running.svg")
|
|
54
|
+
}
|
|
55
|
+
.status-success {
|
|
56
|
+
background-image: url("/static/images/success.svg")
|
|
57
|
+
}
|
|
58
|
+
.status-failed {
|
|
59
|
+
background-image: url("/static/images/failed.svg")
|
|
60
|
+
}
|
|
61
|
+
.status-aborted {
|
|
62
|
+
background-image: url("/static/images/aborted.svg")
|
|
63
|
+
}
|
|
64
|
+
.copy_clip {
|
|
65
|
+
padding-right: 25px;
|
|
66
|
+
background-repeat: no-repeat;
|
|
67
|
+
background-position: right top;
|
|
68
|
+
background-size: 25px 16px;
|
|
69
|
+
white-space: nowrap;
|
|
70
|
+
}
|
|
71
|
+
.copy_clip:hover {
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
background-image: url("/static/images/copy.svg");
|
|
74
|
+
}
|
|
75
|
+
.copy_clip_ok, .copy_clip_ok:hover {
|
|
76
|
+
background-image: url("/static/images/copy_ok.svg");
|
|
77
|
+
}
|
|
78
|
+
input {
|
|
79
|
+
width: 50%;
|
|
80
|
+
-webkit-appearance: none;
|
|
81
|
+
-webkit-border-radius: none;
|
|
82
|
+
appearance: none;
|
|
83
|
+
border-radius: 15px;
|
|
84
|
+
padding: 3px;
|
|
85
|
+
padding-right: 13px;
|
|
86
|
+
border: 1px #aaa solid;
|
|
87
|
+
height: 15px;
|
|
88
|
+
font-size: 15px;
|
|
89
|
+
outline: none;
|
|
90
|
+
text-indent: 10px;
|
|
91
|
+
background-color: white;
|
|
92
|
+
}
|
|
93
|
+
.currentcommand {
|
|
94
|
+
background-color: #eef;
|
|
95
|
+
}
|
|
96
|
+
.resizer {
|
|
97
|
+
width: 100%;
|
|
98
|
+
height: 5px;
|
|
99
|
+
background: #aaa;
|
|
100
|
+
cursor: ns-resize;
|
|
101
|
+
position: absolute;
|
|
102
|
+
bottom: 0;
|
|
103
|
+
left: 0;
|
|
104
|
+
}
|
|
105
|
+
.resizer-container {
|
|
106
|
+
position: relative;
|
|
107
|
+
height: 5px;
|
|
108
|
+
margin-bottom: 10px;
|
|
109
|
+
}
|
|
110
|
+
tr.clickable-row {
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="#000000" viewBox="-1 0 19 19" xmlns="http://www.w3.org/2000/svg" class="cf-icon-svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M16.5 9.5a8 8 0 1 1-8-8 8 8 0 0 1 8 8zm-2.97.006a5.03 5.03 0 1 0-5.03 5.03 5.03 5.03 0 0 0 5.03-5.03zm-7.383-.4H4.289a4.237 4.237 0 0 1 2.565-3.498q.1-.042.2-.079a7.702 7.702 0 0 0-.907 3.577zm0 .8a7.7 7.7 0 0 0 .908 3.577q-.102-.037-.201-.079a4.225 4.225 0 0 1-2.565-3.498zm.8-.8a9.04 9.04 0 0 1 .163-1.402 6.164 6.164 0 0 1 .445-1.415c.289-.615.66-1.013.945-1.013.285 0 .656.398.945 1.013a6.18 6.18 0 0 1 .445 1.415 9.078 9.078 0 0 1 .163 1.402zm3.106.8a9.073 9.073 0 0 1-.163 1.402 6.187 6.187 0 0 1-.445 1.415c-.289.616-.66 1.013-.945 1.013-.285 0-.656-.397-.945-1.013a6.172 6.172 0 0 1-.445-1.415 9.036 9.036 0 0 1-.163-1.402zm1.438-3.391a4.211 4.211 0 0 1 1.22 2.591h-1.858a7.698 7.698 0 0 0-.908-3.577q.102.037.201.08a4.208 4.208 0 0 1 1.345.906zm-.638 3.391h1.858a4.238 4.238 0 0 1-2.565 3.498q-.1.043-.2.08a7.697 7.697 0 0 0 .907-3.578z"></path></g></svg>
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
let currentCommandId = null;
|
|
2
|
+
let outputInterval = null;
|
|
3
|
+
|
|
4
|
+
document.getElementById('launchForm').addEventListener('submit', async (event) => {
|
|
5
|
+
event.preventDefault();
|
|
6
|
+
const commandName = document.getElementById('commandName').value;
|
|
7
|
+
const params = document.getElementById('params').value.split(' ');
|
|
8
|
+
const response = await fetch('/run_command', {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: {
|
|
11
|
+
'Content-Type': 'application/json'
|
|
12
|
+
},
|
|
13
|
+
body: JSON.stringify({ command: commandName, params: params })
|
|
14
|
+
});
|
|
15
|
+
const data = await response.json();
|
|
16
|
+
fetchCommands();
|
|
17
|
+
viewOutput(data.command_id);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function fetchCommands() {
|
|
21
|
+
const response = await fetch('/commands');
|
|
22
|
+
const commands = await response.json();
|
|
23
|
+
commands.sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
|
|
24
|
+
const commandsTbody = document.getElementById('commands');
|
|
25
|
+
commandsTbody.innerHTML = '';
|
|
26
|
+
if (!currentCommandId && commands.length) {
|
|
27
|
+
currentCommandId = commands[0].command_id;
|
|
28
|
+
viewOutput(currentCommandId);
|
|
29
|
+
}
|
|
30
|
+
commands.forEach(command => {
|
|
31
|
+
const commandRow = document.createElement('tr');
|
|
32
|
+
commandRow.className = `clickable-row ${command.command_id === currentCommandId ? 'currentcommand' : ''}`;
|
|
33
|
+
commandRow.onclick = () => viewOutput(command.command_id);
|
|
34
|
+
commandRow.innerHTML = `
|
|
35
|
+
<td class="monospace">
|
|
36
|
+
<span class="copy_clip" onclick="copyToClipboard('${command.command_id}', this)">${command.command_id.slice(0, 8)}</span>
|
|
37
|
+
</td>
|
|
38
|
+
<td><span class="status-icon status-${command.status}"></span>${command.status}</td>
|
|
39
|
+
<td>${formatTime(command.start_time)}</td>
|
|
40
|
+
<td>${command.status === 'running' ? formatDuration(command.start_time, new Date().toISOString()) : formatDuration(command.start_time, command.end_time)}</td>
|
|
41
|
+
<td>${command.exit_code}</td>
|
|
42
|
+
<td>${command.command.replace(/^\.\//, '')}</td>
|
|
43
|
+
<td class="monospace outcol">${command.last_output_line || ''}</td>
|
|
44
|
+
<td>
|
|
45
|
+
<button onclick="relaunchCommand('${command.command_id}')">Relaunch</button>
|
|
46
|
+
${command.status === 'running' ? `<button onclick="stopCommand('${command.command_id}')">Stop</button>` : ''}
|
|
47
|
+
</td>
|
|
48
|
+
`;
|
|
49
|
+
commandsTbody.appendChild(commandRow);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function fetchExecutables() {
|
|
54
|
+
const response = await fetch('/executables');
|
|
55
|
+
const executables = await response.json();
|
|
56
|
+
const commandNameSelect = document.getElementById('commandName');
|
|
57
|
+
commandNameSelect.innerHTML = '';
|
|
58
|
+
executables.forEach(executable => {
|
|
59
|
+
const option = document.createElement('option');
|
|
60
|
+
option.value = executable;
|
|
61
|
+
option.textContent = executable;
|
|
62
|
+
commandNameSelect.appendChild(option);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fetchOutput(command_id) {
|
|
67
|
+
const outputDiv = document.getElementById('output');
|
|
68
|
+
const response = await fetch(`/command_output/${command_id}`);
|
|
69
|
+
const data = await response.json();
|
|
70
|
+
if (data.error) {
|
|
71
|
+
outputDiv.innerHTML = data.error;
|
|
72
|
+
clearInterval(outputInterval);
|
|
73
|
+
} else {
|
|
74
|
+
outputDiv.innerHTML = data.output;
|
|
75
|
+
outputDiv.scrollTop = outputDiv.scrollHeight;
|
|
76
|
+
if (data.status != 'running') {
|
|
77
|
+
clearInterval(outputInterval);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function viewOutput(command_id) {
|
|
83
|
+
adjustOutputHeight();
|
|
84
|
+
currentCommandId = command_id;
|
|
85
|
+
clearInterval(outputInterval);
|
|
86
|
+
const response = await fetch(`/command_status/${command_id}`);
|
|
87
|
+
const data = await response.json();
|
|
88
|
+
if (data.status === 'running') {
|
|
89
|
+
fetchOutput(command_id);
|
|
90
|
+
outputInterval = setInterval(() => fetchOutput(command_id), 1000);
|
|
91
|
+
} else {
|
|
92
|
+
fetchOutput(command_id);
|
|
93
|
+
}
|
|
94
|
+
fetchCommands(); // Refresh the command list to highlight the current command
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function relaunchCommand(command_id) {
|
|
98
|
+
const response = await fetch(`/command_status/${command_id}`);
|
|
99
|
+
const data = await response.json();
|
|
100
|
+
if (data.error) {
|
|
101
|
+
alert(data.error);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const relaunchResponse = await fetch('/run_command', {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
'Content-Type': 'application/json'
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
command: data.command,
|
|
111
|
+
params: data.params
|
|
112
|
+
})
|
|
113
|
+
});
|
|
114
|
+
const relaunchData = await relaunchResponse.json();
|
|
115
|
+
fetchCommands();
|
|
116
|
+
viewOutput(relaunchData.command_id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function stopCommand(command_id) {
|
|
120
|
+
const response = await fetch(`/stop_command/${command_id}`, {
|
|
121
|
+
method: 'POST'
|
|
122
|
+
});
|
|
123
|
+
const data = await response.json();
|
|
124
|
+
if (data.error) {
|
|
125
|
+
alert(data.error);
|
|
126
|
+
} else {
|
|
127
|
+
alert(data.message);
|
|
128
|
+
fetchCommands();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatTime(time) {
|
|
133
|
+
if (!time || time === 'N/A') return 'N/A';
|
|
134
|
+
const date = new Date(time);
|
|
135
|
+
return date.toISOString().slice(0, 16).replace('T', ' ');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatDuration(startTime, endTime) {
|
|
139
|
+
if (!startTime || !endTime) return 'N/A';
|
|
140
|
+
const start = new Date(startTime);
|
|
141
|
+
const end = new Date(endTime);
|
|
142
|
+
const duration = (end - start) / 1000;
|
|
143
|
+
const hours = Math.floor(duration / 3600);
|
|
144
|
+
const minutes = Math.floor((duration % 3600) / 60);
|
|
145
|
+
const seconds = Math.floor(duration % 60);
|
|
146
|
+
return `${hours}h ${minutes}m ${seconds}s`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function copyToClipboard(text, element) {
|
|
150
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
151
|
+
element.classList.add('copy_clip_ok');
|
|
152
|
+
setTimeout(() => {
|
|
153
|
+
element.classList.remove('copy_clip_ok');
|
|
154
|
+
}, 2000);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function adjustOutputHeight() {
|
|
159
|
+
const outputDiv = document.getElementById('output');
|
|
160
|
+
const windowHeight = window.innerHeight;
|
|
161
|
+
const outputTop = outputDiv.getBoundingClientRect().top;
|
|
162
|
+
const maxHeight = windowHeight - outputTop - 30; // 20px for padding/margin
|
|
163
|
+
outputDiv.style.maxHeight = `${maxHeight}px`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function initResizer() {
|
|
167
|
+
const resizer = document.getElementById('resizer');
|
|
168
|
+
const tableContainer = document.getElementById('tableContainer');
|
|
169
|
+
let startY, startHeight;
|
|
170
|
+
|
|
171
|
+
resizer.addEventListener('mousedown', (e) => {
|
|
172
|
+
startY = e.clientY;
|
|
173
|
+
startHeight = parseInt(document.defaultView.getComputedStyle(tableContainer).height, 10);
|
|
174
|
+
document.documentElement.addEventListener('mousemove', doDrag, false);
|
|
175
|
+
document.documentElement.addEventListener('mouseup', stopDrag, false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
function doDrag(e) {
|
|
179
|
+
tableContainer.style.height = `${startHeight + e.clientY - startY}px`;
|
|
180
|
+
adjustOutputHeight();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function stopDrag() {
|
|
184
|
+
document.documentElement.removeEventListener('mousemove', doDrag, false);
|
|
185
|
+
document.documentElement.removeEventListener('mouseup', stopDrag, false);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
window.addEventListener('resize', adjustOutputHeight);
|
|
190
|
+
window.addEventListener('load', () => {
|
|
191
|
+
adjustOutputHeight();
|
|
192
|
+
initResizer();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
fetchExecutables();
|
|
196
|
+
fetchCommands();
|
|
197
|
+
setInterval(fetchCommands, 5000);
|
pywebexec/templates/index.html
CHANGED
|
@@ -2,108 +2,12 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
|
+
<link rel="icon" href="/static/images/favicon.svg" type="image/svg+xml">
|
|
5
6
|
<title>{{ title }}</title>
|
|
6
|
-
<style>
|
|
7
|
-
body { font-family: Arial, sans-serif; }
|
|
8
|
-
.table-container { height: 380px; overflow-y: auto; position: relative; }
|
|
9
|
-
table { width: 100%; border-collapse: collapse; }
|
|
10
|
-
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
|
|
11
|
-
th { background-color: #f2f2f2; position: sticky; top: 0; z-index: 1; }
|
|
12
|
-
.output {
|
|
13
|
-
white-space: pre-wrap;
|
|
14
|
-
background: #f0f0f0;
|
|
15
|
-
padding: 10px;
|
|
16
|
-
border: 1px solid #ccc;
|
|
17
|
-
font-family: monospace;
|
|
18
|
-
border-radius: 15px;
|
|
19
|
-
overflow-y: auto;
|
|
20
|
-
}
|
|
21
|
-
.copy-icon { cursor: pointer; }
|
|
22
|
-
.monospace { font-family: monospace; }
|
|
23
|
-
.copied { color: green; margin-left: 5px; }
|
|
24
|
-
button {
|
|
25
|
-
-webkit-appearance: none;
|
|
26
|
-
-webkit-border-radius: none;
|
|
27
|
-
appearance: none;
|
|
28
|
-
border-radius: 15px;
|
|
29
|
-
padding: 3px;
|
|
30
|
-
padding-right: 13px;
|
|
31
|
-
border: 1px #555 solid;
|
|
32
|
-
height: 22px;
|
|
33
|
-
font-size: 13px;
|
|
34
|
-
outline: none;
|
|
35
|
-
text-indent: 10px;
|
|
36
|
-
background-color: #eee;
|
|
37
|
-
display: inline-block;
|
|
38
|
-
vertical-align: middle;
|
|
39
|
-
}
|
|
40
|
-
form {
|
|
41
|
-
padding-bottom: 15px;
|
|
42
|
-
}
|
|
43
|
-
.status-icon {
|
|
44
|
-
display: inline-block;
|
|
45
|
-
width: 16px;
|
|
46
|
-
height: 16px;
|
|
47
|
-
margin-right: 5px;
|
|
48
|
-
background-size: contain;
|
|
49
|
-
background-repeat: no-repeat;
|
|
50
|
-
vertical-align: middle;
|
|
51
|
-
}
|
|
52
|
-
.status-running {
|
|
53
|
-
background-image: url("/static/images/running.svg")
|
|
54
|
-
}
|
|
55
|
-
.status-success {
|
|
56
|
-
background-image: url("/static/images/success.svg")
|
|
57
|
-
}
|
|
58
|
-
.status-failed {
|
|
59
|
-
background-image: url("/static/images/failed.svg")
|
|
60
|
-
}
|
|
61
|
-
.status-aborted {
|
|
62
|
-
background-image: url("/static/images/aborted.svg")
|
|
63
|
-
}
|
|
64
|
-
.copy_clip {
|
|
65
|
-
padding-right: 25px;
|
|
66
|
-
background-repeat: no-repeat;
|
|
67
|
-
background-position: right top;
|
|
68
|
-
background-size: 25px 16px;
|
|
69
|
-
white-space: nowrap;
|
|
70
|
-
}
|
|
71
|
-
.copy_clip_left {
|
|
72
|
-
padding-left: 25px;
|
|
73
|
-
padding-right: 0px;
|
|
74
|
-
background-position: left top;
|
|
75
|
-
}
|
|
76
|
-
.copy_clip:hover {
|
|
77
|
-
cursor: pointer;
|
|
78
|
-
background-image: url("/static/images/copy.svg");
|
|
79
|
-
}
|
|
80
|
-
.copy_clip_ok, .copy_clip_ok:hover {
|
|
81
|
-
background-image: url("/static/images/copy_ok.svg");
|
|
82
|
-
}
|
|
83
|
-
input {
|
|
84
|
-
width: 50%
|
|
85
|
-
}
|
|
86
|
-
.currentcommand {
|
|
87
|
-
background-color: #eef;
|
|
88
|
-
}
|
|
89
|
-
.resizer {
|
|
90
|
-
width: 100%;
|
|
91
|
-
height: 5px;
|
|
92
|
-
background: #aaa;
|
|
93
|
-
cursor: ns-resize;
|
|
94
|
-
position: absolute;
|
|
95
|
-
bottom: 0;
|
|
96
|
-
left: 0;
|
|
97
|
-
}
|
|
98
|
-
.resizer-container {
|
|
99
|
-
position: relative;
|
|
100
|
-
height: 5px;
|
|
101
|
-
margin-bottom: 10px;
|
|
102
|
-
}
|
|
103
|
-
</style>
|
|
7
|
+
<link rel="stylesheet" href="/static/css/style.css">
|
|
104
8
|
</head>
|
|
105
9
|
<body>
|
|
106
|
-
<
|
|
10
|
+
<h2>{{ title }}</h2>
|
|
107
11
|
<form id="launchForm">
|
|
108
12
|
<label for="commandName">Command</label>
|
|
109
13
|
<select id="commandName" name="commandName"></select>
|
|
@@ -115,12 +19,13 @@
|
|
|
115
19
|
<table>
|
|
116
20
|
<thead>
|
|
117
21
|
<tr>
|
|
118
|
-
<th>
|
|
22
|
+
<th>ID</th>
|
|
119
23
|
<th>Status</th>
|
|
120
24
|
<th>Start Time</th>
|
|
121
25
|
<th>Duration</th>
|
|
122
26
|
<th>Exit</th>
|
|
123
27
|
<th>Command</th>
|
|
28
|
+
<th>Output</th>
|
|
124
29
|
<th>Actions</th>
|
|
125
30
|
</tr>
|
|
126
31
|
</thead>
|
|
@@ -131,200 +36,6 @@
|
|
|
131
36
|
<div class="resizer" id="resizer"></div>
|
|
132
37
|
</div>
|
|
133
38
|
<div id="output" class="output"></div>
|
|
134
|
-
|
|
135
|
-
<script>
|
|
136
|
-
let currentCommandId = null;
|
|
137
|
-
let outputInterval = null;
|
|
138
|
-
|
|
139
|
-
document.getElementById('launchForm').addEventListener('submit', async (event) => {
|
|
140
|
-
event.preventDefault();
|
|
141
|
-
const commandName = document.getElementById('commandName').value;
|
|
142
|
-
const params = document.getElementById('params').value.split(' ');
|
|
143
|
-
const response = await fetch('/run_command', {
|
|
144
|
-
method: 'POST',
|
|
145
|
-
headers: {
|
|
146
|
-
'Content-Type': 'application/json'
|
|
147
|
-
},
|
|
148
|
-
body: JSON.stringify({ command: commandName, params: params })
|
|
149
|
-
});
|
|
150
|
-
const data = await response.json();
|
|
151
|
-
fetchCommands();
|
|
152
|
-
viewOutput(data.command_id);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
async function fetchCommands() {
|
|
156
|
-
const response = await fetch('/commands');
|
|
157
|
-
const commands = await response.json();
|
|
158
|
-
commands.sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
|
|
159
|
-
const commandsTbody = document.getElementById('commands');
|
|
160
|
-
commandsTbody.innerHTML = '';
|
|
161
|
-
commands.forEach(command => {
|
|
162
|
-
const commandRow = document.createElement('tr');
|
|
163
|
-
commandRow.className = command.command_id === currentCommandId ? 'currentcommand' : '';
|
|
164
|
-
commandRow.innerHTML = `
|
|
165
|
-
<td class="monospace">
|
|
166
|
-
<span class="copy_clip" onclick="copyToClipboard('${command.command_id}', this)">${command.command_id.slice(0, 8)}</span>
|
|
167
|
-
</td>
|
|
168
|
-
<td><span class="status-icon status-${command.status}"></span>${command.status}</td>
|
|
169
|
-
<td>${formatTime(command.start_time)}</td>
|
|
170
|
-
<td>${command.status === 'running' ? formatDuration(command.start_time, new Date().toISOString()) : formatDuration(command.start_time, command.end_time)}</td>
|
|
171
|
-
<td>${command.exit_code}</td>
|
|
172
|
-
<td>${command.command.replace(/^\.\//, '')}</td>
|
|
173
|
-
<td>
|
|
174
|
-
<button onclick="viewOutput('${command.command_id}')">Log</button>
|
|
175
|
-
<button onclick="relaunchCommand('${command.command_id}')">Relaunch</button>
|
|
176
|
-
${command.status === 'running' ? `<button onclick="stopCommand('${command.command_id}')">Stop</button>` : ''}
|
|
177
|
-
</td>
|
|
178
|
-
`;
|
|
179
|
-
commandsTbody.appendChild(commandRow);
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
async function fetchExecutables() {
|
|
184
|
-
const response = await fetch('/executables');
|
|
185
|
-
const executables = await response.json();
|
|
186
|
-
const commandNameSelect = document.getElementById('commandName');
|
|
187
|
-
commandNameSelect.innerHTML = '';
|
|
188
|
-
executables.forEach(executable => {
|
|
189
|
-
const option = document.createElement('option');
|
|
190
|
-
option.value = executable;
|
|
191
|
-
option.textContent = executable;
|
|
192
|
-
commandNameSelect.appendChild(option);
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function fetchOutput(command_id) {
|
|
197
|
-
const outputDiv = document.getElementById('output');
|
|
198
|
-
const response = await fetch(`/command_output/${command_id}`);
|
|
199
|
-
const data = await response.json();
|
|
200
|
-
if (data.error) {
|
|
201
|
-
outputDiv.innerHTML = data.error;
|
|
202
|
-
clearInterval(outputInterval);
|
|
203
|
-
} else {
|
|
204
|
-
outputDiv.innerHTML = data.output;
|
|
205
|
-
outputDiv.scrollTop = outputDiv.scrollHeight;
|
|
206
|
-
if (data.status != 'running') {
|
|
207
|
-
clearInterval(outputInterval);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async function viewOutput(command_id) {
|
|
213
|
-
adjustOutputHeight();
|
|
214
|
-
currentCommandId = command_id;
|
|
215
|
-
clearInterval(outputInterval);
|
|
216
|
-
const response = await fetch(`/command_status/${command_id}`);
|
|
217
|
-
const data = await response.json();
|
|
218
|
-
if (data.status === 'running') {
|
|
219
|
-
fetchOutput(command_id);
|
|
220
|
-
outputInterval = setInterval(() => fetchOutput(command_id), 1000);
|
|
221
|
-
} else {
|
|
222
|
-
fetchOutput(command_id);
|
|
223
|
-
}
|
|
224
|
-
fetchCommands(); // Refresh the command list to highlight the current command
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function relaunchCommand(command_id) {
|
|
228
|
-
const response = await fetch(`/command_status/${command_id}`);
|
|
229
|
-
const data = await response.json();
|
|
230
|
-
if (data.error) {
|
|
231
|
-
alert(data.error);
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
const relaunchResponse = await fetch('/run_command', {
|
|
235
|
-
method: 'POST',
|
|
236
|
-
headers: {
|
|
237
|
-
'Content-Type': 'application/json'
|
|
238
|
-
},
|
|
239
|
-
body: JSON.stringify({
|
|
240
|
-
command: data.command,
|
|
241
|
-
params: data.params
|
|
242
|
-
})
|
|
243
|
-
});
|
|
244
|
-
const relaunchData = await relaunchResponse.json();
|
|
245
|
-
fetchCommands();
|
|
246
|
-
viewOutput(relaunchData.command_id);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async function stopCommand(command_id) {
|
|
250
|
-
const response = await fetch(`/stop_command/${command_id}`, {
|
|
251
|
-
method: 'POST'
|
|
252
|
-
});
|
|
253
|
-
const data = await response.json();
|
|
254
|
-
if (data.error) {
|
|
255
|
-
alert(data.error);
|
|
256
|
-
} else {
|
|
257
|
-
alert(data.message);
|
|
258
|
-
fetchCommands();
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function formatTime(time) {
|
|
263
|
-
if (!time || time === 'N/A') return 'N/A';
|
|
264
|
-
const date = new Date(time);
|
|
265
|
-
return date.toISOString().slice(0, 16).replace('T', ' ');
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function formatDuration(startTime, endTime) {
|
|
269
|
-
if (!startTime || !endTime) return 'N/A';
|
|
270
|
-
const start = new Date(startTime);
|
|
271
|
-
const end = new Date(endTime);
|
|
272
|
-
const duration = (end - start) / 1000;
|
|
273
|
-
const hours = Math.floor(duration / 3600);
|
|
274
|
-
const minutes = Math.floor((duration % 3600) / 60);
|
|
275
|
-
const seconds = Math.floor(duration % 60);
|
|
276
|
-
return `${hours}h ${minutes}m ${seconds}s`;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function copyToClipboard(text, element) {
|
|
280
|
-
navigator.clipboard.writeText(text).then(() => {
|
|
281
|
-
element.classList.add('copy_clip_ok');
|
|
282
|
-
setTimeout(() => {
|
|
283
|
-
element.classList.remove('copy_clip_ok');
|
|
284
|
-
}, 2000);
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function adjustOutputHeight() {
|
|
289
|
-
const outputDiv = document.getElementById('output');
|
|
290
|
-
const windowHeight = window.innerHeight;
|
|
291
|
-
const outputTop = outputDiv.getBoundingClientRect().top;
|
|
292
|
-
const maxHeight = windowHeight - outputTop - 30; // 20px for padding/margin
|
|
293
|
-
outputDiv.style.maxHeight = `${maxHeight}px`;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function initResizer() {
|
|
297
|
-
const resizer = document.getElementById('resizer');
|
|
298
|
-
const tableContainer = document.getElementById('tableContainer');
|
|
299
|
-
let startY, startHeight;
|
|
300
|
-
|
|
301
|
-
resizer.addEventListener('mousedown', (e) => {
|
|
302
|
-
startY = e.clientY;
|
|
303
|
-
startHeight = parseInt(document.defaultView.getComputedStyle(tableContainer).height, 10);
|
|
304
|
-
document.documentElement.addEventListener('mousemove', doDrag, false);
|
|
305
|
-
document.documentElement.addEventListener('mouseup', stopDrag, false);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
function doDrag(e) {
|
|
309
|
-
tableContainer.style.height = `${startHeight + e.clientY - startY}px`;
|
|
310
|
-
adjustOutputHeight();
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function stopDrag() {
|
|
314
|
-
document.documentElement.removeEventListener('mousemove', doDrag, false);
|
|
315
|
-
document.documentElement.removeEventListener('mouseup', stopDrag, false);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
window.addEventListener('resize', adjustOutputHeight);
|
|
320
|
-
window.addEventListener('load', () => {
|
|
321
|
-
adjustOutputHeight();
|
|
322
|
-
initResizer();
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
fetchCommands();
|
|
326
|
-
fetchExecutables();
|
|
327
|
-
setInterval(fetchCommands, 5000);
|
|
328
|
-
</script>
|
|
39
|
+
<script type="text/javascript" src="/static/js/script.js"></script>
|
|
329
40
|
</body>
|
|
330
41
|
</html>
|
pywebexec/version.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: pywebexec
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Simple Python HTTP Exec Server
|
|
5
5
|
Home-page: https://github.com/joknarf/pywebexec
|
|
6
6
|
Author: Franck Jouvanceau
|
|
@@ -58,6 +58,7 @@ Requires-Dist: cryptography>=40.0.2
|
|
|
58
58
|
Requires-Dist: Flask>=2.0.3
|
|
59
59
|
Requires-Dist: Flask-HTTPAuth>=4.8.0
|
|
60
60
|
Requires-Dist: gunicorn>=21.2.0
|
|
61
|
+
Requires-Dist: ldap3>=2.9.1
|
|
61
62
|
|
|
62
63
|
[](https://pypi.org/project/pywebexec/)
|
|
63
64
|

|
|
@@ -75,13 +76,14 @@ $ pip install pywebexec
|
|
|
75
76
|
|
|
76
77
|
## Quick start
|
|
77
78
|
|
|
79
|
+
* put in a directory the scripts/commands/links to commands you want to expose
|
|
78
80
|
* start http server serving current directory executables listening on 0.0.0.0 port 8080
|
|
79
|
-
```
|
|
81
|
+
```shell
|
|
80
82
|
$ pywebexec
|
|
81
83
|
```
|
|
82
84
|
|
|
83
85
|
* Launch commands with params/view live output/Status using browser
|
|
84
|
-

|
|
85
87
|
|
|
86
88
|
## features
|
|
87
89
|
|
|
@@ -92,40 +94,55 @@ $ pywebexec
|
|
|
92
94
|
* Relaunch command
|
|
93
95
|
* HTTPS support
|
|
94
96
|
* HTTPS self-signed certificate generator
|
|
97
|
+
* Basic Auth
|
|
98
|
+
* LDAP(S)
|
|
95
99
|
* Can be started as a daemon (POSIX)
|
|
96
100
|
* uses gunicorn to serve http/https
|
|
97
101
|
* compatible Linux/MacOS
|
|
98
102
|
|
|
99
103
|
## Customize server
|
|
100
|
-
```
|
|
104
|
+
```shell
|
|
101
105
|
$ pywebexec --dir ~/myscripts --listen 0.0.0.0 --port 8080
|
|
102
106
|
$ pywebexec -d ~/myscripts -l 0.0.0.0 -p 8080
|
|
103
107
|
```
|
|
104
108
|
|
|
105
|
-
## Basic auth
|
|
106
|
-
|
|
109
|
+
## Basic auth
|
|
110
|
+
|
|
111
|
+
* single user/password
|
|
112
|
+
```shell
|
|
107
113
|
$ pywebexec --user myuser [--password mypass]
|
|
108
|
-
$
|
|
114
|
+
$ pywebexec -u myuser [-P mypass]
|
|
109
115
|
```
|
|
110
116
|
Generated password is given if no `--pasword` option
|
|
111
117
|
|
|
118
|
+
* ldap(s) password check / group member
|
|
119
|
+
```shell
|
|
120
|
+
$ export PYWEBEXEC_LDAP_SERVER=ldap.forumsys.com
|
|
121
|
+
$ export PYWEBEXEC_LDAP_USE_SSL=0
|
|
122
|
+
$ export PYWEBEXEC_LDAP_BIND_DN="cn=read-only-admin,dc=example,dc=com"
|
|
123
|
+
$ export PYWEBEXEC_LDAP_BIND_PASSWORD="password"
|
|
124
|
+
$ export PYWEBEXEC_LDAP_GROUPS=mathematicians,scientists
|
|
125
|
+
$ export PYWEBEXEC_LDAP_USER_ID="uid"
|
|
126
|
+
$ export PYWEBEXEC_LDAP_BASE_DN="dc=example,dc=com"
|
|
127
|
+
$ pywebexec
|
|
128
|
+
```
|
|
112
129
|
## HTTPS server
|
|
113
130
|
|
|
114
131
|
* Generate auto-signed certificate and start https server
|
|
115
|
-
```
|
|
132
|
+
```shell
|
|
116
133
|
$ pywebfs --gencert
|
|
117
134
|
$ pywebfs --g
|
|
118
135
|
```
|
|
119
136
|
|
|
120
137
|
* Start https server using existing certificate
|
|
121
|
-
```
|
|
138
|
+
```shell
|
|
122
139
|
$ pywebfs --cert /pathto/host.cert --key /pathto/host.key
|
|
123
140
|
$ pywebfs -c /pathto/host.cert -k /pathto/host.key
|
|
124
141
|
```
|
|
125
142
|
|
|
126
143
|
## Launch server as a daemon
|
|
127
144
|
|
|
128
|
-
```
|
|
145
|
+
```shell
|
|
129
146
|
$ pywebexec start
|
|
130
147
|
$ pywebexec status
|
|
131
148
|
$ pywebexec stop
|
|
@@ -134,8 +151,8 @@ $ pywebexec stop
|
|
|
134
151
|
|
|
135
152
|
## Launch command through API
|
|
136
153
|
|
|
137
|
-
```
|
|
138
|
-
|
|
154
|
+
```shell
|
|
155
|
+
$ curl http://myhost:8080/run_script -H 'Content-Type: application/json' -X POST -d '{ "script_name":"myscript", "param":["param1", ...]}
|
|
139
156
|
```
|
|
140
157
|
|
|
141
158
|
## API reference
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
pywebexec/__init__.py,sha256=4spIsVaF8RJt8S58AG_wWoORRNkws9Iwqprj27C3ljM,99
|
|
2
|
+
pywebexec/pywebexec.py,sha256=KyKJcadzuXWh1Im0bkbu0ScEBMwsWTPQg5xPN2jDMdQ,21174
|
|
3
|
+
pywebexec/version.py,sha256=CqDGE4B1ZqZ-56mxeOFcXRTmlxrdOh4ayrjbcPjziE4,411
|
|
4
|
+
pywebexec/static/css/style.css,sha256=NiBoOzZ35eBM1ZP2HFNda-dzOsAv4xRPh3vsvVgLL9c,2513
|
|
5
|
+
pywebexec/static/images/aborted.svg,sha256=_mP43hU5QdRLFZIknBgjx-dIXrHgQG23-QV27ApXK2A,381
|
|
6
|
+
pywebexec/static/images/copy.svg,sha256=d9OwtGh5GzzZHzYcDrLfNxZYLth1Q64x7bRyYxu4Px0,622
|
|
7
|
+
pywebexec/static/images/copy_ok.svg,sha256=mEqUVUhSq8xaJK2msQkxRawnz_KwlCZ-tok8QS6hJ3g,451
|
|
8
|
+
pywebexec/static/images/failed.svg,sha256=ADZ7IKrUyOXtqpivnz3VcH0-Wru-I5MOi3OJAkI3hxk,1439
|
|
9
|
+
pywebexec/static/images/favicon.svg,sha256=ti80IfuDZwIvQcmJxkOeUaB1iMsiyOPmQmVO-h0y1IU,1126
|
|
10
|
+
pywebexec/static/images/running.svg,sha256=vBpiG6ClNUNCArkwsyqK7O-qhIKJX1NI7MSjclNSp_8,1537
|
|
11
|
+
pywebexec/static/images/success.svg,sha256=PJDcCSTevJh7rkfSFLtc7P0pbeh8PVQBS8DaOLQemmc,489
|
|
12
|
+
pywebexec/static/js/script.js,sha256=d16MohpBSvw5evamC4o3_MWayQ1B8Cj9TePNclroUvo,7093
|
|
13
|
+
pywebexec/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
pywebexec/templates/index.html,sha256=70yLF1zTrNG4cVFsi9gvy9WYurLXvvMdeR5EFIDafBA,1306
|
|
15
|
+
pywebexec-1.1.0.dist-info/LICENSE,sha256=gRJf0JPT_wsZJsUGlWPTS8Vypfl9vQ1qjp6sNbKykuA,1064
|
|
16
|
+
pywebexec-1.1.0.dist-info/METADATA,sha256=yeelN08_hUHw_GyZIb9oGFmfqr8KhSNJS1zEEnDOWGk,6567
|
|
17
|
+
pywebexec-1.1.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
18
|
+
pywebexec-1.1.0.dist-info/entry_points.txt,sha256=l52GBkPCXRkmlHfEyoVauyfBdg8o-CAtC8qQpOIjJK0,55
|
|
19
|
+
pywebexec-1.1.0.dist-info/top_level.txt,sha256=vHoHyzngrfGdm_nM7Xn_5iLmaCrf10XO1EhldgNLEQ8,10
|
|
20
|
+
pywebexec-1.1.0.dist-info/RECORD,,
|
pywebexec-1.0.0.dist-info/RECORD
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
pywebexec/__init__.py,sha256=4spIsVaF8RJt8S58AG_wWoORRNkws9Iwqprj27C3ljM,99
|
|
2
|
-
pywebexec/pywebexec.py,sha256=2lVnQd9BgaUunjUtgYKIwgIRllX11v64jZQgfweteFg,16281
|
|
3
|
-
pywebexec/version.py,sha256=DGJ4pj32xs3_DRJhSzQwCiRNnAQrMgo09USYpyMZsKc,411
|
|
4
|
-
pywebexec/static/images/aborted.svg,sha256=_mP43hU5QdRLFZIknBgjx-dIXrHgQG23-QV27ApXK2A,381
|
|
5
|
-
pywebexec/static/images/copy.svg,sha256=d9OwtGh5GzzZHzYcDrLfNxZYLth1Q64x7bRyYxu4Px0,622
|
|
6
|
-
pywebexec/static/images/copy_ok.svg,sha256=mEqUVUhSq8xaJK2msQkxRawnz_KwlCZ-tok8QS6hJ3g,451
|
|
7
|
-
pywebexec/static/images/failed.svg,sha256=ADZ7IKrUyOXtqpivnz3VcH0-Wru-I5MOi3OJAkI3hxk,1439
|
|
8
|
-
pywebexec/static/images/running.svg,sha256=vBpiG6ClNUNCArkwsyqK7O-qhIKJX1NI7MSjclNSp_8,1537
|
|
9
|
-
pywebexec/static/images/success.svg,sha256=PJDcCSTevJh7rkfSFLtc7P0pbeh8PVQBS8DaOLQemmc,489
|
|
10
|
-
pywebexec/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
pywebexec/templates/index.html,sha256=taMeaWiX7ijHI-kWVyjIBH0E0Fj5RY7-9ocOXOiTZCk,12431
|
|
12
|
-
pywebexec-1.0.0.dist-info/LICENSE,sha256=gRJf0JPT_wsZJsUGlWPTS8Vypfl9vQ1qjp6sNbKykuA,1064
|
|
13
|
-
pywebexec-1.0.0.dist-info/METADATA,sha256=CrooVWXHqkcV0PtBpWzHedlZVLvoR0b77O_MxojvK6E,5970
|
|
14
|
-
pywebexec-1.0.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
15
|
-
pywebexec-1.0.0.dist-info/entry_points.txt,sha256=l52GBkPCXRkmlHfEyoVauyfBdg8o-CAtC8qQpOIjJK0,55
|
|
16
|
-
pywebexec-1.0.0.dist-info/top_level.txt,sha256=vHoHyzngrfGdm_nM7Xn_5iLmaCrf10XO1EhldgNLEQ8,10
|
|
17
|
-
pywebexec-1.0.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|