Compare commits

..
35 Commits
Author SHA1 Message Date
bennykok eb5e2f36cd chore: update default git url 2024-02-02 00:55:38 +08:00
bennykok 0b0015fbd1 fix: async issues with updating python to 3.11.1 2024-02-02 00:31:04 +08:00
bennykok abb4a4798c fix: revert app back with async 2024-02-02 00:11:40 +08:00
bennykok 95238469d6 chore: update custom route 2024-02-01 23:38:35 +08:00
bennykok 86f14b5bce fix: roll back to old app 2024-02-01 22:11:17 +08:00
bennykok 84802f5c4e feat: update custom nodes to latest hash 2024-02-01 19:42:46 +08:00
bennykok 68b4d93639 feat(plugin): add run log in comfydeploy plugin level 2024-02-01 18:32:03 +08:00
bennykok 5d349153d2 fix: if timestamp is undefined skip rendering 2024-02-01 18:30:57 +08:00
bennykok ea675fcd4c fix: client side refresh for component 2024-02-01 17:01:54 +08:00
bennykok 4c715b815a feat: bring back refresh indicator 2024-02-01 16:08:50 +08:00
bennykok bef6ce35de fix: auto refresh table 2024-02-01 15:24:43 +08:00
bennykok d8951df35f fix: log and async task issues with modal script 2024-02-01 13:42:01 +08:00
bennykok 38fea1e79f fix: docs page 2024-02-01 11:57:15 +08:00
bennykok 66ad3ab4c2 fix: remove base model from new machine settings 2024-01-31 23:33:55 +08:00
bennykok 488d2aee8c fix: machine build height 2024-01-31 21:41:04 +08:00
bennykok c4628f6e4c feat: output render support mp4 and webm 2024-01-31 21:23:00 +08:00
bennykok 9ba349d36a refactor(plugin): upload files logic in custom routes 2024-01-31 21:22:44 +08:00
bennykok 08ab93127e chore(plugin): add a 2 secs delay before loading the incoming workflow 2024-01-31 21:22:14 +08:00
bennykok de641f0acf fix: log viewer 2024-01-31 21:21:22 +08:00
bennykok d4d7e98487 fix: comfydeploy npm code display 2024-01-31 18:00:31 +08:00
bennykok 967a77d3a3 fix: gif file not returning file url. 2024-01-31 16:46:08 +08:00
bennykok a4cd5db360 fix: ensure when getting current pricing plan is not deleted 2024-01-31 16:02:01 +08:00
bennykok 576a6744a4 fix: safe catch stripe changed credential 2024-01-31 15:49:21 +08:00
bennykok 3e5ff7702e feat: run log dialog 2024-01-31 15:30:29 +08:00
bennykok 009589630d fix: build 2024-01-31 14:29:42 +08:00
bennykok 8eb2ce3e10 Squashed commit of the following:
commit 33c0ad7d14a85f22c57f943dab58610c13d2ac07
Author: Nicholas Koben Kao <[email protected]>
Date:   Tue Jan 30 21:56:00 2024 -0800

    revert custom form change

commit d2905ad045ad7856156e3647a81d642999352de7
Merge: 654423d e3a1d24
Author: Nicholas Koben Kao <[email protected]>
Date:   Tue Jan 30 20:50:06 2024 -0800

    merge schema

commit 654423d597e019a5ebf1ab6568c9942fcb9181c5
Author: Nicholas Koben Kao <[email protected]>
Date:   Tue Jan 30 20:49:34 2024 -0800

    merge confl.ict

commit 641724c11346319674fbb329e8e29b362117c242
Author: Nicholas Koben Kao <[email protected]>
Date:   Tue Jan 30 20:47:34 2024 -0800

    model reload on create

commit eb4dfe8e3f39a0a98eab0fcf1affe7096c12f33b
Author: Nicholas Koben Kao <[email protected]>
Date:   Tue Jan 30 17:00:03 2024 -0800

    delete models

commit 0bea9583fada102396c4e08fe6da971c94d404df
Author: Nicholas Koben Kao <[email protected]>
Date:   Tue Jan 30 14:35:15 2024 -0800

    deploy volume uploader to have timeouts only be modal related
2024-01-31 14:29:36 +08:00
bennykok e3a1d24304 feat: add auto form value provider 2024-01-31 11:39:13 +08:00
bennykok 2d033570f4 fix: modal logging with complete async io rewrite 2024-01-31 00:41:41 +08:00
bennykok 7ae25aa162 fix: turn log type from text to jsonb 2024-01-31 00:31:00 +08:00
bennykok 4b37de9ec5 fix: turn string check into any for udpate status endpoint 2024-01-30 23:48:15 +08:00
bennykok b0d1bcc303 feat: add timeout and run_log 2024-01-30 21:42:30 +08:00
bennykok 2193dd287d fix: ensure logs are disabled by default 2024-01-30 15:17:25 +08:00
bennykok 81bde40aeb fix: default value 2024-01-30 11:26:50 +08:00
bennykok 317f699c46 tweak 2024-01-30 11:17:41 +08:00
bennykok 0d1bb2aaf4 fix: machine selections 2024-01-30 11:14:46 +08:00
53 changed files with 4110 additions and 799 deletions
+67 -7
View File
@@ -19,7 +19,7 @@ import requests
from urllib.parse import parse_qs from urllib.parse import parse_qs
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp, Scope, Receive, Send from starlette.types import ASGIApp, Scope, Receive, Send
import modal
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -180,6 +180,8 @@ class Item(BaseModel):
models: List[Model] models: List[Model]
callback_url: str callback_url: str
model_volume_name: str model_volume_name: str
run_timeout: Optional[int] = Field(default=60 * 5)
idle_timeout: Optional[int] = Field(default=60)
gpu: GPUType = Field(default=GPUType.T4) gpu: GPUType = Field(default=GPUType.T4)
@field_validator('gpu') @field_validator('gpu')
@@ -234,6 +236,14 @@ class UploadType(str, Enum):
checkpoint = "checkpoint" checkpoint = "checkpoint"
lora = "lora" lora = "lora"
embedding = "embedding" embedding = "embedding"
clip = "clip"
clip_vision = "clip_vision"
configs = "configs"
controlnet = "controlnet"
upscale_models = "upscale_models"
vae = "vae"
ipadapter = "ipadapter"
other = "other"
class UploadBody(BaseModel): class UploadBody(BaseModel):
download_url: str download_url: str
@@ -249,8 +259,46 @@ UPLOAD_TYPE_DIR_MAP = {
UploadType.checkpoint: "checkpoints", UploadType.checkpoint: "checkpoints",
UploadType.lora: "loras", UploadType.lora: "loras",
UploadType.embedding: "embeddings", UploadType.embedding: "embeddings",
UploadType.clip: "clip",
UploadType.clip_vision: "clip_vision",
UploadType.configs: "configs",
UploadType.controlnet: "controlnet",
UploadType.upscale_models: "upscale_models",
UploadType.vae: "vae",
UploadType.ipadapter: "ipadapter",
UploadType.other: "",
} }
class DeleteBody(BaseModel):
volume_name: str
path: str
file_name: str
@app.post("/delete-volume-model")
async def delete_model(body: DeleteBody):
global last_activity_time
last_activity_time = time.time()
logger.info(f"Extended inactivity time to {global_timeout}")
full_path = f"{body.path.rstrip('/')}/{body.file_name}"
rm_process = await asyncio.subprocess.create_subprocess_exec("modal", "volume", "rm", body.volume_name, full_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,)
await rm_process.wait()
logger.info(f"Successfully deleted: {full_path} from volume: {body.volume_name}")
stdout, stderr = await rm_process.communicate()
if stdout:
logger.info(f"cp_process stdout: {stdout.decode()}")
if stderr:
logger.info(f"cp_process stderr: {stderr.decode()}")
if rm_process.returncode == 0:
return JSONResponse(status_code=200, content={"status":f"Successfully deleted {full_path} from volume {body.volume_name}"})
else:
return JSONResponse(status_code=500, content={"status": "error", "error": stderr.decode()})
@app.post("/upload-volume") @app.post("/upload-volume")
async def upload_model(body: UploadBody): async def upload_model(body: UploadBody):
@@ -265,12 +313,16 @@ async def upload_model(body: UploadBody):
async def upload_logic(body: UploadBody): async def upload_logic(body: UploadBody):
folder_path = f"/app/builds/{body.volume_id}" folder_path = f"/app/builds/{body.volume_id}-{uuid4()}"
cp_process = await asyncio.subprocess.create_subprocess_exec("cp", "-r", "/app/src/volume-builder", folder_path) cp_process = await asyncio.subprocess.create_subprocess_exec("cp", "-r", "/app/src/volume_builder", folder_path)
await cp_process.wait() await cp_process.wait()
upload_path = UPLOAD_TYPE_DIR_MAP[body.upload_type] upload_path = UPLOAD_TYPE_DIR_MAP[body.upload_type]
if upload_path == "":
# TODO: deal with custom paths
pass
config = { config = {
"volume_names": { "volume_names": {
body.volume_name: {"download_url": body.download_url, "folder_path": upload_path} body.volume_name: {"download_url": body.download_url, "folder_path": upload_path}
@@ -284,16 +336,22 @@ async def upload_logic(body: UploadBody):
"volume_id": body.volume_id, "volume_id": body.volume_id,
"folder_path": upload_path, "folder_path": upload_path,
}, },
"civitai_api_key": os.environ.get('CIVITAI_API_KEY') "civitai_api_key": os.environ.get('CIVITAI_API_KEY'),
"app_name": f"vol_name_{uuid4()}"
} }
with open(f"{folder_path}/config.py", "w") as f: with open(f"{folder_path}/config.py", "w") as f:
f.write("config = " + json.dumps(config)) f.write("config = " + json.dumps(config))
await asyncio.subprocess.create_subprocess_shell( process = await asyncio.subprocess.create_subprocess_shell(
f"modal run app.py", f"python runner.py",
cwd=folder_path, cwd=folder_path,
env={**os.environ, "COLUMNS": "10000"} env={**os.environ, "COLUMNS": "10000"}
) )
await process.wait()
# import modal
# modal.deploy_stub(stub)
# stub["download_model"].web_url
@app.post("/create") @app.post("/create")
async def create_machine(item: Item): async def create_machine(item: Item):
@@ -391,7 +449,9 @@ async def build_logic(item: Item):
"gpu": item.gpu, "gpu": item.gpu,
"public_model_volume": public_model_volume_name, "public_model_volume": public_model_volume_name,
"private_model_volume": item.model_volume_name, "private_model_volume": item.model_volume_name,
"pip": list(pip_modules) "pip": list(pip_modules),
"run_timeout": item.run_timeout,
"idle_timeout": item.idle_timeout,
} }
with open(f"{folder_path}/config.py", "w") as f: with open(f"{folder_path}/config.py", "w") as f:
f.write("config = " + json.dumps(config)) f.write("config = " + json.dumps(config))
+197 -64
View File
@@ -9,6 +9,8 @@ from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from volume_setup import volumes from volume_setup import volumes
from datetime import datetime from datetime import datetime
import aiohttp
from aiohttp import TCPConnector
# deploy_test = False # deploy_test = False
import os import os
@@ -36,7 +38,9 @@ if not deploy_test:
# dockerfile_image = Image.from_dockerfile(f"{current_directory}/Dockerfile", context_mount=Mount.from_local_dir(f"{current_directory}/data", remote_path="/data")) # dockerfile_image = Image.from_dockerfile(f"{current_directory}/Dockerfile", context_mount=Mount.from_local_dir(f"{current_directory}/data", remote_path="/data"))
dockerfile_image = ( dockerfile_image = (
modal.Image.debian_slim() modal.Image.debian_slim(
python_version="3.11.1"
)
.apt_install("git", "wget") .apt_install("git", "wget")
.pip_install( .pip_install(
"git+https://github.com/modal-labs/asgiproxy.git", "httpx", "tqdm" "git+https://github.com/modal-labs/asgiproxy.git", "httpx", "tqdm"
@@ -49,12 +53,12 @@ if not deploy_test:
# Install comfyui manager # Install comfyui manager
"cd /comfyui/custom_nodes && git clone https://github.com/ltdrdata/ComfyUI-Manager.git", "cd /comfyui/custom_nodes && git clone https://github.com/ltdrdata/ComfyUI-Manager.git",
"cd /comfyui/custom_nodes/ComfyUI-Manager && git reset --hard 9c86f62b912f4625fe2b929c7fc61deb9d16f6d3", "cd /comfyui/custom_nodes/ComfyUI-Manager && git reset --hard 8dd801435bb75aa1d24b7e382bac070a4c18bc51",
"cd /comfyui/custom_nodes/ComfyUI-Manager && pip install -r requirements.txt", "cd /comfyui/custom_nodes/ComfyUI-Manager && pip install -r requirements.txt",
"cd /comfyui/custom_nodes/ComfyUI-Manager && mkdir startup-scripts", "cd /comfyui/custom_nodes/ComfyUI-Manager && mkdir startup-scripts",
) )
.run_commands(f"cat /comfyui/server.py") # .run_commands(f"cat /comfyui/server.py")
.run_commands(f"ls /comfyui/app") # .run_commands(f"ls /comfyui/app")
# .run_commands( # .run_commands(
# # Install comfy deploy # # Install comfy deploy
# "cd /comfyui/custom_nodes && git clone https://github.com/BennyKok/comfyui-deploy.git", # "cd /comfyui/custom_nodes && git clone https://github.com/BennyKok/comfyui-deploy.git",
@@ -82,7 +86,7 @@ if not deploy_test:
# Time to wait between API check attempts in milliseconds # Time to wait between API check attempts in milliseconds
COMFY_API_AVAILABLE_INTERVAL_MS = 50 COMFY_API_AVAILABLE_INTERVAL_MS = 50
# Maximum number of API check attempts # Maximum number of API check attempts
COMFY_API_AVAILABLE_MAX_RETRIES = 500 COMFY_API_AVAILABLE_MAX_RETRIES = 1000
# Time to wait between poll attempts in milliseconds # Time to wait between poll attempts in milliseconds
COMFY_POLLING_INTERVAL_MS = 250 COMFY_POLLING_INTERVAL_MS = 250
# Maximum number of poll attempts # Maximum number of poll attempts
@@ -91,48 +95,34 @@ COMFY_POLLING_MAX_RETRIES = 1000
COMFY_HOST = "127.0.0.1:8188" COMFY_HOST = "127.0.0.1:8188"
def check_server(url, retries=50, delay=500): async def check_server(url, retries=50, delay=500):
import requests import aiohttp
import time # for i in range(retries):
""" while True:
Check if a server is reachable via HTTP GET request
Args:
- url (str): The URL to check
- retries (int, optional): The number of times to attempt connecting to the server. Default is 50
- delay (int, optional): The time in milliseconds to wait between retries. Default is 500
Returns:
bool: True if the server is reachable within the given number of retries, otherwise False
"""
for i in range(retries):
try: try:
response = requests.get(url) async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
# If the response status code is 200, the server is up and running # If the response status code is 200, the server is up and running
if response.status_code == 200: if response.status == 200:
print(f"runpod-worker-comfy - API is reachable") print(f"comfy-modal - API is reachable")
return True return True
except requests.RequestException as e: except Exception as e:
# If an exception occurs, the server may not be ready # If an exception occurs, the server may not be ready
pass pass
# print(f"runpod-worker-comfy - trying")
# Wait for the specified delay before retrying # Wait for the specified delay before retrying
time.sleep(delay / 1000) await asyncio.sleep(delay / 1000)
print( print(
f"runpod-worker-comfy - Failed to connect to server at {url} after {retries} attempts." f"comfy-modal - Failed to connect to server at {url} after {retries} attempts."
) )
return False return False
def check_status(prompt_id): async def check_status(prompt_id):
req = urllib.request.Request( async with aiohttp.ClientSession() as session:
f"http://{COMFY_HOST}/comfyui-deploy/check-status?prompt_id={prompt_id}") async with session.get(f"http://{COMFY_HOST}/comfyui-deploy/check-status?prompt_id={prompt_id}") as response:
return json.loads(urllib.request.urlopen(req).read()) return await response.json()
class Input(BaseModel): class Input(BaseModel):
@@ -142,12 +132,12 @@ class Input(BaseModel):
file_upload_endpoint: str file_upload_endpoint: str
def queue_workflow_comfy_deploy(data: Input): async def queue_workflow_comfy_deploy(data: Input):
data_str = data.json() data_str = data.json()
data_bytes = data_str.encode('utf-8') data_bytes = data_str.encode('utf-8')
req = urllib.request.Request( async with aiohttp.ClientSession() as session:
f"http://{COMFY_HOST}/comfyui-deploy/run", data=data_bytes) async with session.post(f"http://{COMFY_HOST}/comfyui-deploy/run", data=data_bytes) as response:
return json.loads(urllib.request.urlopen(req).read()) return await response.json()
class RequestInput(BaseModel): class RequestInput(BaseModel):
@@ -158,45 +148,141 @@ image = Image.debian_slim()
target_image = image if deploy_test else dockerfile_image target_image = image if deploy_test else dockerfile_image
@stub.cls(image=target_image, gpu=config["gpu"] ,volumes=volumes, timeout=60 * 10, container_idle_timeout=60) run_timeout = config["run_timeout"]
idle_timeout = config["idle_timeout"]
import asyncio
@stub.cls(
image=target_image,
gpu=config["gpu"] ,
volumes=volumes,
timeout=(config["run_timeout"] + 20),
container_idle_timeout=config["idle_timeout"],
allow_concurrent_inputs=1,
)
class ComfyDeployRunner: class ComfyDeployRunner:
machine_logs = []
async def read_stream(self, stream, isStderr):
import time
while True:
try:
line = await stream.readline()
if line:
l = line.decode('utf-8').strip()
if l == "":
continue
if not isStderr:
print(l, flush=True)
self.machine_logs.append({
"logs": l,
"timestamp": time.time()
})
else:
# is error
# logger.error(l)
print(l, flush=True)
self.machine_logs.append({
"logs": l,
"timestamp": time.time()
})
else:
break
except asyncio.CancelledError:
# Handle the cancellation here if needed
break # Break out of the loop on cancellation
@enter() @enter()
def setup(self): async def setup(self):
import subprocess import subprocess
import time import time
# Make sure that the ComfyUI API is available # Make sure that the ComfyUI API is available
print(f"comfy-modal - check server") print(f"comfy-modal - check server")
command = ["python", "main.py", self.server_process = await asyncio.subprocess.create_subprocess_shell(
"--disable-auto-launch", "--disable-metadata"] f"python main.py --disable-auto-launch --disable-metadata",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd="/comfyui",
# env={**os.environ, "COLUMNS": "10000"}
)
self.server_process = subprocess.Popen(command, cwd="/comfyui") @exit()
async def cleanup(self, exc_type, exc_value, traceback):
print(f"comfy-modal - cleanup", exc_type, exc_value, traceback)
# Get the current event loop
loop = asyncio.get_event_loop()
check_server( # Check if the event loop is closed
if loop.is_closed():
print("The event loop is closed.")
else:
try:
self.server_process.terminate()
await self.server_process.wait()
except Exception as e:
print("Issues when cleaning up", e)
print("The event loop is open.")
@method()
async def run(self, input: Input):
import signal
import time
import aiohttp
stdout_task = asyncio.create_task(
self.read_stream(self.server_process.stdout, False))
stderr_task = asyncio.create_task(
self.read_stream(self.server_process.stderr, True))
try:
class TimeoutError(Exception):
pass
def timeout_handler(signum, frame):
data = json.dumps({
"run_id": input.prompt_id,
"status": "timeout",
"time": datetime.now().isoformat()
}).encode('utf-8')
req = urllib.request.Request(input.status_endpoint, data=data, method='POST')
urllib.request.urlopen(req)
raise TimeoutError("Operation timed out")
signal.signal(signal.SIGALRM, timeout_handler)
try:
signal.alarm(run_timeout)
ok = await check_server(
f"http://{COMFY_HOST}", f"http://{COMFY_HOST}",
COMFY_API_AVAILABLE_MAX_RETRIES, COMFY_API_AVAILABLE_MAX_RETRIES,
COMFY_API_AVAILABLE_INTERVAL_MS, COMFY_API_AVAILABLE_INTERVAL_MS,
) )
@exit() if not ok:
def cleanup(self, exc_type, exc_value, traceback): raise Exception("ComfyUI API is not available")
self.server_process.terminate() # Set an alarm for some seconds in the future
@method()
def run(self, input: Input):
data = json.dumps({ data = json.dumps({
"run_id": input.prompt_id, "run_id": input.prompt_id,
"status": "started", "status": "started",
"time": datetime.now().isoformat() "time": datetime.now().isoformat()
}).encode('utf-8') }).encode('utf-8')
req = urllib.request.Request(input.status_endpoint, data=data, method='POST') async with aiohttp.ClientSession() as session:
urllib.request.urlopen(req) async with session.post(input.status_endpoint, data=data) as response:
pass
job_input = input job_input = input
try: try:
queued_workflow = queue_workflow_comfy_deploy(job_input) # queue_workflow(workflow) queued_workflow = await queue_workflow_comfy_deploy(job_input) # queue_workflow(workflow)
prompt_id = queued_workflow["prompt_id"] prompt_id = queued_workflow["prompt_id"]
print(f"comfy-modal - queued workflow with ID {prompt_id}") print(f"comfy-modal - queued workflow with ID {prompt_id}")
except Exception as e: except Exception as e:
@@ -210,22 +296,16 @@ class ComfyDeployRunner:
status = "" status = ""
try: try:
print("getting request") print("getting request")
while retries < COMFY_POLLING_MAX_RETRIES: # while retries < COMFY_POLLING_MAX_RETRIES:
status_result = check_status(prompt_id=prompt_id) while True:
# history = get_history(prompt_id) status_result = await check_status(prompt_id=prompt_id)
# Exit the loop if we have found the history
# if prompt_id in history and history[prompt_id].get("outputs"):
# break
# Exit the loop if we have found the status both success or failed
if 'status' in status_result and (status_result['status'] == 'success' or status_result['status'] == 'failed'): if 'status' in status_result and (status_result['status'] == 'success' or status_result['status'] == 'failed'):
status = status_result['status'] status = status_result['status']
print(status) print(status)
break break
else: else:
# Wait before trying again # Wait before trying again
time.sleep(COMFY_POLLING_INTERVAL_MS / 1000) await asyncio.sleep(COMFY_POLLING_INTERVAL_MS / 1000)
retries += 1 retries += 1
else: else:
return {"error": "Max retries reached while waiting for image generation"} return {"error": "Max retries reached while waiting for image generation"}
@@ -236,9 +316,60 @@ class ComfyDeployRunner:
result = {"status": status} result = {"status": status}
except TimeoutError:
print("Operation timed out")
return {"status": "failed"}
except Exception as e:
print(f"Unexpected error occurred: {str(e)}")
data = json.dumps({
"run_id": input.prompt_id,
"status": "failed",
"time": datetime.now().isoformat()
}).encode('utf-8')
async with aiohttp.ClientSession() as session:
async with session.post(input.status_endpoint, data=data) as response:
print("response", response)
self.machine_logs.append({
"logs": str(e),
"timestamp": time.time()
})
finally:
signal.alarm(0)
print("uploading log_data")
data = json.dumps({
"run_id": input.prompt_id,
"time": datetime.now().isoformat(),
"log_data": self.machine_logs
}).encode('utf-8')
print("my logs", len(self.machine_logs))
# Clear logs
timeout = aiohttp.ClientTimeout(total=60) # 60 seconds total timeout
# Use HTTP/1.1 explicitly and increase the connection pool size
connector = TCPConnector(limit=100, force_close=True, enable_cleanup_closed=True)
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
try:
async with session.post(input.status_endpoint, data=data) as response:
print("response", response)
# Process your response here
except asyncio.TimeoutError:
print("Request timed out")
except Exception as e:
print(f"An error occurred: {e}")
print("uploaded log_data")
# print(data)
self.machine_logs = []
finally:
stdout_task.cancel()
stderr_task.cancel()
await stdout_task
await stderr_task
return result return result
@web_app.post("/run") @web_app.post("/run")
async def post_run(request_input: RequestInput): async def post_run(request_input: RequestInput):
if not deploy_test: if not deploy_test:
@@ -252,10 +383,12 @@ async def post_run(request_input: RequestInput):
urllib.request.urlopen(req) urllib.request.urlopen(req)
model = ComfyDeployRunner() model = ComfyDeployRunner()
call = model.run.spawn(request_input.input) call = await model.run.spawn.aio(request_input.input)
print("call", call)
# call = run.spawn() # call = run.spawn()
return {"call_id": call.object_id} return {"call_id": None}
return {"call_id": None} return {"call_id": None}
+3 -1
View File
@@ -4,5 +4,7 @@ config = {
"gpu": "T4", "gpu": "T4",
"public_model_volume": "model-store", "public_model_volume": "model-store",
"private_model_volume": "private-model-store", "private_model_volume": "private-model-store",
"pip": [] "pip": [],
"run_timeout": 60 * 5,
"idle_timeout": 60
} }
@@ -9,6 +9,8 @@ public:
loras: loras loras: loras
upscale_models: upscale_models upscale_models: upscale_models
vae: vae vae: vae
ipadapter: ipadapter
private: private:
base_path: /private_models/ base_path: /private_models/
@@ -21,3 +23,4 @@ private:
loras: loras loras: loras
upscale_models: upscale_models upscale_models: upscale_models
vae: vae vae: vae
ipadapter: ipadapter
@@ -2,7 +2,7 @@
"comfyui": "d0165d819afe76bd4e6bdd710eb5f3e571b6a804", "comfyui": "d0165d819afe76bd4e6bdd710eb5f3e571b6a804",
"git_custom_nodes": { "git_custom_nodes": {
"https://github.com/BennyKok/comfyui-deploy.git": { "https://github.com/BennyKok/comfyui-deploy.git": {
"hash": "a838cb7ad425e5652c3931fbafdc886b53c48a22", "hash": "df46e3a0e5ad93fa71f5d216997e376af33b2a6d",
"disabled": false "disabled": false
} }
}, },
@@ -1,10 +1,18 @@
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
import modal import modal
from config import config from config import config
import os import os
import subprocess import subprocess
from pprint import pprint from pprint import pprint
stub = modal.Stub() stub = modal.Stub(config["app_name"])
vol_name_to_links = config["volume_names"]
vol_name_to_path = config["volume_paths"]
callback_url = config["callback_url"]
callback_body = config["callback_body"]
civitai_key = config["civitai_api_key"]
web_app = FastAPI()
# Volume names may only contain alphanumeric characters, dashes, periods, and underscores, and must be less than 64 characters in length. # Volume names may only contain alphanumeric characters, dashes, periods, and underscores, and must be less than 64 characters in length.
def is_valid_name(name: str) -> bool: def is_valid_name(name: str) -> bool:
@@ -21,12 +29,6 @@ def create_volumes(volume_names, paths):
return path_to_vol return path_to_vol
vol_name_to_links = config["volume_names"]
vol_name_to_path = config["volume_paths"]
callback_url = config["callback_url"]
callback_body = config["callback_body"]
civitai_key = config["civitai_api_key"]
volumes = create_volumes(vol_name_to_links, vol_name_to_path) volumes = create_volumes(vol_name_to_links, vol_name_to_path)
image = ( image = (
modal.Image.debian_slim().apt_install("wget").pip_install("requests") modal.Image.debian_slim().apt_install("wget").pip_install("requests")
@@ -45,7 +47,7 @@ def download_model(volume_name, download_config):
modified_download_url = download_url + ("&" if "?" in download_url else "?") + "token=" + civitai_key # civitai requires auth modified_download_url = download_url + ("&" if "?" in download_url else "?") + "token=" + civitai_key # civitai requires auth
print('downloading', modified_download_url) print('downloading', modified_download_url)
subprocess.run(["wget", modified_download_url , "--content-disposition", "-P", model_store_path]) subprocess.run(["wget", modified_download_url , "--content-disposition", "-P", model_store_path, "-nv"])
subprocess.run(["ls", "-la", volume_base_path]) subprocess.run(["ls", "-la", volume_base_path])
subprocess.run(["ls", "-la", model_store_path]) subprocess.run(["ls", "-la", model_store_path])
volumes[volume_base_path].commit() volumes[volume_base_path].commit()
@@ -56,11 +58,12 @@ def download_model(volume_name, download_config):
print(f"finished! sending to {callback_url}") print(f"finished! sending to {callback_url}")
pprint({**status, **callback_body}) pprint({**status, **callback_body})
@stub.local_entrypoint() @stub.function(image=image)
# @modal.asgi_app()
def simple_download(): def simple_download():
import requests import requests
try: try:
list(download_model.starmap([(vol_name, link) for vol_name,link in vol_name_to_links.items()])) list(download_model.starmap([(vol_name, download_conf) for vol_name,download_conf in vol_name_to_links.items()]))
except modal.exception.FunctionTimeoutError as e: except modal.exception.FunctionTimeoutError as e:
status = {"status": "failed", "error_logs": f"{str(e)}", "timeout": timeout} status = {"status": "failed", "error_logs": f"{str(e)}", "timeout": timeout}
requests.post(callback_url, json={**status, **callback_body}) requests.post(callback_url, json={**status, **callback_body})
@@ -71,4 +74,3 @@ def simple_download():
requests.post(callback_url, json={**status, **callback_body}) requests.post(callback_url, json={**status, **callback_body})
print(f"finished! sending to {callback_url}") print(f"finished! sending to {callback_url}")
pprint({**status, **callback_body}) pprint({**status, **callback_body})
@@ -15,4 +15,5 @@ config = {
"folder_path": "checkpoints", "folder_path": "checkpoints",
}, },
"civitai_api_key": "", "civitai_api_key": "",
"app_name": "",
} }
@@ -0,0 +1,12 @@
import modal
import requests
from app import stub
from config import config
modal.runner.deploy_stub(stub)
print("deployed stub")
# web_url = stub["simple_download"].web_url
f = modal.Function.lookup(config['app_name'], "simple_download")
f.spawn()
# print(f"web_url: {web_url}")
# requests.post(web_url)
+62 -12
View File
@@ -26,6 +26,8 @@ import threading
api = None api = None
api_task = None api_task = None
prompt_metadata = {} prompt_metadata = {}
cd_enable_log = os.environ.get('CD_ENABLE_LOG', 'false').lower() == 'true'
cd_enable_run_log = os.environ.get('CD_ENABLE_RUN_LOG', 'false').lower() == 'true'
def post_prompt(json_data): def post_prompt(json_data):
prompt_server = server.PromptServer.instance prompt_server = server.PromptServer.instance
@@ -157,6 +159,8 @@ async def websocket_handler(request):
try: try:
# Send initial state to the new client # Send initial state to the new client
await send("status", { 'sid': sid }, sid) await send("status", { 'sid': sid }, sid)
if cd_enable_log:
await send_first_time_log(sid) await send_first_time_log(sid)
async for msg in ws: async for msg in ws:
@@ -236,7 +240,12 @@ class Status(Enum):
FAILED = "failed" FAILED = "failed"
UPLOADING = "uploading" UPLOADING = "uploading"
# Global variable to keep track of the last read line number
last_read_line_number = 0
def update_run(prompt_id, status: Status): def update_run(prompt_id, status: Status):
global last_read_line_number
if prompt_id not in prompt_metadata: if prompt_id not in prompt_metadata:
return return
@@ -251,15 +260,49 @@ def update_run(prompt_id, status: Status):
"run_id": prompt_id, "run_id": prompt_id,
"status": status.value, "status": status.value,
} }
prompt_metadata[prompt_id]['status'] = status
print(f"Status: {status.value}") print(f"Status: {status.value}")
try: try:
requests.post(status_endpoint, json=body) requests.post(status_endpoint, json=body)
if cd_enable_run_log and (status == Status.SUCCESS or status == Status.FAILED):
try:
with open(comfyui_file_path, 'r') as log_file:
# log_data = log_file.read()
# Move to the last read line
all_log_data = log_file.read() # Read all log data
print("All log data before skipping:", all_log_data) # Log all data before skipping
log_file.seek(0) # Reset file pointer to the beginning
for _ in range(last_read_line_number):
next(log_file)
log_data = log_file.read()
# Update the last read line number
last_read_line_number += log_data.count('\n')
print("last_read_line_number", last_read_line_number)
print("log_data", log_data)
print("log_data.count(n)", log_data.count('\n'))
body = {
"run_id": prompt_id,
"log_data": [
{
"logs": log_data,
# "timestamp": time.time(),
}
]
}
requests.post(status_endpoint, json=body)
except Exception as log_error:
print(f"Error reading log file: {log_error}")
except Exception as e: except Exception as e:
error_type = type(e).__name__ error_type = type(e).__name__
stack_trace = traceback.format_exc().strip() stack_trace = traceback.format_exc().strip()
print(f"Error occurred while updating run: {e} {stack_trace}") print(f"Error occurred while updating run: {e} {stack_trace}")
finally:
prompt_metadata[prompt_id]['status'] = status
async def upload_file(prompt_id, filename, subfolder=None, content_type="image/png", type="output"): async def upload_file(prompt_id, filename, subfolder=None, content_type="image/png", type="output"):
@@ -387,20 +430,25 @@ async def update_file_status(prompt_id, data, uploading, have_error=False, node_
"prompt_id": prompt_id, "prompt_id": prompt_id,
}) })
async def handle_upload(prompt_id, data, key, content_type_key, default_content_type):
items = data.get(key, [])
for item in items:
await upload_file(
prompt_id,
item.get("filename"),
subfolder=item.get("subfolder"),
type=item.get("type"),
content_type=item.get(content_type_key, default_content_type)
)
# Upload files in the background # Upload files in the background
async def upload_in_background(prompt_id, data, node_id=None, have_upload=True): async def upload_in_background(prompt_id, data, node_id=None, have_upload=True):
try: try:
images = data.get('images', []) await handle_upload(prompt_id, data, 'images', "content_type", "image/png")
for image in images: await handle_upload(prompt_id, data, 'files', "content_type", "image/png")
await upload_file(prompt_id, image.get("filename"), subfolder=image.get("subfolder"), type=image.get("type"), content_type=image.get("content_type", "image/png")) # This will also be mp4
await handle_upload(prompt_id, data, 'gifs', "format", "image/gif")
files = data.get('files', [])
for file in files:
await upload_file(prompt_id, file.get("filename"), subfolder=file.get("subfolder"), type=file.get("type"), content_type=file.get("content_type", "image/png"))
gifs = data.get('gifs', [])
for gif in gifs:
await upload_file(prompt_id, gif.get("filename"), subfolder=gif.get("subfolder"), type=gif.get("type"), content_type=gif.get("format", "image/gif"))
if have_upload: if have_upload:
await update_file_status(prompt_id, data, False, node_id=node_id) await update_file_status(prompt_id, data, False, node_id=node_id)
@@ -441,6 +489,7 @@ prompt_server.send_json = send_json_override.__get__(prompt_server, server.Promp
root_path = os.path.dirname(os.path.abspath(__file__)) root_path = os.path.dirname(os.path.abspath(__file__))
two_dirs_up = os.path.dirname(os.path.dirname(root_path)) two_dirs_up = os.path.dirname(os.path.dirname(root_path))
log_file_path = os.path.join(two_dirs_up, 'comfy-deploy.log') log_file_path = os.path.join(two_dirs_up, 'comfy-deploy.log')
comfyui_file_path = os.path.join(two_dirs_up, 'comfyui.log')
last_read_line = 0 last_read_line = 0
@@ -480,4 +529,5 @@ def run_in_new_thread(coroutine):
t.start() t.start()
asyncio.run_coroutine_threadsafe(coroutine, new_loop) asyncio.run_coroutine_threadsafe(coroutine, new_loop)
if cd_enable_log:
run_in_new_thread(watch_file_changes(log_file_path, send_logs_to_websocket)) run_in_new_thread(watch_file_changes(log_file_path, send_logs_to_websocket))
+11
View File
@@ -7,6 +7,13 @@ import threading
import logging import logging
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
# Running with export CD_ENABLE_LOG=true; python main.py
# Check for 'cd-enable-log' flag in input arguments
# cd_enable_log = '--cd-enable-log' in sys.argv
cd_enable_log = os.environ.get('CD_ENABLE_LOG', 'false').lower() == 'true'
def setup():
handler = RotatingFileHandler('comfy-deploy.log', maxBytes=500000, backupCount=5) handler = RotatingFileHandler('comfy-deploy.log', maxBytes=500000, backupCount=5)
original_stdout = sys.stdout original_stdout = sys.stdout
@@ -47,6 +54,10 @@ class StreamToLogger():
sys.stdout = StreamToLogger(logging.INFO) sys.stdout = StreamToLogger(logging.INFO)
sys.stderr = StreamToLogger(logging.ERROR) sys.stderr = StreamToLogger(logging.ERROR)
if cd_enable_log:
print("** Comfy Deploy logging enabled")
setup()
try: try:
# Get the absolute path of the script's directory # Get the absolute path of the script's directory
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
+18 -4
View File
@@ -50,11 +50,22 @@ const ext = {
}) })
.then(async (res) => { .then(async (res) => {
const data = await res.json(); const data = await res.json();
const { workflow, error } = data; const { workflow, workflow_id, error } = data;
if (error) { if (error) {
infoDialog.showMessage("Unable to load this workflow", error); infoDialog.showMessage("Unable to load this workflow", error);
return; return;
} }
// Adding a delay to wait for the intial graph to load
await new Promise((resolve) => setTimeout(resolve, 2000));
workflow?.nodes.forEach((x) => {
if (x?.type === "ComfyDeploy") {
x.widgets_values[1] = workflow_id;
// x.widgets_values[2] = workflow_version.version;
}
});
/** @type {LGraph} */ /** @type {LGraph} */
app.loadGraphData(workflow); app.loadGraphData(workflow);
}) })
@@ -682,15 +693,18 @@ export class ConfigDialog extends ComfyDialog {
</label> </label>
<label style="color: white; width: 100%;"> <label style="color: white; width: 100%;">
Endpoint: Endpoint:
<input id="endpoint" style="margin-top: 8px; width: 100%; height:40px; box-sizing: border-box; padding: 0px 6px;" type="text" value="${data.endpoint <input id="endpoint" style="margin-top: 8px; width: 100%; height:40px; box-sizing: border-box; padding: 0px 6px;" type="text" value="${
data.endpoint
}"> }">
</label> </label>
<label style="color: white;"> <label style="color: white;">
API Key: ${data.displayName ?? ""} API Key: ${data.displayName ?? ""}
<input id="apiKey" style="margin-top: 8px; width: 100%; height:40px; box-sizing: border-box; padding: 0px 6px;" type="password" value="${data.apiKey <input id="apiKey" style="margin-top: 8px; width: 100%; height:40px; box-sizing: border-box; padding: 0px 6px;" type="password" value="${
data.apiKey
}"> }">
<button id="loginButton" style="margin-top: 8px; width: 100%; height:40px; box-sizing: border-box; padding: 0px 6px;"> <button id="loginButton" style="margin-top: 8px; width: 100%; height:40px; box-sizing: border-box; padding: 0px 6px;">
${data.apiKey ? "Re-login with ComfyDeploy" : "Login with ComfyDeploy" ${
data.apiKey ? "Re-login with ComfyDeploy" : "Login with ComfyDeploy"
} }
</button> </button>
</label> </label>
+2
View File
@@ -0,0 +1,2 @@
ALTER TYPE "workflow_run_status" ADD VALUE 'timeout';--> statement-breakpoint
ALTER TABLE "comfyui_deploy"."workflow_runs" ADD COLUMN "run_log" text;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "comfyui_deploy"."workflow_runs" DROP COLUMN "run_log";
ALTER TABLE "comfyui_deploy"."workflow_runs" ADD COLUMN "run_log" jsonb;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -337,6 +337,20 @@
"when": 1706384528895, "when": 1706384528895,
"tag": "0047_gifted_starbolt", "tag": "0047_gifted_starbolt",
"breakpoints": true "breakpoints": true
},
{
"idx": 48,
"version": "5",
"when": 1706600255919,
"tag": "0048_dear_korath",
"breakpoints": true
},
{
"idx": 49,
"version": "5",
"when": 1706631744127,
"tag": "0049_sweet_hex",
"breakpoints": true
} }
] ]
} }
+30 -9
View File
@@ -2,10 +2,8 @@ import { parseDataSafe } from "../../../../lib/parseDataSafe";
import { db } from "@/db/db"; import { db } from "@/db/db";
import { import {
WorkflowRunStatusSchema, WorkflowRunStatusSchema,
userUsageTable,
workflowRunOutputs, workflowRunOutputs,
workflowRunsTable, workflowRunsTable,
workflowTable,
} from "@/db/schema"; } from "@/db/schema";
import { getCurrentPlan } from "@/server/getCurrentPlan"; import { getCurrentPlan } from "@/server/getCurrentPlan";
import { stripe } from "@/server/stripe"; import { stripe } from "@/server/stripe";
@@ -18,6 +16,7 @@ const Request = z.object({
status: WorkflowRunStatusSchema.optional(), status: WorkflowRunStatusSchema.optional(),
time: z.coerce.date().optional(), time: z.coerce.date().optional(),
output_data: z.any().optional(), output_data: z.any().optional(),
log_data: z.any().optional(),
}); });
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -26,7 +25,26 @@ export async function POST(request: Request) {
if (!data || error) return error; if (!data || error) return error;
const { run_id, status, time, output_data } = data; const { run_id, status, time, output_data, log_data } = data;
if (log_data) {
// It successfully started, update the started_at time
await db
.update(workflowRunsTable)
.set({
run_log: log_data,
})
.where(eq(workflowRunsTable.id, run_id));
return NextResponse.json(
{
message: "success",
},
{
status: 200,
},
);
}
if (status == "started" && time != undefined) { if (status == "started" && time != undefined) {
// It successfully started, update the started_at time // It successfully started, update the started_at time
@@ -48,6 +66,9 @@ export async function POST(request: Request) {
.where(eq(workflowRunsTable.id, run_id)); .where(eq(workflowRunsTable.id, run_id));
} }
const ended =
status === "success" || status === "failed" || status === "timeout";
if (output_data) { if (output_data) {
const workflow_run_output = await db.insert(workflowRunOutputs).values({ const workflow_run_output = await db.insert(workflowRunOutputs).values({
run_id: run_id, run_id: run_id,
@@ -58,8 +79,7 @@ export async function POST(request: Request) {
.update(workflowRunsTable) .update(workflowRunsTable)
.set({ .set({
status: status, status: status,
ended_at: ended_at: ended ? new Date() : null,
status === "success" || status === "failed" ? new Date() : null,
}) })
.where(eq(workflowRunsTable.id, run_id)) .where(eq(workflowRunsTable.id, run_id))
.returning(); .returning();
@@ -67,10 +87,7 @@ export async function POST(request: Request) {
// Need to filter out only comfy deploy serverless // Need to filter out only comfy deploy serverless
// Also multiply with the gpu selection // Also multiply with the gpu selection
if (workflow_run.machine_type == "comfy-deploy-serverless") { if (workflow_run.machine_type == "comfy-deploy-serverless") {
if ( if (ended && workflow_run.user_id) {
(status === "success" || status === "failed") &&
workflow_run.user_id
) {
const sub = await getCurrentPlan({ const sub = await getCurrentPlan({
user_id: workflow_run.user_id, user_id: workflow_run.user_id,
org_id: workflow_run.org_id, org_id: workflow_run.org_id,
@@ -91,12 +108,16 @@ export async function POST(request: Request) {
durationInSec *= 4; durationInSec *= 4;
break; break;
} }
try {
await stripe.subscriptionItems.createUsageRecord( await stripe.subscriptionItems.createUsageRecord(
sub.subscription_item_api_id, sub.subscription_item_api_id,
{ {
quantity: durationInSec, quantity: durationInSec,
}, },
); );
} catch (error) {
console.log(error);
}
} }
} }
} }
@@ -35,7 +35,10 @@ export default async function Page({
/> />
)} )}
{machine.status !== "building" && machine.build_log && ( {machine.status !== "building" && machine.build_log && (
<LogsViewer logs={JSON.parse(machine.build_log)} /> <LogsViewer
logs={JSON.parse(machine.build_log)}
className="h-full max-h-[600px]"
/>
)} )}
</CardContent> </CardContent>
</Card> </Card>
+9 -1
View File
@@ -16,11 +16,19 @@ const freeTierSeconds = 30000;
export default async function Home() { export default async function Home() {
const sub = await getCurrentPlanWithAuth(); const sub = await getCurrentPlanWithAuth();
const data = sub?.subscription_item_api_id let data: Awaited<
ReturnType<typeof stripe.subscriptionItems.listUsageRecordSummaries>
> | null = null;
try {
data = sub?.subscription_item_api_id
? await stripe.subscriptionItems.listUsageRecordSummaries( ? await stripe.subscriptionItems.listUsageRecordSummaries(
sub?.subscription_item_api_id, sub?.subscription_item_api_id,
) )
: null; : null;
} catch (e) {
console.error(e);
}
return ( return (
<div className="mt-4 flex items-center justify-center"> <div className="mt-4 flex items-center justify-center">
@@ -1,5 +1,5 @@
import { LoadingWrapper } from "@/components/LoadingWrapper"; import { LoadingWrapper } from "@/components/LoadingWrapper";
import { DeploymentsTable } from "@/components/RunsTable"; import { DeploymentsTable } from "@/components/DeploymentsTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default async function Page({ export default async function Page({
@@ -13,12 +13,12 @@ export default async function Page({
const workflow_id = params.workflow_id; const workflow_id = params.workflow_id;
return ( return (
<Card className="w-full h-fit min-w-0"> <Card className="w-full h-fit min-w-0 relative">
<CardHeader className="relative"> <CardHeader>
<CardTitle>Run</CardTitle> <CardTitle>Run</CardTitle>
<div className="absolute right-6 top-6"> {/* <div className="absolute right-6 top-6">
<RouteRefresher interval={5000} autoRefresh={false} /> <RouteRefresher interval={5000} autoRefresh={false} />
</div> </div> */}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -8,8 +8,8 @@ import {
OpenEditButton, OpenEditButton,
RunWorkflowButton, RunWorkflowButton,
VersionSelect, VersionSelect,
ViewWorkflowDetailsButton,
} from "@/components/VersionSelect"; } from "@/components/VersionSelect";
import { ViewWorkflowDetailsButton } from "@/components/ViewWorkflowDetailsButton";
import { import {
Card, Card,
CardContent, CardContent,
-4
View File
@@ -13,10 +13,7 @@ export async function CodeBlock(props: {
return ( return (
<div className="relative w-full text-sm"> <div className="relative w-full text-sm">
{/* max-w-[calc(32rem-1.5rem-1.5rem)] */}
{/* <div className=""> */}
<p <p
// tabIndex={1}
className="[&>pre]:p-4 rounded-lg max-h-96 overflow-auto w-full" className="[&>pre]:p-4 rounded-lg max-h-96 overflow-auto w-full"
style={{ style={{
overflowWrap: "break-word", overflowWrap: "break-word",
@@ -28,7 +25,6 @@ export async function CodeBlock(props: {
}), }),
}} }}
/> />
{/* </div> */}
<CopyButton className="absolute right-2 top-2" text={props.code} /> <CopyButton className="absolute right-2 top-2" text={props.code} />
</div> </div>
); );
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { CopyButton } from "@/components/CopyButton";
import type { StringLiteralUnion } from "shikiji";
import useSWR from "swr";
import { highlight } from "../server/highlight";
export function CodeBlockClient({
code,
lang,
}: {
code: string;
lang: StringLiteralUnion<string>;
}) {
const { data } = useSWR(code, async () => {
return highlight(code.trim(), lang);
});
return (
<div className="relative w-full text-sm">
{data && (
<p
className="[&>pre]:p-4 rounded-lg max-h-96 overflow-auto w-full"
style={{
overflowWrap: "break-word",
}}
dangerouslySetInnerHTML={{
__html: data,
}}
/>
)}
<CopyButton className="absolute right-2 top-2" text={code} />
</div>
);
}
+3 -1
View File
@@ -7,10 +7,12 @@ import { toast } from "sonner";
export function CopyButton({ export function CopyButton({
className, className,
children,
...props ...props
}: { }: {
text: string; text: string;
className?: string; className?: string;
children?: React.ReactNode;
}) { }) {
return ( return (
<Button <Button
@@ -21,7 +23,7 @@ export function CopyButton({
}} }}
className={cn(" p-2 min-h-0 aspect-square", className)} className={cn(" p-2 min-h-0 aspect-square", className)}
> >
<Copy size={14} /> {children} <Copy size={14} />
</Button> </Button>
); );
} }
+8 -10
View File
@@ -69,13 +69,16 @@ const client = new ComfyDeployClient({
`; `;
const jsClientCreateRunTemplate = ` const jsClientCreateRunTemplate = `
const { run_id } = await client.run("<ID>", { const { run_id } = await client.run({
deployment_id: "<ID>",
inputs: {} inputs: {}
}); });
`; `;
const jsClientCreateRunNoInputsTemplate = ` const jsClientCreateRunNoInputsTemplate = `
const { run_id } = await client.run("<ID>"); const { run_id } = await client.run({
deployment_id: "<ID>"
});
`; `;
const clientTemplate_checkStatus = ` const clientTemplate_checkStatus = `
@@ -119,10 +122,7 @@ export function DeploymentDisplay({
<TabsContent className="flex flex-col gap-2 !mt-0" value="client"> <TabsContent className="flex flex-col gap-2 !mt-0" value="client">
<div> <div>
Install the node comfydeploy SDK Install the node comfydeploy SDK
<CodeBlock <CodeBlock lang="bash" code={`npm i comfydeploy`} />
lang="bash"
code={`npm i comfydeploy`}
/>
Initialize your client Initialize your client
</div> </div>
<CodeBlock <CodeBlock
@@ -197,8 +197,7 @@ function formatCode(
if (inputs && inputs.length > 0) { if (inputs && inputs.length > 0) {
codeTemplate = codeTemplate.replace( codeTemplate = codeTemplate.replace(
"inputs: {}", "inputs: {}",
`inputs: ${ `inputs: ${JSON.stringify(
JSON.stringify(
Object.fromEntries( Object.fromEntries(
inputs.map((x) => { inputs.map((x) => {
return [x?.input_id, ""]; return [x?.input_id, ""];
@@ -209,8 +208,7 @@ function formatCode(
) )
.split("\n") .split("\n")
.map((line, index) => (index === 0 ? line : ` ${line}`)) // Add two spaces indentation except for the first line .map((line, index) => (index === 0 ? line : ` ${line}`)) // Add two spaces indentation except for the first line
.join("\n") .join("\n")}`,
}`,
); );
} else { } else {
codeTemplate = codeTemplate.replace( codeTemplate = codeTemplate.replace(
+41
View File
@@ -0,0 +1,41 @@
import {
Table,
TableBody,
TableCaption,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { headers } from "next/headers";
import { findAllDeployments } from "../server/findAllRuns";
import { DeploymentDisplay } from "./DeploymentDisplay";
export async function DeploymentsTable(props: { workflow_id: string }) {
const allRuns = await findAllDeployments(props.workflow_id);
const headersList = headers();
const host = headersList.get("host") || "";
const protocol = headersList.get("x-forwarded-proto") || "";
const domain = `${protocol}://${host}`;
return (
<div className="overflow-auto h-fit w-full">
<Table className="">
<TableCaption>A list of your deployments</TableCaption>
<TableHeader className="bg-background top-0 sticky">
<TableRow>
<TableHead className=" w-[100px]">Environment</TableHead>
<TableHead className=" w-[100px]">Version</TableHead>
<TableHead className="">Machine</TableHead>
<TableHead className=" text-right">Updated At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allRuns.map((run) => (
<DeploymentDisplay deployment={run} key={run.id} domain={domain} />
))}
</TableBody>
</Table>
</div>
);
}
+41 -2
View File
@@ -23,6 +23,33 @@ import * as React from "react";
import { useState } from "react"; import { useState } from "react";
import type { UnknownKeysParam, ZodObject, ZodRawShape, z } from "zod"; import type { UnknownKeysParam, ZodObject, ZodRawShape, z } from "zod";
type ContextType = [Partial<any>, React.Dispatch<React.SetStateAction<any>>];
const AutoFormValueContext = React.createContext<ContextType | null>(null);
function AutoFormValueProvider<Z extends ZodObject<any, any>>({
children,
value,
}: {
children: React.ReactNode;
value: ContextType;
}) {
return (
<AutoFormValueContext.Provider value={value}>
{children}
</AutoFormValueContext.Provider>
);
}
export function useAutoFormValueContext<Z extends ZodObject<any, any>>() {
const context = React.useContext(AutoFormValueContext);
// if (!context) {
// throw new Error("useInsertModal must be used within a InsertModalProvider");
// }
return context;
}
export function InsertModal< export function InsertModal<
K extends ZodRawShape, K extends ZodRawShape,
Y extends UnknownKeysParam, Y extends UnknownKeysParam,
@@ -41,7 +68,10 @@ export function InsertModal<
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const [values, setValues] = useState<Partial<z.infer<Z>>>({});
return ( return (
<AutoFormValueProvider value={[values, setValues]}>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
{/* <DialogTrigger disabled={props.disabled}> */} {/* <DialogTrigger disabled={props.disabled}> */}
{props.tooltip ? ( {props.tooltip ? (
@@ -74,13 +104,17 @@ export function InsertModal<
</Button> </Button>
)} )}
{/* </DialogTrigger> */} {/* </DialogTrigger> */}
<DialogContent className={cn("sm:max-w-[425px]", props.dialogClassName)}> <DialogContent
className={cn("sm:max-w-[425px]", props.dialogClassName)}
>
<DialogHeader> <DialogHeader>
<DialogTitle>{props.title}</DialogTitle> <DialogTitle>{props.title}</DialogTitle>
<DialogDescription>{props.description}</DialogDescription> <DialogDescription>{props.description}</DialogDescription>
</DialogHeader> </DialogHeader>
{/* <ScrollArea> */} {/* <ScrollArea> */}
<AutoForm <AutoForm
values={values}
onValuesChange={setValues}
fieldConfig={props.fieldConfig} fieldConfig={props.fieldConfig}
formSchema={props.formSchema} formSchema={props.formSchema}
onSubmit={async (data) => { onSubmit={async (data) => {
@@ -100,6 +134,7 @@ export function InsertModal<
{/* </ScrollArea> */} {/* </ScrollArea> */}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</AutoFormValueProvider>
); );
} }
@@ -138,6 +173,7 @@ export function UpdateModal<
}, [props.data]); }, [props.data]);
return ( return (
<AutoFormValueProvider value={[values, setValues]}>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
{props.trigger ?? ( {props.trigger ?? (
<DialogTrigger <DialogTrigger
@@ -150,7 +186,9 @@ export function UpdateModal<
{props.trigger} {props.trigger}
</DialogTrigger> </DialogTrigger>
)} )}
<DialogContent className={cn("sm:max-w-[425px]", props.dialogClassName)}> <DialogContent
className={cn("sm:max-w-[425px]", props.dialogClassName)}
>
<DialogHeader> <DialogHeader>
<DialogTitle>{props.title}</DialogTitle> <DialogTitle>{props.title}</DialogTitle>
<DialogDescription>{props.description}</DialogDescription> <DialogDescription>{props.description}</DialogDescription>
@@ -182,5 +220,6 @@ export function UpdateModal<
</AutoForm> </AutoForm>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</AutoFormValueProvider>
); );
} }
+4 -2
View File
@@ -16,7 +16,7 @@ export function LiveStatus({
(state) => (state) =>
state.data state.data
.filter((x) => x.id === run.id) .filter((x) => x.id === run.id)
.sort((a, b) => b.timestamp - a.timestamp)?.[0] .sort((a, b) => b.timestamp - a.timestamp)?.[0],
); );
let status = run.status; let status = run.status;
@@ -51,7 +51,9 @@ export function LiveStatus({
<> <>
<TableCell> <TableCell>
{data && status != "success" {data && status != "success"
? `${data.json.event} - ${data.json.data.node}` ? `${data.json.event}${
data.json.data.node ? " - " + data.json.data.node : ""
}`
: "-"} : "-"}
</TableCell> </TableCell>
<TableCell className="truncate text-right"> <TableCell className="truncate text-right">
+41 -5
View File
@@ -1,17 +1,31 @@
"use client"; "use client";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
export type LogsType = { export type LogsType = {
machine_id?: string; machine_id?: string;
logs: string; logs: string;
timestamp: number; timestamp?: number;
}[]; }[];
export function LogsViewer({ logs }: { logs: LogsType }) { export function LogsViewer({
logs,
hideTimestamp,
className,
stickToBottom = true,
}: {
logs: LogsType;
hideTimestamp?: boolean;
className?: string;
stickToBottom?: boolean;
}) {
const container = useRef<HTMLDivElement | null>(null); const container = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
if (!stickToBottom) return;
// console.log(logs.length, container.current); // console.log(logs.length, container.current);
if (container.current) { if (container.current) {
const scrollHeight = container.current.scrollHeight; const scrollHeight = container.current.scrollHeight;
@@ -21,11 +35,12 @@ export function LogsViewer({ logs }: { logs: LogsType }) {
behavior: "smooth", behavior: "smooth",
}); });
} }
}, [logs.length]); }, [logs.length, stickToBottom]);
return ( return (
<div <div
ref={(ref) => { ref={(ref) => {
if (!stickToBottom) return;
if (!container.current && ref) { if (!container.current && ref) {
const scrollHeight = ref.scrollHeight; const scrollHeight = ref.scrollHeight;
@@ -36,10 +51,31 @@ export function LogsViewer({ logs }: { logs: LogsType }) {
} }
container.current = ref; container.current = ref;
}} }}
className="flex flex-col text-xs p-2 overflow-y-scroll max-h-[400px] whitespace-break-spaces" className={cn(
"h-full w-full flex flex-col text-xs p-2 overflow-y-scroll whitespace-break-spaces",
className,
)}
> >
{logs.map((x, i) => ( {logs.map((x, i) => (
<div key={i}>{x.logs}</div> <div
key={i}
className="hover:bg-gray-100 flex flex-row items-center gap-2"
onClick={() => {
toast.success("Copied to clipboard");
navigator.clipboard.writeText(x.logs);
}}
>
{!hideTimestamp && x.timestamp != undefined && (
<>
<span className="w-[150px] flex-shrink-0">
{new Date(x.timestamp * 1000).toLocaleString()}
</span>
<div className="h-full w-[1px] bg-stone-400 flex-shrink-0"></div>
</>
)}
{/* Display timestamp */}
<div>{x.logs}</div>
</div>
))} ))}
</div> </div>
); );
+38 -27
View File
@@ -16,7 +16,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export function MachineBuildLog({ export function MachineBuildLog({
@@ -41,7 +41,7 @@ export function MachineBuildLog({
reconnectAttempts: 20, reconnectAttempts: 20,
reconnectInterval: 1000, reconnectInterval: 1000,
queryParams: query, queryParams: query,
} },
); );
const connectionStatus = getConnectionStatus(readyState); const connectionStatus = getConnectionStatus(readyState);
@@ -57,59 +57,70 @@ export function MachineBuildLog({
setLogs((logs) => [...(logs ?? []), message.data]); setLogs((logs) => [...(logs ?? []), message.data]);
} else if (message?.event === "FINISHED") { } else if (message?.event === "FINISHED") {
setFinished(true); setFinished(true);
setStatus(message.data.status) setStatus(message.data.status);
} }
}, [lastMessage]); }, [lastMessage]);
const router = useRouter() const router = useRouter();
return ( return (
<div> <div>
{connectionStatus} {connectionStatus}
<LogsViewer logs={logs} /> <LogsViewer logs={logs} className="h-full max-h-[600px]" />
<AlertDialog open={finished}> <AlertDialog open={finished}>
<AlertDialogContent> <AlertDialogContent>
{ {status == "succuss" ? (
status == "succuss" ? (
<> <>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Machine Built</AlertDialogTitle> <AlertDialogTitle>Machine Built</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Your machine is built, you can now integrate your API, or directly run to check this machines. Your machine is built, you can now integrate your API, or
directly run to check this machines.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogAction onClick={() => { <AlertDialogAction
router.push("/workflows") onClick={() => {
}}>See Workflows</AlertDialogAction> router.push("/workflows");
<AlertDialogAction onClick={() => { }}
router.push("/machines") >
}}>See All Machines</AlertDialogAction> See Workflows
</AlertDialogFooter></> </AlertDialogAction>
<AlertDialogAction
onClick={() => {
router.push("/machines");
}}
>
See All Machines
</AlertDialogAction>
</AlertDialogFooter>
</>
) : ( ) : (
<> <>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Machine Failed</AlertDialogTitle> <AlertDialogTitle>Machine Failed</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Something went wrong with the machine build, please check the log. Something went wrong with the machine build, please check the
Possible cause could be conflits with custom nodes, build got stuck, timeout, or too many custom nodes installed. log. Possible cause could be conflits with custom nodes, build
Please attempt a rebuild or remove some of the custom nodes. got stuck, timeout, or too many custom nodes installed. Please
attempt a rebuild or remove some of the custom nodes.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>See logs</AlertDialogCancel> <AlertDialogCancel>See logs</AlertDialogCancel>
<AlertDialogAction onClick={() => { <AlertDialogAction
router.push("/machines") onClick={() => {
}}>Back to machines</AlertDialogAction> router.push("/machines");
</AlertDialogFooter></> }}
) >
} Back to machines
</AlertDialogAction>
</AlertDialogFooter>
</>
)}
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
); );
} }
+6 -4
View File
@@ -36,7 +36,7 @@ type State = {
json: { json: {
event: string; event: string;
data: any; data: any;
} },
) => void; ) => void;
}; };
@@ -82,7 +82,7 @@ function MachineWS({
const logs = useStore((x) => const logs = useStore((x) =>
x.logs x.logs
.filter((p) => p.machine_id === machine.id) .filter((p) => p.machine_id === machine.id)
.sort((a, b) => a.timestamp - b.timestamp) .sort((a, b) => a.timestamp - b.timestamp),
); );
const [sid, setSid] = useState(""); const [sid, setSid] = useState("");
@@ -96,7 +96,7 @@ function MachineWS({
// queryParams: { // queryParams: {
// clientId: sid, // clientId: sid,
// }, // },
} },
); );
const connectionStatus = getConnectionStatus(readyState); const connectionStatus = getConnectionStatus(readyState);
@@ -135,7 +135,9 @@ function MachineWS({
You can view your run&apos;s outputs here You can view your run&apos;s outputs here
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<LogsViewer logs={logs} /> <div className="h-[400px]">
<LogsViewer logs={logs} hideTimestamp />
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+45 -32
View File
@@ -7,6 +7,13 @@ import { Checkbox } from "@/components/ui/checkbox";
import { InsertModal } from "./InsertModal"; import { InsertModal } from "./InsertModal";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Table, Table,
TableBody, TableBody,
@@ -30,9 +37,9 @@ import {
getSortedRowModel, getSortedRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { ArrowUpDown } from "lucide-react"; import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { addModel } from "@/server/curdModel"; import { addModel, deleteModel } from "@/server/curdModel";
import { downloadUrlModelSchema } from "@/server/addCivitaiModelSchema"; import { downloadUrlModelSchema } from "@/server/addCivitaiModelSchema";
import { modelEnumType } from "@/db/schema"; import { modelEnumType } from "@/db/schema";
@@ -192,10 +199,16 @@ export const columns: ColumnDef<ModelItemList>[] = [
lora: "green", lora: "green",
embedding: "violet", embedding: "violet",
vae: "teal", vae: "teal",
clip: "default",
clip_vision: "default",
configs: "default",
controlnet: "default",
upscale_models: "default",
ipadapter: "default",
}; };
function getBadgeColor(modelType: modelEnumType) { function getBadgeColor(modelType: modelEnumType) {
return model_type_map[modelType] || "default"; return model_type_map[modelType]
} }
const color = getBadgeColor(row.original.model_type); const color = getBadgeColor(row.original.model_type);
@@ -225,35 +238,35 @@ export const columns: ColumnDef<ModelItemList>[] = [
), ),
}, },
// TODO: deletion and editing for future sprint // TODO: deletion and editing for future sprint
// { {
// id: "actions", id: "actions",
// enableHiding: false, enableHiding: false,
// cell: ({ row }) => { cell: ({ row }) => {
// const checkpoint = row.original; const model = row.original;
//
// return ( return (
// <DropdownMenu> <DropdownMenu>
// <DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
// <Button variant="ghost" className="h-8 w-8 p-0"> <Button variant="ghost" className="h-8 w-8 p-0">
// <span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
// <MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
// </Button> </Button>
// </DropdownMenuTrigger> </DropdownMenuTrigger>
// <DropdownMenuContent align="end"> <DropdownMenuContent align="end">
// <DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
// <DropdownMenuItem <DropdownMenuItem
// className="text-destructive" className="text-destructive"
// onClick={() => { onClick={() => {
// deleteWorkflow(checkpoint.id); deleteModel(model.id);
// }} }}
// > >
// Delete Workflow Delete Model
// </DropdownMenuItem> </DropdownMenuItem>
// </DropdownMenuContent> </DropdownMenuContent>
// </DropdownMenu> </DropdownMenu>
// ); );
// }, },
// }, },
]; ];
export function ModelList({ data }: { data: ModelItemList[] }) { export function ModelList({ data }: { data: ModelItemList[] }) {
+25 -10
View File
@@ -1,27 +1,42 @@
"use client";
import useSWR from "swr";
import { DownloadButton } from "./DownloadButton"; import { DownloadButton } from "./DownloadButton";
import { getFileDownloadUrl } from "@/server/getFileDownloadUrl"; import { getFileDownloadUrl } from "@/server/getFileDownloadUrl";
export async function OutputRender(props: { export function OutputRender(props: {
run_id: string; run_id: string;
filename: string; filename: string;
}) { }) {
const { data: url } = useSWR(
"run-outputs+" + props.run_id + props.filename,
async () => {
return await getFileDownloadUrl(
`outputs/runs/${props.run_id}/${props.filename}`,
);
},
);
if (!url) return <></>;
if (props.filename.endsWith(".mp4") || props.filename.endsWith(".webm")) {
return (
<video controls autoPlay className="w-[400px]">
<source src={url} type="video/mp4" />
<source src={url} type="video/webm" />
Your browser does not support the video tag.
</video>
);
}
if ( if (
props.filename.endsWith(".png") || props.filename.endsWith(".png") ||
props.filename.endsWith(".gif") || props.filename.endsWith(".gif") ||
props.filename.endsWith(".jpg") || props.filename.endsWith(".jpg") ||
props.filename.endsWith(".jpeg") props.filename.endsWith(".jpeg")
) { ) {
const url = await getFileDownloadUrl(
`outputs/runs/${props.run_id}/${props.filename}`
);
return <img className="max-w-[200px]" alt={props.filename} src={url} />; return <img className="max-w-[200px]" alt={props.filename} src={url} />;
} else { } else {
const url = await getFileDownloadUrl(
`outputs/runs/${props.run_id}/${props.filename}`
);
// console.log(url);
return <DownloadButton filename={props.filename} href={url} />; return <DownloadButton filename={props.filename} href={url} />;
} }
} }
+7 -5
View File
@@ -1,3 +1,5 @@
"use client";
import { RunInputs } from "@/components/RunInputs"; import { RunInputs } from "@/components/RunInputs";
import { RunOutputs } from "@/components/RunOutputs"; import { RunOutputs } from "@/components/RunOutputs";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -17,10 +19,10 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { getDuration, getRelativeTime } from "@/lib/getRelativeTime"; import { getDuration, getRelativeTime } from "@/lib/getRelativeTime";
import { type findAllRuns } from "@/server/findAllRuns"; import { type findAllRuns } from "@/server/findAllRuns";
import { Suspense } from "react";
import { LiveStatus } from "./LiveStatus"; import { LiveStatus } from "./LiveStatus";
import { LoadingWrapper } from "@/components/LoadingWrapper";
export async function RunDisplay({ export function RunDisplay({
run, run,
}: { }: {
run: Awaited<ReturnType<typeof findAllRuns>>[0]; run: Awaited<ReturnType<typeof findAllRuns>>[0];
@@ -73,9 +75,9 @@ export async function RunDisplay({
</DialogHeader> </DialogHeader>
<div className="max-h-96 overflow-y-scroll"> <div className="max-h-96 overflow-y-scroll">
<RunInputs run={run} /> <RunInputs run={run} />
<Suspense> <LoadingWrapper tag="output">
<RunOutputs run_id={run.id} /> <RunOutputs run={run} />
</Suspense> </LoadingWrapper>
</div> </div>
{/* <div className="max-h-96 overflow-y-scroll">{view}</div> */} {/* <div className="max-h-96 overflow-y-scroll">{view}</div> */}
</DialogContent> </DialogContent>
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { findAllRuns } from "@/server/findAllRuns"; import type { findAllRuns } from "@/server/findAllRuns";
export async function RunInputs({ export function RunInputs({
run, run,
}: { }: {
run: Awaited<ReturnType<typeof findAllRuns>>[0]; run: Awaited<ReturnType<typeof findAllRuns>>[0];
+68 -6
View File
@@ -1,5 +1,16 @@
"use client";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { OutputRender } from "./OutputRender"; import { OutputRender } from "./OutputRender";
import { CodeBlock } from "@/components/CodeBlock";
import { import {
Table, Table,
TableBody, TableBody,
@@ -8,10 +19,25 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { findAllRuns } from "@/server/findAllRuns";
import { getRunsOutput } from "@/server/getRunsOutput"; import { getRunsOutput } from "@/server/getRunsOutput";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
import { LogsViewer } from "@/components/LogsViewer";
import { CopyButton } from "@/components/CopyButton";
import useSWR from "swr";
import { CodeBlockClient } from "@/components/CodeBlockClient";
export function RunOutputs({
run,
}: { run: Awaited<ReturnType<typeof findAllRuns>>[0] }) {
const { data, isValidating, error } = useSWR(
"run-outputs+" + run.id,
async () => {
return await getRunsOutput(run.id);
},
);
export async function RunOutputs({ run_id }: { run_id: string }) {
const outputs = await getRunsOutput(run_id);
return ( return (
<Table className="table-fixed"> <Table className="table-fixed">
<TableHeader className="bg-background top-0 sticky"> <TableHeader className="bg-background top-0 sticky">
@@ -21,7 +47,43 @@ export async function RunOutputs({ run_id }: { run_id: string }) {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{outputs?.map((run) => { <TableRow key={run.id}>
<TableCell className="break-words">Run log</TableCell>
<TableCell>
{run.run_log ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary" className="w-fit">
View Log <ExternalLink size={14} />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[1000px] h-full max-h-[600px] grid grid-rows-[auto,1fr,auto]">
<DialogHeader>
<DialogTitle>Run Log</DialogTitle>
</DialogHeader>
<LogsViewer logs={run.run_log} stickToBottom={false} />
<DialogFooter>
<CopyButton
className="w-fit aspect-auto p-4"
text={JSON.stringify(run.run_log)}
>
Copy
</CopyButton>
<DialogClose>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
"No log available"
)}
</TableCell>
</TableRow>
{data?.map((run) => {
const fileName = const fileName =
run.data.images?.[0].filename || run.data.images?.[0].filename ||
run.data.files?.[0].filename || run.data.files?.[0].filename ||
@@ -32,7 +94,7 @@ export async function RunOutputs({ run_id }: { run_id: string }) {
<TableRow key={run.id}> <TableRow key={run.id}>
<TableCell>Output</TableCell> <TableCell>Output</TableCell>
<TableCell className=""> <TableCell className="">
<CodeBlock <CodeBlockClient
code={JSON.stringify(run.data, null, 2)} code={JSON.stringify(run.data, null, 2)}
lang="json" lang="json"
/> />
@@ -45,7 +107,7 @@ export async function RunOutputs({ run_id }: { run_id: string }) {
<TableRow key={run.id}> <TableRow key={run.id}>
<TableCell className="break-words">{fileName}</TableCell> <TableCell className="break-words">{fileName}</TableCell>
<TableCell> <TableCell>
<OutputRender run_id={run_id} filename={fileName} /> <OutputRender run_id={run.run_id} filename={fileName} />
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
+32 -45
View File
@@ -1,3 +1,5 @@
"use client";
import { import {
Table, Table,
TableBody, TableBody,
@@ -7,38 +9,53 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { parseAsInteger } from "next-usequerystate"; import { parseAsInteger } from "next-usequerystate";
import { headers } from "next/headers";
import { import {
findAllDeployments,
findAllRunsWithCounts, findAllRunsWithCounts,
getAllRunstableContent,
} from "../server/findAllRuns"; } from "../server/findAllRuns";
import { DeploymentDisplay } from "./DeploymentDisplay";
import { PaginationControl } from "./PaginationControl"; import { PaginationControl } from "./PaginationControl";
import { RunDisplay } from "./RunDisplay"; import { RunDisplay } from "./RunDisplay";
import useSWR from "swr";
import { LoadingIcon } from "@/components/LoadingIcon";
const itemPerPage = 6; const itemPerPage = 6;
const pageParser = parseAsInteger.withDefault(1); const pageParser = parseAsInteger.withDefault(1);
export async function RunsTable(props: { export function RunsTable(props: {
workflow_id: string; workflow_id: string;
searchParams: { [key: string]: string | string[] | undefined }; searchParams: { [key: string]: any };
}) { }) {
// await new Promise((resolve) => setTimeout(resolve, 5000)); const page = pageParser.parse(props.searchParams?.page ?? undefined) ?? 1;
const page = pageParser.parseServerSide( const { data, error, isLoading, isValidating } = useSWR(
props.searchParams?.page ?? undefined "runs+" + page,
); async () => {
const { allRuns, total } = await findAllRunsWithCounts({ const data = await findAllRunsWithCounts({
workflow_id: props.workflow_id, workflow_id: props.workflow_id,
limit: itemPerPage, limit: itemPerPage,
offset: (page - 1) * itemPerPage, offset: (page - 1) * itemPerPage,
}); });
return data;
},
{
// suspense: false,
refreshInterval: 5000,
},
);
// await new Promise((resolve) => setTimeout(resolve, 5000));
return ( return (
<div> <div>
{isValidating ? (
<div className="absolute right-8 top-8">
<LoadingIcon />
</div>
) : null}
<div className="overflow-auto h-fit w-full"> <div className="overflow-auto h-fit w-full">
<Table className=""> <Table className="">
{allRuns.length === 0 && ( {/* {data?.allRuns.length === 0 && (
<TableCaption>A list of your recent runs.</TableCaption> <TableCaption>A list of your recent runs.</TableCaption>
)} )} */}
<TableHeader className="bg-background top-0 sticky"> <TableHeader className="bg-background top-0 sticky">
<TableRow> <TableRow>
<TableHead className="truncate">Number</TableHead> <TableHead className="truncate">Number</TableHead>
@@ -52,49 +69,19 @@ export async function RunsTable(props: {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{allRuns.map((run) => ( {data?.allRuns.map((run) => (
<RunDisplay run={run} key={run.id} /> <RunDisplay run={run} key={run.id} />
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{Math.ceil(total / itemPerPage) > 0 && ( {data && Math.ceil(data.total / itemPerPage) > 0 && (
<PaginationControl <PaginationControl
totalPage={Math.ceil(total / itemPerPage)} totalPage={Math.ceil(data.total / itemPerPage)}
currentPage={page} currentPage={page}
/> />
)} )}
</div> </div>
); );
} }
export async function DeploymentsTable(props: { workflow_id: string }) {
const allRuns = await findAllDeployments(props.workflow_id);
const headersList = headers();
const host = headersList.get("host") || "";
const protocol = headersList.get("x-forwarded-proto") || "";
const domain = `${protocol}://${host}`;
return (
<div className="overflow-auto h-fit w-full">
<Table className="">
<TableCaption>A list of your deployments</TableCaption>
<TableHeader className="bg-background top-0 sticky">
<TableRow>
<TableHead className=" w-[100px]">Environment</TableHead>
<TableHead className=" w-[100px]">Version</TableHead>
<TableHead className="">Machine</TableHead>
<TableHead className=" text-right">Updated At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allRuns.map((run) => (
<DeploymentDisplay deployment={run} key={run.id} domain={domain} />
))}
</TableBody>
</Table>
</div>
);
}
+2
View File
@@ -17,6 +17,8 @@ export function StatusBadge({
); );
case "success": case "success":
return <Badge variant="success">{status}</Badge>; return <Badge variant="success">{status}</Badge>;
case "timeout":
return <Badge variant="amber">{status}</Badge>;
case "failed": case "failed":
return <Badge variant="destructive">{status}</Badge>; return <Badge variant="destructive">{status}</Badge>;
} }
+38 -184
View File
@@ -2,7 +2,6 @@
import { LoadingIcon } from "@/components/LoadingIcon"; import { LoadingIcon } from "@/components/LoadingIcon";
import AutoForm, { AutoFormSubmit } from "@/components/ui/auto-form"; import AutoForm, { AutoFormSubmit } from "@/components/ui/auto-form";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -28,36 +27,19 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import type { showcaseMediaNullable } from "@/db/schema";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { showcaseMediaNullable, workflowAPINodeType } from "@/db/schema";
import { checkStatus, createRun } from "@/server/createRun"; import { checkStatus, createRun } from "@/server/createRun";
import { createDeployments } from "@/server/curdDeploments"; import { createDeployments } from "@/server/curdDeploments";
import type { getMachines } from "@/server/curdMachine"; import type { getMachines } from "@/server/curdMachine";
import type { findFirstTableWithVersion } from "@/server/findFirstTableWithVersion"; import type { findFirstTableWithVersion } from "@/server/findFirstTableWithVersion";
import { import { Copy, Edit, MoreVertical, Play } from "lucide-react";
Copy,
Edit,
ExternalLink,
Info,
MoreVertical,
Play,
} from "lucide-react";
import { parseAsInteger, useQueryState } from "next-usequerystate"; import { parseAsInteger, useQueryState } from "next-usequerystate";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr";
import type { z } from "zod"; import type { z } from "zod";
import { create } from "zustand"; import { create } from "zustand";
import { workflowVersionInputsToZod } from "../lib/workflowVersionInputsToZod"; import { workflowVersionInputsToZod } from "../lib/workflowVersionInputsToZod";
import { callServerPromise } from "./callServerPromise"; import { callServerPromise } from "./callServerPromise";
import fetcher from "./fetcher";
import { ButtonAction } from "@/components/ButtonActionLoader"; import { ButtonAction } from "@/components/ButtonActionLoader";
import { editWorkflowOnMachine } from "@/server/editWorkflowOnMachine"; import { editWorkflowOnMachine } from "@/server/editWorkflowOnMachine";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -125,29 +107,48 @@ export function MachineSelect({
); );
} }
type SelectedMachineStore = {
selectedMachine: string | undefined;
setSelectedMachine: (machine: string) => void;
};
export const selectedMachineStore = create<SelectedMachineStore>((set) => ({
selectedMachine: undefined,
setSelectedMachine: (machine) => set(() => ({ selectedMachine: machine })),
}));
export function useSelectedMachine( export function useSelectedMachine(
machines: Awaited<ReturnType<typeof getMachines>>, machines: Awaited<ReturnType<typeof getMachines>>,
): [string, (v: string) => void] { ): [string, (v: string) => void] {
const searchParams = useSearchParams(); const { selectedMachine, setSelectedMachine } = selectedMachineStore();
const pathname = usePathname(); return [selectedMachine ?? machines?.[0]?.id ?? "", setSelectedMachine];
const router = useRouter();
const createQueryString = useCallback( // const searchParams = useSearchParams();
(name: string, value: string) => { // const pathname = usePathname();
const params = new URLSearchParams(searchParams.toString()); // const router = useRouter();
params.set(name, value);
return params.toString(); // const createQueryString = useCallback(
}, // (name: string, value: string) => {
[searchParams], // const params = new URLSearchParams(searchParams.toString());
); // params.set(name, value);
return [ // return params.toString();
searchParams.get("machine") ?? machines?.[0]?.id ?? "", // },
(v: string) => { // [searchParams],
router.push(pathname + "?" + createQueryString("machine", v)); // );
},
]; // return [
// searchParams.get("machine") ?? machines?.[0]?.id ?? "",
// (v: string) => {
// // window.history.pushState(
// // "new url",
// // "",
// // pathname + "?" + createQueryString("machine", v),
// // );
// // router.push(pathname + "?" + createQueryString("machine", v));
// router.replace(pathname + "?" + createQueryString("machine", v));
// },
// ];
} }
type PublicRunStore = { type PublicRunStore = {
@@ -500,150 +501,3 @@ export function getWorkflowVersionFromVersionIndex(
return workflow_version; return workflow_version;
} }
export function ViewWorkflowDetailsButton({
workflow,
}: {
workflow: Awaited<ReturnType<typeof findFirstTableWithVersion>>;
}) {
const [version] = useQueryState("version", {
defaultValue: workflow?.versions[0].version ?? 1,
...parseAsInteger,
});
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const {
data,
error,
isLoading: isNodesIndexLoading,
} = useSWR(
"https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/extension-node-map.json",
fetcher,
);
const groupedByAuxName = useMemo(() => {
if (!data) return null;
// console.log(data);
const workflow_version = getWorkflowVersionFromVersionIndex(
workflow,
version,
);
const api = workflow_version?.workflow_api;
if (!api) return null;
const crossCheckedApi = Object.entries(api)
.map(([_, value]) => {
const classType = value.class_type;
const classTypeData = Object.entries(data).find(([_, nodeArray]) =>
nodeArray[0].includes(classType),
);
return classTypeData ? { node: value, classTypeData } : null;
})
.filter((item) => item !== null);
// console.log(crossCheckedApi);
const groupedByAuxName = crossCheckedApi.reduce(
(acc, data) => {
if (!data) return acc;
const { node, classTypeData } = data;
const auxName = classTypeData[1][1].title_aux;
// console.log(auxName);
if (!acc[auxName]) {
acc[auxName] = {
url: classTypeData[0],
node: [],
};
}
acc[auxName].node.push(node);
return acc;
},
{} as Record<
string,
{
node: z.infer<typeof workflowAPINodeType>[];
url: string;
}
>,
);
// console.log(groupedByAuxName);
return groupedByAuxName;
}, [version, data]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild className="appearance-none hover:cursor-pointer">
<Button className="gap-2" variant="outline">
Details <Info size={14} />
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Workflow Details</DialogTitle>
<DialogDescription>
View your custom nodes, models, external files used in this workflow
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[400px] w-full">
<Table>
<TableHeader className="bg-background top-0 sticky">
<TableRow>
<TableHead className="w-[200px]">File</TableHead>
<TableHead className="">Output</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupedByAuxName &&
Object.entries(groupedByAuxName).map(([key, group]) => {
// const filePath
return (
<TableRow key={key}>
<TableCell className="break-words">
<a
href={group.url}
target="_blank"
className="hover:underline"
rel="noreferrer"
>
{key}
<ExternalLink
className="inline-block ml-1"
size={12}
/>
</a>
</TableCell>
<TableCell className="flex flex-wrap gap-2">
{group.node.map((x) => (
<Badge key={x.class_type} variant="outline">
{x.class_type}
</Badge>
))}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<div className="flex justify-end">
<Button className="w-fit" onClick={() => setOpen(false)}>
Close
</Button>
</div>
{/* </div> */}
{/* <div className="max-h-96 overflow-y-scroll">{view}</div> */}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,169 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { workflowAPINodeType } from "@/db/schema";
import type { findFirstTableWithVersion } from "@/server/findFirstTableWithVersion";
import { ExternalLink, Info } from "lucide-react";
import { parseAsInteger, useQueryState } from "next-usequerystate";
import { useMemo } from "react";
import useSWR from "swr";
import type { z } from "zod";
import fetcher from "./fetcher";
import { getWorkflowVersionFromVersionIndex } from "./VersionSelect";
export function ViewWorkflowDetailsButton({
workflow,
}: {
workflow: Awaited<ReturnType<typeof findFirstTableWithVersion>>;
}) {
const [version] = useQueryState("version", {
defaultValue: workflow?.versions[0].version ?? 1,
...parseAsInteger,
});
const {
data,
error,
isLoading: isNodesIndexLoading,
} = useSWR(
"https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/extension-node-map.json",
fetcher,
);
const groupedByAuxName = useMemo(() => {
if (!data) return null;
const workflow_version = getWorkflowVersionFromVersionIndex(
workflow,
version,
);
const api = workflow_version?.workflow_api;
if (!api) return null;
const crossCheckedApi = Object.entries(api)
.map(([_, value]) => {
const classType = value.class_type;
const classTypeData = Object.entries(data).find(([_, nodeArray]) =>
nodeArray[0].includes(classType),
);
return classTypeData ? { node: value, classTypeData } : null;
})
.filter((item) => item !== null);
// console.log(crossCheckedApi);
const groupedByAuxName = crossCheckedApi.reduce(
(acc, data) => {
if (!data) return acc;
const { node, classTypeData } = data;
const auxName = classTypeData[1][1].title_aux;
// console.log(auxName);
if (!acc[auxName]) {
acc[auxName] = {
url: classTypeData[0],
node: [],
};
}
acc[auxName].node.push(node);
return acc;
},
{} as Record<
string,
{
node: z.infer<typeof workflowAPINodeType>[];
url: string;
}
>,
);
// console.log(groupedByAuxName);
return groupedByAuxName;
}, [version, data]);
return (
<Dialog>
<DialogTrigger asChild className="appearance-none hover:cursor-pointer">
<Button className="gap-2" variant="outline">
Details <Info size={14} />
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Workflow Details</DialogTitle>
<DialogDescription>
View your custom nodes, models, external files used in this workflow
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[400px] w-full">
<Table>
<TableHeader className="bg-background top-0 sticky">
<TableRow>
<TableHead className="w-[200px]">File</TableHead>
<TableHead className="">Output</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupedByAuxName &&
Object.entries(groupedByAuxName).map(([key, group]) => {
// const filePath
return (
<TableRow key={key}>
<TableCell className="break-words">
<a
href={group.url}
target="_blank"
className="hover:underline"
rel="noreferrer"
>
{key}
<ExternalLink
className="inline-block ml-1"
size={12}
/>
</a>
</TableCell>
<TableCell className="flex flex-wrap gap-2">
{group.node.map((x) => (
<Badge key={x.class_type} variant="outline">
{x.class_type}
</Badge>
))}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<div className="flex justify-end">
<DialogClose asChild>
<Button className="w-fit">Close</Button>
</DialogClose>
</div>
{/* </div> */}
{/* <div className="max-h-96 overflow-y-scroll">{view}</div> */}
</DialogContent>
</Dialog>
);
}
@@ -53,9 +53,8 @@ import {
Plus, Plus,
} from "lucide-react"; } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import { z } from "zod"; import { getBranchInfo } from "./getBranchInfo";
export function SnapshotPickerView({ export function SnapshotPickerView({
field, field,
@@ -131,7 +130,7 @@ export function SnapshotPickerView({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
disabled={key.endsWith("comfyui-deploy.git")} disabled={key.endsWith("comfyui-deploy")}
// className="opacity-50" // className="opacity-50"
onClick={() => { onClick={() => {
const newNodeList = { const newNodeList = {
@@ -148,6 +147,29 @@ export function SnapshotPickerView({
> >
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
// className="opacity-50"
onClick={async () => {
const newNodeList = {
...field.value.git_custom_nodes,
};
const branchInfo = await getBranchInfo(key);
if (!branchInfo) return;
newNodeList[key].hash = branchInfo?.commit.sha;
const nodeList = newNodeList;
const newValue = {
...field.value,
git_custom_nodes: nodeList,
};
field.onChange(newValue);
}}
>
Update
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</Card> </Card>
@@ -268,24 +290,6 @@ type CustomNodeList = {
}[]; }[];
}; };
const RepoSchema = z.object({
default_branch: z.string(),
});
const BranchInfoSchema = z.object({
commit: z.object({
sha: z.string(),
}),
});
function extractRepoName(repoUrl: string) {
const url = new URL(repoUrl);
const pathParts = url.pathname.split("/");
const repoName = pathParts[2].replace(".git", "");
const author = pathParts[1];
return `${author}/${repoName}`;
}
function CustomNodesSelector({ function CustomNodesSelector({
field, field,
}: Pick<AutoFormInputComponentProps, "field">) { }: Pick<AutoFormInputComponentProps, "field">) {
@@ -362,43 +366,7 @@ function CustomNodesSelector({
delete newNodeList[currentValue]; delete newNodeList[currentValue];
nodeList = newNodeList; nodeList = newNodeList;
} else { } else {
const repoName = extractRepoName(currentValue); const branchInfo = await getBranchInfo(currentValue);
const id = toast.loading(`Fetching repo info...`);
const repo = await fetch(
`https://api.github.com/repos/${repoName}`,
)
.then((x) => x.json())
.then((x) => {
console.log(x);
return x;
})
.then((x) => RepoSchema.parse(x))
.catch((e) => {
console.error(e);
toast.dismiss(id);
toast.error(
`Failed to fetch repo info ${e.message}`,
);
return null;
});
if (!repo) return;
const branch = repo.default_branch;
const branchInfo = await fetch(
`https://api.github.com/repos/${repoName}/branches/${branch}`,
)
.then((x) => x.json())
.then((x) => BranchInfoSchema.parse(x))
.catch((e) => {
console.error(e);
toast.dismiss(id);
toast.error(
`Failed to fetch branch info ${e.message}`,
);
return null;
});
toast.dismiss(id);
if (!branchInfo) return; if (!branchInfo) return;
@@ -0,0 +1,52 @@
import { toast } from "sonner";
import { z } from "zod";
const RepoSchema = z.object({
default_branch: z.string(),
});
const BranchInfoSchema = z.object({
commit: z.object({
sha: z.string(),
}),
});
function extractRepoName(repoUrl: string) {
const url = new URL(repoUrl);
const pathParts = url.pathname.split("/");
const repoName = pathParts[2].replace(".git", "");
const author = pathParts[1];
return `${author}/${repoName}`;
}
export async function getBranchInfo(gitUrl: string) {
const repoName = extractRepoName(gitUrl);
const id = toast.loading(`Fetching repo info...`);
const repo = await fetch(`https://api.github.com/repos/${repoName}`)
.then((x) => x.json())
.then((x) => {
console.log(x);
return x;
})
.then((x) => RepoSchema.parse(x))
.catch((e) => {
console.error(e);
toast.dismiss(id);
toast.error(`Failed to fetch repo info ${e.message}`);
return null;
});
if (!repo) return;
const branch = repo.default_branch;
const branchInfo = await fetch(
`https://api.github.com/repos/${repoName}/branches/${branch}`,
)
.then((x) => x.json())
.then((x) => BranchInfoSchema.parse(x))
.catch((e) => {
console.error(e);
toast.dismiss(id);
toast.error(`Failed to fetch branch info ${e.message}`);
return null;
});
toast.dismiss(id);
return branchInfo;
}
+17 -6
View File
@@ -1,3 +1,4 @@
import { LogsType } from "@/components/LogsViewer";
import { CivitaiModelResponse } from "@/types/civitai"; import { CivitaiModelResponse } from "@/types/civitai";
import { type InferSelectModel, relations } from "drizzle-orm"; import { type InferSelectModel, relations } from "drizzle-orm";
import { import {
@@ -6,10 +7,10 @@ import {
jsonb, jsonb,
pgEnum, pgEnum,
pgSchema, pgSchema,
real,
text, text,
timestamp, timestamp,
uuid, uuid,
real,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { TypeOf, z } from "zod"; import { TypeOf, z } from "zod";
@@ -104,6 +105,7 @@ export const workflowRunStatus = pgEnum("workflow_run_status", [
"failed", "failed",
"started", "started",
"queued", "queued",
"timeout",
]); ]);
export const deploymentEnvironment = pgEnum("deployment_environment", [ export const deploymentEnvironment = pgEnum("deployment_environment", [
@@ -148,8 +150,9 @@ export const workflowRunsTable = dbSchema.table("workflow_runs", {
onDelete: "set null", onDelete: "set null",
}, },
), ),
workflow_inputs: workflow_inputs: jsonb("workflow_inputs").$type<
jsonb("workflow_inputs").$type<Record<string, string | number>>(), Record<string, string | number>
>(),
workflow_id: uuid("workflow_id") workflow_id: uuid("workflow_id")
.notNull() .notNull()
.references(() => workflowTable.id, { .references(() => workflowTable.id, {
@@ -172,6 +175,7 @@ export const workflowRunsTable = dbSchema.table("workflow_runs", {
machine_type: machinesType("machine_type"), machine_type: machinesType("machine_type"),
user_id: text("user_id"), user_id: text("user_id"),
org_id: text("org_id"), org_id: text("org_id"),
run_log: jsonb("run_log").$type<LogsType>(),
}); });
export const workflowRunRelations = relations( export const workflowRunRelations = relations(
@@ -295,8 +299,9 @@ export const deploymentsTable = dbSchema.table("deployments", {
.references(() => machinesTable.id), .references(() => machinesTable.id),
share_slug: text("share_slug").unique(), share_slug: text("share_slug").unique(),
description: text("description"), description: text("description"),
showcase_media: showcase_media: jsonb("showcase_media").$type<
jsonb("showcase_media").$type<z.infer<typeof showcaseMedia>>(), z.infer<typeof showcaseMedia>
>(),
environment: deploymentEnvironment("environment").notNull(), environment: deploymentEnvironment("environment").notNull(),
created_at: timestamp("created_at").defaultNow().notNull(), created_at: timestamp("created_at").defaultNow().notNull(),
updated_at: timestamp("updated_at").defaultNow().notNull(), updated_at: timestamp("updated_at").defaultNow().notNull(),
@@ -391,7 +396,13 @@ export const modelTypes = [
"lora", "lora",
"embedding", "embedding",
"vae", "vae",
] as const "clip",
"clip_vision",
"configs",
"controlnet",
"upscale_models",
"ipadapter",
] as const;
export const modelType = pgEnum("model_type", modelTypes); export const modelType = pgEnum("model_type", modelTypes);
export type modelEnumType = (typeof modelTypes)[number]; export type modelEnumType = (typeof modelTypes)[number];
+12 -2
View File
@@ -31,6 +31,16 @@ const getOutputRoute = createRoute({
input_image: "https://somestatic.png", input_image: "https://somestatic.png",
}, },
}), }),
run_log: (schema) =>
schema.run_log.openapi({
type: "object",
example: [
{
logs: "some logs",
timestamp: 1706631877.3831277,
},
],
}),
}), }),
}, },
}, },
@@ -81,7 +91,7 @@ export const registerGetOutputRoute = (app: App) => {
code: 400, code: 400,
message: "Workflow not found", message: "Workflow not found",
}, },
400 400,
); );
return c.json(run, 200); return c.json(run, 200);
@@ -94,7 +104,7 @@ export const registerGetOutputRoute = (app: App) => {
}, },
{ {
status: 500, status: 500,
} },
); );
} }
}); });
+3 -15
View File
@@ -16,26 +16,14 @@ export const insertCustomMachineSchema = createInsertSchema(machinesTable, {
schema.snapshot.default({ schema.snapshot.default({
comfyui: "d0165d819afe76bd4e6bdd710eb5f3e571b6a804", comfyui: "d0165d819afe76bd4e6bdd710eb5f3e571b6a804",
git_custom_nodes: { git_custom_nodes: {
"https://github.com/BennyKok/comfyui-deploy.git": { "https://github.com/bennykok/comfyui-deploy": {
hash: "a838cb7ad425e5652c3931fbafdc886b53c48a22", hash: "df46e3a0e5ad93fa71f5d216997e376af33b2a6d",
disabled: false, disabled: false,
}, },
}, },
file_custom_nodes: [], file_custom_nodes: [],
}), }),
models: (schema) => models: (schema) => schema.models.default([]),
schema.models.default([
{
name: "v1-5-pruned-emaonly.ckpt",
type: "checkpoints",
base: "SD1.5",
save_path: "default",
description: "Stable Diffusion 1.5 base model",
reference: "https://huggingface.co/runwayml/stable-diffusion-v1-5",
filename: "v1-5-pruned-emaonly.ckpt",
url: "https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned-emaonly.ckpt",
},
]),
}); });
export const addCustomMachineSchema = insertCustomMachineSchema.pick({ export const addCustomMachineSchema = insertCustomMachineSchema.pick({
+45 -2
View File
@@ -11,6 +11,7 @@ import {
import { withServerPromise } from "./withServerPromise"; import { withServerPromise } from "./withServerPromise";
import { db } from "@/db/db"; import { db } from "@/db/db";
import type { z } from "zod"; import type { z } from "zod";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { downloadUrlModelSchema } from "./addCivitaiModelSchema"; import { downloadUrlModelSchema } from "./addCivitaiModelSchema";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
@@ -210,6 +211,47 @@ export const addModelDownloadUrl = withServerPromise(
}, },
); );
export const deleteModel = withServerPromise(
async (modelId: string) => {
const model = await db.query.modelTable.findFirst({
where: eq(modelTable.id, modelId),
});
// If the model does not exist, throw an error or return a message
if (!model) {
throw new Error("Model not found");
// Or return { error: "Model not found" }; if you prefer to handle it without throwing
}
const volumes = await retrieveModelVolumes();
if (
model.status === "success" && !!model.folder_path && !!model.model_name
) {
const result = await fetch(
`${process.env.MODAL_BUILDER_URL!}/delete-volume-model`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
volume_name: volumes[0].volume_name,
path: model.folder_path,
file_name: model.model_name,
}),
},
);
if (!result.ok) {
const error_log = await result.text();
throw new Error(`Error: ${result.statusText} ${error_log}`);
}
}
await db.delete(modelTable).where(eq(modelTable.id, modelId));
revalidatePath("/storage");
return { message: "Model Deleted" };
},
);
export const getCivitaiModelRes = async (civitaiUrl: string) => { export const getCivitaiModelRes = async (civitaiUrl: string) => {
const { url, modelVersionId } = getUrl(civitaiUrl); const { url, modelVersionId } = getUrl(civitaiUrl);
const civitaiModelRes = await fetch(url) const civitaiModelRes = await fetch(url)
@@ -301,8 +343,8 @@ export const addCivitaiModel = withServerPromise(
model_name: selectedModelVersion.files[0].name, model_name: selectedModelVersion.files[0].name,
civitai_id: civitaiModelRes.id.toString(), civitai_id: civitaiModelRes.id.toString(),
civitai_version_id: selectedModelVersionId, civitai_version_id: selectedModelVersionId,
civitai_url: data.url, // TODO: need to confirm civitai_url: data.url,
civitai_download_url: selectedModelVersion.files[0].downloadUrl, civitai_download_url: selectedModelVersion.files[0].downloadUrl, // there is an issue when a model hoster might put multiple different types of files i.e. their training data.
civitai_model_response: civitaiModelRes, civitai_model_response: civitaiModelRes,
user_volume_id: volumes[0].id, user_volume_id: volumes[0].id,
model_type, model_type,
@@ -312,6 +354,7 @@ export const addCivitaiModel = withServerPromise(
const b = a[0]; const b = a[0];
await uploadModel(data, b, volumes[0]); await uploadModel(data, b, volumes[0]);
revalidatePath("/storage");
}, },
); );
+10
View File
@@ -1,5 +1,6 @@
"use server"; "use server";
import { RunDisplay } from "@/components/RunDisplay";
import { db } from "@/db/db"; import { db } from "@/db/db";
import { deploymentsTable, workflowRunsTable } from "@/db/schema"; import { deploymentsTable, workflowRunsTable } from "@/db/schema";
import { count, desc, eq, sql } from "drizzle-orm"; import { count, desc, eq, sql } from "drizzle-orm";
@@ -56,6 +57,15 @@ export async function findAllRuns({
}); });
} }
export async function getAllRunstableContent(props: RunsSearchTypes) {
const data = await findAllRunsWithCounts(props);
return {
table: data?.allRuns.map((run) => <RunDisplay run={run} key={run.id} />),
total: data?.total,
};
}
export async function findAllRunsWithCounts(props: RunsSearchTypes) { export async function findAllRunsWithCounts(props: RunsSearchTypes) {
const a = await db const a = await db
.select({ .select({
+2 -1
View File
@@ -1,5 +1,5 @@
import { db } from "@/db/db"; import { db } from "@/db/db";
import { and, desc, eq, isNull, or } from "drizzle-orm"; import { and, desc, eq, isNull, ne, or } from "drizzle-orm";
import { subscriptionStatusTable } from "@/db/schema"; import { subscriptionStatusTable } from "@/db/schema";
import { APIKeyUserType } from "@/server/APIKeyBodyRequest"; import { APIKeyUserType } from "@/server/APIKeyBodyRequest";
import { auth } from "@clerk/nextjs"; import { auth } from "@clerk/nextjs";
@@ -28,6 +28,7 @@ export async function getCurrentPlan({ user_id, org_id }: APIKeyUserType) {
isNull(subscriptionStatusTable.org_id), isNull(subscriptionStatusTable.org_id),
eq(subscriptionStatusTable.org_id, ""), eq(subscriptionStatusTable.org_id, ""),
), ),
ne(subscriptionStatusTable.status, "deleted"),
), ),
orderBy: desc(subscriptionStatusTable.created_at), orderBy: desc(subscriptionStatusTable.created_at),
}); });
+17 -15
View File
@@ -49,24 +49,26 @@ export async function getRunsData(run_id: string, user?: APIKeyUserType) {
for (let i = 0; i < data.outputs.length; i++) { for (let i = 0; i < data.outputs.length; i++) {
const output = data.outputs[i]; const output = data.outputs[i];
if (output.data?.images !== undefined) { if (output.data?.images !== undefined)
for (let j = 0; j < output.data?.images.length; j++) { replaceUrls(output.data?.images, data.id);
const element = output.data?.images[j];
element.url = replaceCDNUrl( if (output.data?.files !== undefined)
`${process.env.SPACES_ENDPOINT}/${process.env.SPACES_BUCKET}/outputs/runs/${data.id}/${element.filename}` replaceUrls(output.data?.files, data.id);
);
} if (output.data?.gifs !== undefined)
} else if (output.data?.files !== undefined) { replaceUrls(output.data?.gifs, data.id);
for (let j = 0; j < output.data?.files.length; j++) {
const element = output.data?.files[j];
element.url = replaceCDNUrl(
`${process.env.SPACES_ENDPOINT}/${process.env.SPACES_BUCKET}/outputs/runs/${data.id}/${element.filename}`
);
}
}
} }
} }
} }
return data; return data;
} }
function replaceUrls(dataType: any[], dataId: string) {
for (let j = 0; j < dataType.length; j++) {
const element = dataType[j];
element.url = replaceCDNUrl(
`${process.env.SPACES_ENDPOINT}/${process.env.SPACES_BUCKET}/outputs/runs/${dataId}/${element.filename}`,
);
}
}
-6
View File
@@ -1,16 +1,10 @@
"use server"; "use server";
import { RunOutputs } from "@/components/RunOutputs";
import { db } from "@/db/db"; import { db } from "@/db/db";
import { workflowRunOutputs } from "@/db/schema"; import { workflowRunOutputs } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
export async function getRunsOutputDisplay(run_id: string) {
return <RunOutputs run_id={run_id} />;
}
export async function getRunsOutput(run_id: string) { export async function getRunsOutput(run_id: string) {
// throw new Error("Not implemented");
return await db return await db
.select() .select()
.from(workflowRunOutputs) .from(workflowRunOutputs)
+18
View File
@@ -0,0 +1,18 @@
"use server";
import type { StringLiteralUnion } from "shikiji";
import { getHighlighter } from "shikiji";
export async function highlight(
code: string,
lang: StringLiteralUnion<string>,
) {
const highlighter = await getHighlighter({
themes: ["one-dark-pro"],
langs: [lang],
});
return highlighter.codeToHtml(code.trim(), {
lang: lang,
theme: "one-dark-pro",
});
}