import subprocess import logging import requests import tkinter as tk import threading import time import socket import os, sys from tkinter import ttk, messagebox from PIL import Image, ImageTk from new_job_window import NewJobWindow from server_proxy import RenderServerProxy sys.path.append("../") from lib.server.zeroconf_server import ZeroconfServer 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: default_image = Image.open("../server/static/images/desktop.png") def __init__(self): # Create a Treeview widget self.root = tk.Tk() self.root.title("Zordon Render Client") self.local_host = socket.gethostname() self.server_proxy = RenderServerProxy(hostname=self.local_host) self.job_cache = [] # Setup zeroconf self.zeroconf = ZeroconfServer("_zordon._tcp.local.", socket.gethostname(), 8080) self.zeroconf.start(listen_only=True) # 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) self.set_image(self.default_image) server_frame = tk.LabelFrame(self.root, text="Server") server_frame.pack(fill=tk.BOTH, pady=5, padx=5, expand=True) # Create server tree self.server_tree = ttk.Treeview(server_frame, show="headings") self.server_tree.pack(side=tk.LEFT, expand=True, fill=tk.BOTH) self.server_tree["columns"] = ("server") self.server_tree.column("server", width=50) self.server_tree.bind("<>", self.server_picked) self.server_tree.column("server", width=200) # Create separator separator = ttk.Separator(server_frame, orient=tk.VERTICAL) separator.pack(side=tk.LEFT, padx=5, pady=5, fill=tk.Y) # Setup the Tree self.job_tree = ttk.Treeview(server_frame, show="headings", height=20) self.job_tree.tag_configure('running', background='lawn green', font=('', 0, 'bold')) self.job_tree.bind("<>", self.on_row_select) self.job_tree["columns"] = ("id", "Name", "Renderer", "Priority", "Status", "Time Elapsed", "Frames", "") # Format the columns self.job_tree.column("id", width=0, stretch=False) self.job_tree.column("Name", width=300) self.job_tree.column("Renderer", width=100, stretch=False) self.job_tree.column("Priority", width=50, stretch=False) self.job_tree.column("Status", width=100, stretch=False) self.job_tree.column("Time Elapsed", width=100, stretch=False) self.job_tree.column("Frames", width=50, stretch=False) # Create the column headings for name in self.job_tree['columns']: self.job_tree.heading(name, text=name) # Pack the Treeview widget self.job_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.job_tree) # update jobs self.update_jobs() try: selected_job = self.job_tree.get_children()[0] self.job_tree.selection_set(selected_job) except IndexError: pass # update servers self.update_servers() try: selected_server = self.server_tree.get_children()[0] self.server_tree.selection_set(selected_server) except IndexError: pass # start background update x = threading.Thread(target=self.__background_update) x.daemon = True x.start() def server_picked(self, event): try: new_hostname = self.server_tree.selection()[0] self.server_proxy.hostname = new_hostname except IndexError: pass self.job_cache.clear() self.update_jobs(clear_table=True) def selected_job_id(self): try: selected_item = self.job_tree.selection()[0] # Get the selected item row_data = self.job_tree.item(selected_item) # Get the text of the selected item job_id = row_data['values'][0] return job_id except Exception as e: return None def stop_job(self): job_id = self.selected_job_id() self.server_proxy.request_data(f'job/{job_id}/cancel?confirm=true') def delete_job(self): job_id = self.selected_job_id() 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.job_cache.clear() self.update_jobs(clear_table=True) def set_image(self, image): thumb_image = ImageTk.PhotoImage(image) if thumb_image: self.photo_label.configure(image=thumb_image) self.photo_label.image = thumb_image def on_row_select(self, event): job_id = self.selected_job_id() if job_id: # update thumb response = self.server_proxy.request(f'job/{job_id}/thumbnail?size=big') if response.ok: try: import io image_data = response.content image = Image.open(io.BytesIO(image_data)) self.set_image(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 and job['status'] == 'running' else 'disabled' self.stop_button.config(state=button_state) else: self.set_image(self.default_image) def reveal_in_finder(self): job = next((d for d in self.job_cache if d.get('id') == self.selected_job_id()), None) output_dir = os.path.dirname(job['output_path']) subprocess.run(['open', output_dir]) def open_logs(self): job = next((d for d in self.job_cache if d.get('id') == self.selected_job_id()), None) subprocess.run(['open', job['log_path']]) def mainloop(self): self.root.mainloop() def __background_update(self): while True: self.update_jobs() self.update_servers() time.sleep(3) def update_servers(self): servers = self.zeroconf.found_clients() if len(servers) < len(self.server_tree.get_children()): self.server_tree.delete(*self.server_tree.get_children()) for hostname in servers: if hostname not in self.server_tree.get_children(): self.server_tree.insert("", tk.END, iid=hostname, values=(hostname,)) def update_jobs(self, clear_table=False): def update_jobs_inner(): 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 job_fetch = self.server_proxy.get_jobs() if job_fetch is not None: if len(job_fetch) < len(self.job_cache) or len(job_fetch) == 0: self.job_tree.delete(*self.job_tree.get_children()) 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']) try: if self.job_tree.exists(job['id']): update_row(self.job_tree, job['id'], new_values=values, tags=tags) else: self.job_tree.insert("", tk.END, iid=job['id'], values=values, tags=tags) except tk.TclError: pass if clear_table: self.job_tree.delete(*self.job_tree.get_children()) x = threading.Thread(target=update_jobs_inner) x.daemon = True x.start() 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()