Files
Zordon/lib/client/dashboard_window.py

344 lines
14 KiB
Python

import datetime
import logging
import tkinter as tk
import threading
import time
import socket
import os
from tkinter import ttk, messagebox, simpledialog
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, file_exists_in_mounts, get_time_elapsed
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 = []
self.added_hostnames = []
# 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
left_frame = tk.Frame(server_frame)
left_frame.pack(side=tk.LEFT, expand=True, fill=tk.BOTH)
self.server_tree = ttk.Treeview(left_frame, show="headings")
self.server_tree.pack(expand=True, fill=tk.BOTH)
self.server_tree["columns"] = ("Server")
self.server_tree.column("Server", width=50)
self.server_tree.bind("<<TreeviewSelect>>", self.server_picked)
self.server_tree.column("Server", width=200)
left_button_frame = tk.Frame(left_frame)
left_button_frame.pack(side=tk.BOTTOM, fill=tk.X, expand=False)
# Create buttons
add_server_button = tk.Button(left_button_frame, text="Add Server", command=self.add_server_button)
add_server_button.pack(side=tk.RIGHT, padx=5, pady=5)
# 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("<<TreeviewSelect>>", 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.X, expand=False)
# Create buttons
self.logs_button = tk.Button(button_frame, text="Logs", command=self.open_logs)
self.show_files_button = tk.Button(button_frame, text="Show Files", command=self.show_files)
self.stop_button = tk.Button(button_frame, text="Stop", command=self.stop_job)
self.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)
self.stop_button.config(state='disabled')
self.delete_button.pack(side=tk.LEFT)
self.delete_button.config(state='disabled')
self.show_files_button.pack(side=tk.LEFT)
self.show_files_button.config(state='disabled')
self.logs_button.pack(side=tk.LEFT)
self.logs_button.config(state='disabled')
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)
make_sortable(self.server_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 add_server_button(self):
hostname = simpledialog.askstring("Server Hostname", "Enter the server hostname to add:")
if hostname:
hostname = hostname.strip()
if hostname not in self.added_hostnames:
if RenderServerProxy(hostname=hostname).connect():
self.added_hostnames.append(hostname)
else:
messagebox.showerror("Cannot Connect", f"Cannot connect to server at hostname: '{hostname}'")
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)
stop_button_state = 'normal' if job and job['status'] == 'running' else 'disabled'
self.stop_button.config(state=stop_button_state)
generic_button_state = 'normal' if job else 'disabled'
self.show_files_button.config(state=generic_button_state)
self.delete_button.config(state=generic_button_state)
self.logs_button.config(state=generic_button_state)
def show_files(self):
output_path = None
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_path = os.path.dirname(job['output_path']) # check local filesystem
if not os.path.exists(output_path):
output_path = file_exists_in_mounts(output_path) # check any attached network shares
if output_path:
launch_url(output_path)
else:
messagebox.showerror("File Not Found", "The file could not be found. Check your network mounts.")
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 = list(set(self.zeroconf.found_clients() + self.added_hostnames))
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(ignore_token=clear_table)
# 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,
get_time_elapsed(datetime.datetime.fromisoformat(job['start_time']),
datetime.datetime.fromisoformat(job['end_time'])),
job['total_frames'],
job['date_created'],
job['owner'])
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()