Merge pull request #6 from blw1138/client_job_admin

Client job admin
This commit is contained in:
2023-05-31 23:31:28 -05:00
committed by GitHub
6 changed files with 326 additions and 30 deletions

View File

@@ -30,7 +30,7 @@ status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', R
RenderStatus.RUNNING: 'cyan'}
categories = [RenderStatus.RUNNING, RenderStatus.ERROR, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED,
RenderStatus.COMPLETED, RenderStatus.CANCELLED]
RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.UNDEFINED]
renderer_colors = {'ffmpeg': '[magenta]', 'blender': '[orange1]', 'aerender': '[purple]'}

242
lib/client/client.py Normal file
View File

@@ -0,0 +1,242 @@
import subprocess
import requests
import tkinter as tk
import threading
import time
import socket
import os
from tkinter import ttk, messagebox
from PIL import Image, ImageTk
from new_job_window import NewJobWindow
from server_proxy import RenderServerProxy
def request_data(server_ip, payload, server_port=8080, timeout=2):
try:
req = requests.get(f'http://{server_ip}:{server_port}/api/{payload}', timeout=timeout)
if req.ok:
return req.json()
except Exception as e:
pass
return None
def sort_column(tree, col, reverse=False):
data = [(tree.set(child, col), child) for child in tree.get_children('')]
data.sort(reverse=reverse)
for index, (_, child) in enumerate(data):
tree.move(child, '', index)
def make_sortable(tree):
for col in tree["columns"]:
tree.heading(col, text=col, command=lambda c=col: sort_column(tree, c))
def available_servers():
return [socket.gethostname()]
class ZordonClient:
def __init__(self):
servers = available_servers()
# Create a Treeview widget
self.root = tk.Tk()
self.root.title("Zordon Render Client")
self.local_host = socket.gethostname()
self.server_proxy = RenderServerProxy(hostname=servers[0])
self.job_cache = []
# Setup photo preview
photo_pad = tk.Frame(self.root, background="gray")
photo_pad.pack(fill=tk.BOTH, pady=5, padx=5)
self.photo_label = tk.Label(photo_pad, height=500)
self.photo_label.pack(fill=tk.BOTH, expand=True)
server_frame = tk.LabelFrame(self.root, text="Server")
server_frame.pack(fill=tk.BOTH, pady=5, padx=5, expand=True)
server_picker_frame = tk.Frame(server_frame)
server_picker_frame.pack(fill=tk.X, expand=True)
server_label = tk.Label(server_picker_frame, text="Current Server:")
server_label.pack(side=tk.LEFT, padx=5)
self.server_combo = tk.ttk.Combobox(server_picker_frame)
self.server_combo.pack(fill=tk.X)
self.server_combo.bind("<<ComboboxSelected>>", self.server_picked)
self.server_combo['values'] = servers
self.server_combo.current(0)
# Setup the Tree
self.tree = ttk.Treeview(server_frame, show="headings", height=20)
self.tree.tag_configure('running', background='lawn green', font=('', 0, 'bold'))
self.tree.bind("<<TreeviewSelect>>", self.on_row_select)
self.tree["columns"] = ("id", "Name", "Renderer", "Priority", "Status", "Time Elapsed", "Frames")
# Format the columns
self.tree.column("id", width=0, stretch=False)
self.tree.column("Name", width=50)
self.tree.column("Renderer", width=100, stretch=False)
self.tree.column("Priority", width=50, stretch=False)
self.tree.column("Status", width=100, stretch=False)
self.tree.column("Time Elapsed", width=100, stretch=False)
self.tree.column("Frames", width=50, stretch=False)
# Create the column headings
for name in self.tree['columns']:
self.tree.heading(name, text=name)
# Pack the Treeview widget
self.tree.pack(fill=tk.BOTH, expand=True)
button_frame = tk.Frame(server_frame)
button_frame.pack(pady=5, fill=tk.BOTH, expand=True)
# Create buttons
logs_button = tk.Button(button_frame, text="Logs", command=self.open_logs)
finder_button = tk.Button(button_frame, text="Reveal in Finder", command=self.reveal_in_finder)
self.stop_button = tk.Button(button_frame, text="Stop", command=self.stop_job)
delete_button = tk.Button(button_frame, text="Delete", command=self.delete_job)
add_job_button = tk.Button(button_frame, text="Add Job", command=self.show_new_job_window)
# Pack the buttons in the frame
self.stop_button.pack(side=tk.LEFT)
delete_button.pack(side=tk.LEFT)
finder_button.pack(side=tk.LEFT)
logs_button.pack(side=tk.LEFT)
add_job_button.pack(side=tk.RIGHT)
# Start the Tkinter event loop
self.root.geometry("500x600+300+300")
self.root.maxsize(width=2000, height=1200)
self.root.minsize(width=900, height=1000)
make_sortable(self.tree)
self.update_jobs()
selected_item = self.tree.get_children()[0]
self.tree.selection_set(selected_item)
self.start_update_thread()
def server_picked(self, event):
new_hostname = self.server_combo.get()
self.server_proxy.hostname = new_hostname
self.update_jobs(clear_table=True)
def stop_job(self):
selected_item = self.tree.selection()[0] # Get the selected item
row_data = self.tree.item(selected_item) # Get the text of the selected item
job_id = row_data['values'][0]
self.server_proxy.request_data(f'job/{job_id}/cancel?confirm=true')
def delete_job(self):
selected_item = self.tree.selection()[0] # Get the selected item
row_data = self.tree.item(selected_item) # Get the text of the selected item
job_id = row_data['values'][0]
job = next((d for d in self.job_cache if d.get('id') == job_id), None)
display_name = job['name'] or os.path.basename(job['input_path'])
result = messagebox.askyesno("Confirmation", f"Are you sure you want to delete the job:\n{display_name}?")
if result:
self.server_proxy.request_data(f'job/{job_id}/delete?confirm=true')
self.update_jobs(clear_table=True)
def on_row_select(self, event):
if self.tree.selection():
selected_item = self.tree.selection()[0] # Get the selected item
row_data = self.tree.item(selected_item) # Get the text of the selected item
job_id = row_data['values'][0]
# update thumb
thumb_url = f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/ui/job/{job_id}/thumbnail?size=big'
response = requests.get(thumb_url)
if response.status_code == 200:
try:
import io
image_data = response.content
image = Image.open(io.BytesIO(image_data))
thumb_image = ImageTk.PhotoImage(image)
if thumb_image:
self.photo_label.configure(image=thumb_image)
self.photo_label.image = thumb_image
except Exception as e:
print(f"error getting image: {e}")
# update button status
job = next((d for d in self.job_cache if d.get('id') == job_id), None)
button_state = 'normal' if job['status'] == 'running' else 'disabled'
self.stop_button.config(state=button_state)
def reveal_in_finder(self):
selected_item = self.tree.selection()[0] # Get the selected item
row_data = self.tree.item(selected_item) # Get the text of the selected item
job_id = row_data['values'][0]
job = next((d for d in self.job_cache if d.get('id') == job_id), None)
output_dir = os.path.dirname(job['output_path'])
subprocess.run(['open', output_dir])
def open_logs(self):
selected_item = self.tree.selection()[0] # Get the selected item
row_data = self.tree.item(selected_item) # Get the text of the selected item
job_id = row_data['values'][0]
job = next((d for d in self.job_cache if d.get('id') == job_id), None)
subprocess.run(['open', job['log_path']])
def mainloop(self):
self.root.mainloop()
def start_update_thread(self):
x = threading.Thread(target=self.__background_update)
x.daemon = True
x.start()
def __background_update(self):
while True:
self.update_jobs(clear_table=False)
time.sleep(1)
def update_jobs(self, clear_table=False):
def update_row(tree, id, new_values, tags=None):
for item in tree.get_children():
values = tree.item(item, "values")
if values[0] == id:
tree.item(item, values=new_values, tags=tags)
break
if clear_table:
self.tree.delete(*self.tree.get_children())
self.job_cache.clear()
job_fetch = self.server_proxy.get_jobs()
if job_fetch:
self.job_cache = job_fetch # update the cache only if its good data
for job in self.job_cache:
display_status = job['status'] if job['status'] != 'running' else \
('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percentage, otherwise just show status
tags = (job['status'],)
values = (job['id'],
job['name'] or os.path.basename(job['input_path']),
job['renderer'] + "-" + job['renderer_version'],
job['priority'],
display_status,
job['time_elapsed'],
job['total_frames'])
if self.tree.exists(job['id']):
update_row(self.tree, job['id'], new_values=values, tags=tags)
else:
self.tree.insert("", tk.END, iid=job['id'], values=values, tags=tags)
def show_new_job_window(self):
new_window = tk.Toplevel(self.root)
new_window.title("New Window")
new_window.geometry("500x600+300+300")
new_window.resizable(False, height=True)
x = NewJobWindow(parent=new_window, hostname=self.server_proxy.hostname)
x.pack()
if __name__ == '__main__':
x = ZordonClient()
x.mainloop()

View File

@@ -13,7 +13,7 @@ import psutil
import requests
from lib.render_workers.blender_worker import Blender
from lib.utilities.server_helper import post_job_to_server
from server_proxy import RenderServerProxy
logger = logging.getLogger()
@@ -61,24 +61,21 @@ class ChecklistBox(Frame):
return values
class ScheduleJob(Frame):
class NewJobWindow(Frame):
def __init__(self):
super().__init__()
def __init__(self, parent=None, hostname=None):
super().__init__(parent)
self.server_hostname = None
self.server_proxy = RenderServerProxy(hostname=hostname)
self.chosen_file = None
self.clients = []
self.presets = {}
self.renderer_info = {}
self.priority = IntVar(value=2)
self.master.title("Schedule Job")
self.master.title("New Job")
self.pack(fill=BOTH, expand=True)
self.server_button = Button(self, text="", width=6, command=self.request_new_hostname)
self.server_button.pack(fill=X, padx=5, expand=False)
# project frame
job_frame = LabelFrame(self, text="Job Settings")
job_frame.pack(fill=X, padx=5, pady=5)
@@ -156,24 +153,12 @@ class ScheduleJob(Frame):
self.custom_args_entry = None
self.submit_frame = None
if os.path.exists(prefs_name):
with open(prefs_name, 'r') as file:
hostname = file.read()
server_data = request_data(hostname, 'status', timeout=server_setup_timeout)
if server_data:
self.set_hostname(hostname)
if not self.server_hostname:
server_data = request_data('localhost', 'status', timeout=server_setup_timeout)
if server_data:
self.set_hostname(server_data['host_name'])
else:
self.request_new_hostname()
self.fetch_server_data()
def fetch_server_data(self):
self.clients = request_data(self.server_hostname, 'clients', timeout=3) or []
self.renderer_info = request_data(self.server_hostname, 'renderer_info', timeout=3) or {}
self.presets = request_data(self.server_hostname, 'presets', timeout=3) or {}
self.clients = self.server_proxy.request_data('clients', timeout=3) or []
self.renderer_info = self.server_proxy.request_data('renderer_info', timeout=3) or {}
self.presets = self.server_proxy.request_data('presets', timeout=3) or {}
# update clients
self.client_combo['values'] = self.clients
@@ -388,7 +373,7 @@ class ScheduleJob(Frame):
# Submit to server
job_list = job_list or [job_json]
result = post_job_to_server(input_path=input_path, job_list=job_list, hostname=client)
result = self.server_proxy.post_job_to_server(input_path=input_path, job_list=job_list)
if result.ok:
messagebox.showinfo("Success", "Job successfully submitted to server.")
else:
@@ -405,7 +390,7 @@ def main():
root.geometry("500x600+300+300")
root.maxsize(width=1000, height=2000)
root.minsize(width=600, height=600)
app = ScheduleJob()
app = NewJobWindow(root)
root.mainloop()

View File

@@ -0,0 +1,54 @@
import os
import json
import requests
from lib.render_workers.base_worker import RenderStatus
status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', RenderStatus.COMPLETED: 'green',
RenderStatus.NOT_STARTED: "yellow", RenderStatus.SCHEDULED: 'purple',
RenderStatus.RUNNING: 'cyan'}
categories = [RenderStatus.RUNNING, RenderStatus.ERROR, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED,
RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.UNDEFINED]
class RenderServerProxy:
def __init__(self, hostname=None, server_port="8080"):
self.hostname = hostname
self.port = server_port
self.fetched_status_data = None
def connect(self):
status = self.request_data('status')
return status
def request_data(self, payload, timeout=5):
try:
req = requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout)
if req.ok:
return req.json()
except Exception as e:
pass
return None
def get_jobs(self):
all_jobs = self.request_data('jobs')
sorted_jobs = []
if all_jobs:
for status_category in categories:
found_jobs = [x for x in all_jobs if x['status'] == status_category.value]
if found_jobs:
sorted_jobs.extend(found_jobs)
return sorted_jobs
def get_data(self, timeout=5):
all_data = self.request_data('full_status', timeout=timeout)
return all_data
def post_job_to_server(self, input_path, job_list):
# Pack job data and submit to server
job_files = {'file': (os.path.basename(input_path), open(input_path, 'rb'), 'application/octet-stream'),
'json': (None, json.dumps(job_list), 'application/json')}
req = requests.post(f'http://{self.hostname}:{self.port}/api/add_job', files=job_files)
return req

View File

@@ -244,7 +244,7 @@ class BaseRenderWorker(Base):
self.stop(is_error=True)
def stop(self, is_error=False):
if self.__process:
if hasattr(self, '__process'):
try:
if self.status in [RenderStatus.RUNNING, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED]:
if is_error:
@@ -324,7 +324,8 @@ class BaseRenderWorker(Base):
'renderer_version': self.renderer_version,
'errors': getattr(self, 'errors', None),
'total_frames': self.total_frames,
'last_output': getattr(self, 'last_output', None)
'last_output': getattr(self, 'last_output', None),
'log_path': self.log_path()
}
# convert to json and back to auto-convert dates to iso format

View File

@@ -70,17 +70,27 @@ def job_detail(job_id):
@server.route('/ui/job/<job_id>/thumbnail')
def job_thumbnail(job_id):
big_thumb = request.args.get('size', False) == "big"
found_job = RenderQueue.job_with_id(job_id, none_ok=True)
if found_job:
os.makedirs(server.config['THUMBS_FOLDER'], exist_ok=True)
thumb_video_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.mp4')
thumb_image_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.jpg')
big_video_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '_big.mp4')
big_image_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '_big.jpg')
if not os.path.exists(thumb_video_path) and not os.path.exists(thumb_video_path + '_IN-PROGRESS') and \
found_job.status not in [RenderStatus.CANCELLED, RenderStatus.ERROR]:
generate_thumbnail_for_job(found_job, thumb_video_path, thumb_image_path, max_width=240)
if big_thumb and os.path.exists(big_video_path) and not os.path.exists(big_video_path + '_IN-PROGRESS'):
return send_file(big_video_path, mimetype="video/mp4")
elif big_thumb and os.path.exists(big_image_path):
return send_file(big_image_path, mimetype='image/jpeg')
elif not os.path.exists(big_video_path) and not os.path.exists(big_image_path + '_IN-PROGRESS') and \
found_job.status not in [RenderStatus.CANCELLED, RenderStatus.ERROR]:
generate_thumbnail_for_job(found_job, big_video_path, big_image_path, max_width=800)
if os.path.exists(thumb_video_path) and not os.path.exists(thumb_video_path + '_IN-PROGRESS'):
return send_file(thumb_video_path, mimetype="video/mp4")
elif os.path.exists(thumb_image_path):
@@ -110,7 +120,11 @@ def get_job_file(job_id, filename):
@server.get('/api/jobs')
def jobs_json():
return [x.json() for x in RenderQueue.all_jobs()]
try:
return [x.json() for x in RenderQueue.all_jobs()]
except Exception as e:
logger.exception(f"Exception fetching all_jobs: {e}")
return [], 500
@server.get('/api/jobs/<status_val>')