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 lib.client.new_job_window import NewJobWindow from lib.server.server_proxy import RenderServerProxy from lib.server.zeroconf_server import ZeroconfServer from lib.utilities.misc_helper import launch_url logger = logging.getLogger() 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)) class DashboardWindow: lib_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) image_path = os.path.join(lib_path, 'server', 'static', 'images') default_image = Image.open(os.path.join(image_path, 'desktop.png')) def __init__(self): # Create a Treeview widget self.root = tk.Tk() self.root.title("Zordon Dashboard") 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) make_sortable(self.server_tree) # 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") 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", "Date Added", "Owner", "") # 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) self.job_tree.column("Date Added", width=150, stretch=True) self.job_tree.column("Owner", width=250, stretch=True) # 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) self.stop_button.config(state='disabled') # 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=800) 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] if self.server_proxy.hostname == new_hostname: return self.server_proxy.hostname = new_hostname except IndexError: pass self.job_cache.clear() self.update_jobs(clear_table=True) def selected_job_ids(self): selected_items = self.job_tree.selection() # Get the selected item row_data = [self.job_tree.item(item) for item in selected_items] # Get the text of the selected item job_ids = [row['values'][0] for row in row_data] return job_ids def stop_job(self): job_ids = self.selected_job_ids() for job_id in job_ids: self.server_proxy.request_data(f'job/{job_id}/cancel?confirm=true') self.update_jobs(clear_table=True) def delete_job(self): job_ids = self.selected_job_ids() if len(job_ids) == 1: job = next((d for d in self.job_cache if d.get('id') == job_ids[0]), None) display_name = job['name'] or os.path.basename(job['input_path']) message = f"Are you sure you want to delete the job:\n{display_name}?" else: message = f"Are you sure you want to delete these {len(job_ids)} jobs?" result = messagebox.askyesno("Confirmation", message) if result: for job_id in job_ids: 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_ids()[0] if self.selected_job_ids() else None if job_id: # update thumb def fetch_preview(): hostname = self.server_proxy.hostname 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)) if self.server_proxy.hostname == hostname and job_id == self.selected_job_ids()[0]: self.set_image(image) except Exception as e: logger.error(f"error fetching image: {e}") fetch_thread = threading.Thread(target=fetch_preview) fetch_thread.daemon = True fetch_thread.start() else: self.set_image(self.default_image) # 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) def reveal_in_finder(self): if self.selected_job_ids(): job = next((d for d in self.job_cache if d.get('id') == self.selected_job_ids()[0]), None) output_dir = os.path.dirname(job['output_path']) launch_url(output_dir) def open_logs(self): if self.selected_job_ids(): url = f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/api/job/{self.selected_job_ids()[0]}/logs' launch_url(url) 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 hostname = self.server_proxy.hostname job_fetch = self.server_proxy.get_jobs() # have to check hostname is still valid because of delay in fetching jobs if hostname == self.server_proxy.hostname: 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 percent, 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, clients=list(self.server_tree.get_children())) x.pack() def start_client(): logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S', level='INFO'.upper()) x = DashboardWindow() x.mainloop() if __name__ == '__main__': start_client()