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
+1 -1
View File
@@ -13,4 +13,4 @@ RUN mkdir builds
# CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "80", "--lifespan", "on"]
CMD ["python", "src/main.py"]
# If running behind a proxy like Nginx or Traefik add --proxy-headers
# CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--proxy-headers"]
# CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--proxy-headers"]
+67 -7
View File
@@ -19,7 +19,7 @@ import requests
from urllib.parse import parse_qs
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp, Scope, Receive, Send
import modal
from concurrent.futures import ThreadPoolExecutor
@@ -180,6 +180,8 @@ class Item(BaseModel):
models: List[Model]
callback_url: 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)
@field_validator('gpu')
@@ -234,6 +236,14 @@ class UploadType(str, Enum):
checkpoint = "checkpoint"
lora = "lora"
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):
download_url: str
@@ -249,8 +259,46 @@ UPLOAD_TYPE_DIR_MAP = {
UploadType.checkpoint: "checkpoints",
UploadType.lora: "loras",
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")
async def upload_model(body: UploadBody):
@@ -265,12 +313,16 @@ async def upload_model(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()
upload_path = UPLOAD_TYPE_DIR_MAP[body.upload_type]
if upload_path == "":
# TODO: deal with custom paths
pass
config = {
"volume_names": {
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,
"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:
f.write("config = " + json.dumps(config))
await asyncio.subprocess.create_subprocess_shell(
f"modal run app.py",
process = await asyncio.subprocess.create_subprocess_shell(
f"python runner.py",
cwd=folder_path,
env={**os.environ, "COLUMNS": "10000"}
)
await process.wait()
# import modal
# modal.deploy_stub(stub)
# stub["download_model"].web_url
@app.post("/create")
async def create_machine(item: Item):
@@ -391,7 +449,9 @@ async def build_logic(item: Item):
"gpu": item.gpu,
"public_model_volume": public_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:
f.write("config = " + json.dumps(config))
+232 -99
View File
@@ -9,6 +9,8 @@ from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse
from volume_setup import volumes
from datetime import datetime
import aiohttp
from aiohttp import TCPConnector
# deploy_test = False
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 = (
modal.Image.debian_slim()
modal.Image.debian_slim(
python_version="3.11.1"
)
.apt_install("git", "wget")
.pip_install(
"git+https://github.com/modal-labs/asgiproxy.git", "httpx", "tqdm"
@@ -49,12 +53,12 @@ if not deploy_test:
# Install comfyui manager
"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 && mkdir startup-scripts",
)
.run_commands(f"cat /comfyui/server.py")
.run_commands(f"ls /comfyui/app")
# .run_commands(f"cat /comfyui/server.py")
# .run_commands(f"ls /comfyui/app")
# .run_commands(
# # Install comfy deploy
# "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
COMFY_API_AVAILABLE_INTERVAL_MS = 50
# 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
COMFY_POLLING_INTERVAL_MS = 250
# Maximum number of poll attempts
@@ -91,48 +95,34 @@ COMFY_POLLING_MAX_RETRIES = 1000
COMFY_HOST = "127.0.0.1:8188"
def check_server(url, retries=50, delay=500):
import requests
import time
"""
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):
async def check_server(url, retries=50, delay=500):
import aiohttp
# for i in range(retries):
while True:
try:
response = requests.get(url)
# If the response status code is 200, the server is up and running
if response.status_code == 200:
print(f"runpod-worker-comfy - API is reachable")
return True
except requests.RequestException as e:
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 response.status == 200:
print(f"comfy-modal - API is reachable")
return True
except Exception as e:
# If an exception occurs, the server may not be ready
pass
# print(f"runpod-worker-comfy - trying")
# Wait for the specified delay before retrying
time.sleep(delay / 1000)
await asyncio.sleep(delay / 1000)
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
def check_status(prompt_id):
req = urllib.request.Request(
f"http://{COMFY_HOST}/comfyui-deploy/check-status?prompt_id={prompt_id}")
return json.loads(urllib.request.urlopen(req).read())
async def check_status(prompt_id):
async with aiohttp.ClientSession() as session:
async with session.get(f"http://{COMFY_HOST}/comfyui-deploy/check-status?prompt_id={prompt_id}") as response:
return await response.json()
class Input(BaseModel):
@@ -142,12 +132,12 @@ class Input(BaseModel):
file_upload_endpoint: str
def queue_workflow_comfy_deploy(data: Input):
async def queue_workflow_comfy_deploy(data: Input):
data_str = data.json()
data_bytes = data_str.encode('utf-8')
req = urllib.request.Request(
f"http://{COMFY_HOST}/comfyui-deploy/run", data=data_bytes)
return json.loads(urllib.request.urlopen(req).read())
async with aiohttp.ClientSession() as session:
async with session.post(f"http://{COMFY_HOST}/comfyui-deploy/run", data=data_bytes) as response:
return await response.json()
class RequestInput(BaseModel):
@@ -158,85 +148,226 @@ image = Image.debian_slim()
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:
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()
def setup(self):
async def setup(self):
import subprocess
import time
# Make sure that the ComfyUI API is available
print(f"comfy-modal - check server")
command = ["python", "main.py",
"--disable-auto-launch", "--disable-metadata"]
self.server_process = subprocess.Popen(command, cwd="/comfyui")
check_server(
f"http://{COMFY_HOST}",
COMFY_API_AVAILABLE_MAX_RETRIES,
COMFY_API_AVAILABLE_INTERVAL_MS,
self.server_process = await asyncio.subprocess.create_subprocess_shell(
f"python main.py --disable-auto-launch --disable-metadata",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd="/comfyui",
# env={**os.environ, "COLUMNS": "10000"}
)
@exit()
def cleanup(self, exc_type, exc_value, traceback):
self.server_process.terminate()
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 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()
def run(self, input: Input):
data = json.dumps({
"run_id": input.prompt_id,
"status": "started",
"time": datetime.now().isoformat()
}).encode('utf-8')
req = urllib.request.Request(input.status_endpoint, data=data, method='POST')
urllib.request.urlopen(req)
job_input = input
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:
queued_workflow = queue_workflow_comfy_deploy(job_input) # queue_workflow(workflow)
prompt_id = queued_workflow["prompt_id"]
print(f"comfy-modal - queued workflow with ID {prompt_id}")
except Exception as e:
import traceback
print(traceback.format_exc())
return {"error": f"Error queuing workflow: {str(e)}"}
class TimeoutError(Exception):
pass
# Poll for completion
print(f"comfy-modal - wait until image generation is complete")
retries = 0
status = ""
try:
print("getting request")
while retries < COMFY_POLLING_MAX_RETRIES:
status_result = check_status(prompt_id=prompt_id)
# history = get_history(prompt_id)
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)
# Exit the loop if we have found the history
# if prompt_id in history and history[prompt_id].get("outputs"):
# break
try:
signal.alarm(run_timeout)
# 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'):
status = status_result['status']
print(status)
break
else:
# Wait before trying again
time.sleep(COMFY_POLLING_INTERVAL_MS / 1000)
retries += 1
else:
return {"error": "Max retries reached while waiting for image generation"}
except Exception as e:
return {"error": f"Error waiting for image generation: {str(e)}"}
ok = await check_server(
f"http://{COMFY_HOST}",
COMFY_API_AVAILABLE_MAX_RETRIES,
COMFY_API_AVAILABLE_INTERVAL_MS,
)
print(f"comfy-modal - Finished, turning off")
if not ok:
raise Exception("ComfyUI API is not available")
# Set an alarm for some seconds in the future
result = {"status": status}
data = json.dumps({
"run_id": input.prompt_id,
"status": "started",
"time": datetime.now().isoformat()
}).encode('utf-8')
async with aiohttp.ClientSession() as session:
async with session.post(input.status_endpoint, data=data) as response:
pass
job_input = input
try:
queued_workflow = await queue_workflow_comfy_deploy(job_input) # queue_workflow(workflow)
prompt_id = queued_workflow["prompt_id"]
print(f"comfy-modal - queued workflow with ID {prompt_id}")
except Exception as e:
import traceback
print(traceback.format_exc())
return {"error": f"Error queuing workflow: {str(e)}"}
# Poll for completion
print(f"comfy-modal - wait until image generation is complete")
retries = 0
status = ""
try:
print("getting request")
# while retries < COMFY_POLLING_MAX_RETRIES:
while True:
status_result = await check_status(prompt_id=prompt_id)
if 'status' in status_result and (status_result['status'] == 'success' or status_result['status'] == 'failed'):
status = status_result['status']
print(status)
break
else:
# Wait before trying again
await asyncio.sleep(COMFY_POLLING_INTERVAL_MS / 1000)
retries += 1
else:
return {"error": "Max retries reached while waiting for image generation"}
except Exception as e:
return {"error": f"Error waiting for image generation: {str(e)}"}
print(f"comfy-modal - Finished, turning off")
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
@web_app.post("/run")
@@ -252,10 +383,12 @@ async def post_run(request_input: RequestInput):
urllib.request.urlopen(req)
model = ComfyDeployRunner()
call = model.run.spawn(request_input.input)
call = await model.run.spawn.aio(request_input.input)
print("call", call)
# call = run.spawn()
return {"call_id": call.object_id}
return {"call_id": None}
return {"call_id": None}
+3 -1
View File
@@ -4,5 +4,7 @@ config = {
"gpu": "T4",
"public_model_volume": "model-store",
"private_model_volume": "private-model-store",
"pip": []
"pip": [],
"run_timeout": 60 * 5,
"idle_timeout": 60
}
@@ -9,6 +9,8 @@ public:
loras: loras
upscale_models: upscale_models
vae: vae
ipadapter: ipadapter
private:
base_path: /private_models/
@@ -21,3 +23,4 @@ private:
loras: loras
upscale_models: upscale_models
vae: vae
ipadapter: ipadapter
@@ -2,7 +2,7 @@
"comfyui": "d0165d819afe76bd4e6bdd710eb5f3e571b6a804",
"git_custom_nodes": {
"https://github.com/BennyKok/comfyui-deploy.git": {
"hash": "a838cb7ad425e5652c3931fbafdc886b53c48a22",
"hash": "df46e3a0e5ad93fa71f5d216997e376af33b2a6d",
"disabled": false
}
},
@@ -1,10 +1,18 @@
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
import modal
from config import config
import os
import subprocess
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.
def is_valid_name(name: str) -> bool:
@@ -21,12 +29,6 @@ def create_volumes(volume_names, paths):
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)
image = (
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
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", model_store_path])
volumes[volume_base_path].commit()
@@ -56,11 +58,12 @@ def download_model(volume_name, download_config):
print(f"finished! sending to {callback_url}")
pprint({**status, **callback_body})
@stub.local_entrypoint()
@stub.function(image=image)
# @modal.asgi_app()
def simple_download():
import requests
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:
status = {"status": "failed", "error_logs": f"{str(e)}", "timeout": timeout}
requests.post(callback_url, json={**status, **callback_body})
@@ -71,4 +74,3 @@ def simple_download():
requests.post(callback_url, json={**status, **callback_body})
print(f"finished! sending to {callback_url}")
pprint({**status, **callback_body})
@@ -15,4 +15,5 @@ config = {
"folder_path": "checkpoints",
},
"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)
+65 -15
View File
@@ -26,6 +26,8 @@ import threading
api = None
api_task = None
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):
prompt_server = server.PromptServer.instance
@@ -157,7 +159,9 @@ async def websocket_handler(request):
try:
# Send initial state to the new client
await send("status", { 'sid': sid }, sid)
await send_first_time_log(sid)
if cd_enable_log:
await send_first_time_log(sid)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.ERROR:
@@ -236,7 +240,12 @@ class Status(Enum):
FAILED = "failed"
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):
global last_read_line_number
if prompt_id not in prompt_metadata:
return
@@ -251,16 +260,50 @@ def update_run(prompt_id, status: Status):
"run_id": prompt_id,
"status": status.value,
}
prompt_metadata[prompt_id]['status'] = status
print(f"Status: {status.value}")
try:
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:
error_type = type(e).__name__
stack_trace = traceback.format_exc().strip()
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"):
"""
@@ -387,20 +430,25 @@ async def update_file_status(prompt_id, data, uploading, have_error=False, node_
"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
async def upload_in_background(prompt_id, data, node_id=None, have_upload=True):
try:
images = data.get('images', [])
for image in images:
await upload_file(prompt_id, image.get("filename"), subfolder=image.get("subfolder"), type=image.get("type"), content_type=image.get("content_type", "image/png"))
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"))
await handle_upload(prompt_id, data, 'images', "content_type", "image/png")
await handle_upload(prompt_id, data, 'files', "content_type", "image/png")
# This will also be mp4
await handle_upload(prompt_id, data, 'gifs', "format", "image/gif")
if have_upload:
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__))
two_dirs_up = os.path.dirname(os.path.dirname(root_path))
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
@@ -480,4 +529,5 @@ def run_in_new_thread(coroutine):
t.start()
asyncio.run_coroutine_threadsafe(coroutine, new_loop)
run_in_new_thread(watch_file_changes(log_file_path, send_logs_to_websocket))
if cd_enable_log:
run_in_new_thread(watch_file_changes(log_file_path, send_logs_to_websocket))
+43 -32
View File
@@ -7,45 +7,56 @@ import threading
import logging
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler('comfy-deploy.log', maxBytes=500000, backupCount=5)
# Running with export CD_ENABLE_LOG=true; python main.py
original_stdout = sys.stdout
original_stderr = sys.stderr
# 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'
class StreamToLogger():
def __init__(self, log_level):
self.log_level = log_level
def setup():
handler = RotatingFileHandler('comfy-deploy.log', maxBytes=500000, backupCount=5)
def write(self, buf):
if (self.log_level == logging.INFO):
original_stdout.write(buf)
original_stdout.flush()
elif (self.log_level == logging.ERROR):
original_stderr.write(buf)
original_stderr.flush()
original_stdout = sys.stdout
original_stderr = sys.stderr
for line in buf.rstrip().splitlines():
handler.handle(
logging.LogRecord(
name="comfy-deploy",
level=self.log_level,
pathname="prestartup_script.py",
lineno=1,
msg=line.rstrip(),
args=None,
exc_info=None
class StreamToLogger():
def __init__(self, log_level):
self.log_level = log_level
def write(self, buf):
if (self.log_level == logging.INFO):
original_stdout.write(buf)
original_stdout.flush()
elif (self.log_level == logging.ERROR):
original_stderr.write(buf)
original_stderr.flush()
for line in buf.rstrip().splitlines():
handler.handle(
logging.LogRecord(
name="comfy-deploy",
level=self.log_level,
pathname="prestartup_script.py",
lineno=1,
msg=line.rstrip(),
args=None,
exc_info=None
)
)
)
def flush(self):
if (self.log_level == logging.INFO):
original_stdout.flush()
elif (self.log_level == logging.ERROR):
original_stderr.flush()
def flush(self):
if (self.log_level == logging.INFO):
original_stdout.flush()
elif (self.log_level == logging.ERROR):
original_stderr.flush()
# Redirect stdout and stderr to the logger
sys.stdout = StreamToLogger(logging.INFO)
sys.stderr = StreamToLogger(logging.ERROR)
# Redirect stdout and stderr to the logger
sys.stdout = StreamToLogger(logging.INFO)
sys.stderr = StreamToLogger(logging.ERROR)
if cd_enable_log:
print("** Comfy Deploy logging enabled")
setup()
try:
# Get the absolute path of the script's directory
+21 -7
View File
@@ -50,11 +50,22 @@ const ext = {
})
.then(async (res) => {
const data = await res.json();
const { workflow, error } = data;
const { workflow, workflow_id, error } = data;
if (error) {
infoDialog.showMessage("Unable to load this workflow", error);
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} */
app.loadGraphData(workflow);
})
@@ -682,16 +693,19 @@ export class ConfigDialog extends ComfyDialog {
</label>
<label style="color: white; width: 100%;">
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 style="color: white;">
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;">
${data.apiKey ? "Re-login with ComfyDeploy" : "Login with ComfyDeploy"
}
${
data.apiKey ? "Re-login with ComfyDeploy" : "Login with ComfyDeploy"
}
</button>
</label>
</div>
+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
+15 -1
View File
@@ -337,6 +337,20 @@
"when": 1706384528895,
"tag": "0047_gifted_starbolt",
"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
}
]
}
}
+36 -15
View File
@@ -2,10 +2,8 @@ import { parseDataSafe } from "../../../../lib/parseDataSafe";
import { db } from "@/db/db";
import {
WorkflowRunStatusSchema,
userUsageTable,
workflowRunOutputs,
workflowRunsTable,
workflowTable,
} from "@/db/schema";
import { getCurrentPlan } from "@/server/getCurrentPlan";
import { stripe } from "@/server/stripe";
@@ -18,6 +16,7 @@ const Request = z.object({
status: WorkflowRunStatusSchema.optional(),
time: z.coerce.date().optional(),
output_data: z.any().optional(),
log_data: z.any().optional(),
});
export async function POST(request: Request) {
@@ -26,7 +25,26 @@ export async function POST(request: Request) {
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) {
// It successfully started, update the started_at time
@@ -48,6 +66,9 @@ export async function POST(request: Request) {
.where(eq(workflowRunsTable.id, run_id));
}
const ended =
status === "success" || status === "failed" || status === "timeout";
if (output_data) {
const workflow_run_output = await db.insert(workflowRunOutputs).values({
run_id: run_id,
@@ -58,8 +79,7 @@ export async function POST(request: Request) {
.update(workflowRunsTable)
.set({
status: status,
ended_at:
status === "success" || status === "failed" ? new Date() : null,
ended_at: ended ? new Date() : null,
})
.where(eq(workflowRunsTable.id, run_id))
.returning();
@@ -67,10 +87,7 @@ export async function POST(request: Request) {
// Need to filter out only comfy deploy serverless
// Also multiply with the gpu selection
if (workflow_run.machine_type == "comfy-deploy-serverless") {
if (
(status === "success" || status === "failed") &&
workflow_run.user_id
) {
if (ended && workflow_run.user_id) {
const sub = await getCurrentPlan({
user_id: workflow_run.user_id,
org_id: workflow_run.org_id,
@@ -91,12 +108,16 @@ export async function POST(request: Request) {
durationInSec *= 4;
break;
}
await stripe.subscriptionItems.createUsageRecord(
sub.subscription_item_api_id,
{
quantity: durationInSec,
},
);
try {
await stripe.subscriptionItems.createUsageRecord(
sub.subscription_item_api_id,
{
quantity: durationInSec,
},
);
} catch (error) {
console.log(error);
}
}
}
}
@@ -35,7 +35,10 @@ export default async function Page({
/>
)}
{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>
</Card>
+13 -5
View File
@@ -16,11 +16,19 @@ const freeTierSeconds = 30000;
export default async function Home() {
const sub = await getCurrentPlanWithAuth();
const data = sub?.subscription_item_api_id
? await stripe.subscriptionItems.listUsageRecordSummaries(
sub?.subscription_item_api_id,
)
: null;
let data: Awaited<
ReturnType<typeof stripe.subscriptionItems.listUsageRecordSummaries>
> | null = null;
try {
data = sub?.subscription_item_api_id
? await stripe.subscriptionItems.listUsageRecordSummaries(
sub?.subscription_item_api_id,
)
: null;
} catch (e) {
console.error(e);
}
return (
<div className="mt-4 flex items-center justify-center">
@@ -1,5 +1,5 @@
import { LoadingWrapper } from "@/components/LoadingWrapper";
import { DeploymentsTable } from "@/components/RunsTable";
import { DeploymentsTable } from "@/components/DeploymentsTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default async function Page({
@@ -13,12 +13,12 @@ export default async function Page({
const workflow_id = params.workflow_id;
return (
<Card className="w-full h-fit min-w-0">
<CardHeader className="relative">
<Card className="w-full h-fit min-w-0 relative">
<CardHeader>
<CardTitle>Run</CardTitle>
<div className="absolute right-6 top-6">
{/* <div className="absolute right-6 top-6">
<RouteRefresher interval={5000} autoRefresh={false} />
</div>
</div> */}
</CardHeader>
<CardContent>
@@ -8,8 +8,8 @@ import {
OpenEditButton,
RunWorkflowButton,
VersionSelect,
ViewWorkflowDetailsButton,
} from "@/components/VersionSelect";
import { ViewWorkflowDetailsButton } from "@/components/ViewWorkflowDetailsButton";
import {
Card,
CardContent,
-4
View File
@@ -13,10 +13,7 @@ export async function CodeBlock(props: {
return (
<div className="relative w-full text-sm">
{/* max-w-[calc(32rem-1.5rem-1.5rem)] */}
{/* <div className=""> */}
<p
// tabIndex={1}
className="[&>pre]:p-4 rounded-lg max-h-96 overflow-auto w-full"
style={{
overflowWrap: "break-word",
@@ -28,7 +25,6 @@ export async function CodeBlock(props: {
}),
}}
/>
{/* </div> */}
<CopyButton className="absolute right-2 top-2" text={props.code} />
</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({
className,
children,
...props
}: {
text: string;
className?: string;
children?: React.ReactNode;
}) {
return (
<Button
@@ -21,7 +23,7 @@ export function CopyButton({
}}
className={cn(" p-2 min-h-0 aspect-square", className)}
>
<Copy size={14} />
{children} <Copy size={14} />
</Button>
);
}
+18 -20
View File
@@ -69,13 +69,16 @@ const client = new ComfyDeployClient({
`;
const jsClientCreateRunTemplate = `
const { run_id } = await client.run("<ID>", {
const { run_id } = await client.run({
deployment_id: "<ID>",
inputs: {}
});
`;
const jsClientCreateRunNoInputsTemplate = `
const { run_id } = await client.run("<ID>");
const { run_id } = await client.run({
deployment_id: "<ID>"
});
`;
const clientTemplate_checkStatus = `
@@ -119,10 +122,7 @@ export function DeploymentDisplay({
<TabsContent className="flex flex-col gap-2 !mt-0" value="client">
<div>
Install the node comfydeploy SDK
<CodeBlock
lang="bash"
code={`npm i comfydeploy`}
/>
<CodeBlock lang="bash" code={`npm i comfydeploy`} />
Initialize your client
</div>
<CodeBlock
@@ -197,20 +197,18 @@ function formatCode(
if (inputs && inputs.length > 0) {
codeTemplate = codeTemplate.replace(
"inputs: {}",
`inputs: ${
JSON.stringify(
Object.fromEntries(
inputs.map((x) => {
return [x?.input_id, ""];
}),
),
null,
2,
)
.split("\n")
.map((line, index) => (index === 0 ? line : ` ${line}`)) // Add two spaces indentation except for the first line
.join("\n")
}`,
`inputs: ${JSON.stringify(
Object.fromEntries(
inputs.map((x) => {
return [x?.input_id, ""];
}),
),
null,
2,
)
.split("\n")
.map((line, index) => (index === 0 ? line : ` ${line}`)) // Add two spaces indentation except for the first line
.join("\n")}`,
);
} else {
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>
);
}
+139 -100
View File
@@ -23,6 +23,33 @@ import * as React from "react";
import { useState } from "react";
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<
K extends ZodRawShape,
Y extends UnknownKeysParam,
@@ -41,65 +68,73 @@ export function InsertModal<
const [open, setOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const [values, setValues] = useState<Partial<z.infer<Z>>>({});
return (
<Dialog open={open} onOpenChange={setOpen}>
{/* <DialogTrigger disabled={props.disabled}> */}
{props.tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className={props.disabled ? "opacity-50" : ""}
onClick={() => {
if (props.disabled) return;
setOpen(true);
}}
>
{props.buttonTitle ?? props.title}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{props.tooltip}</p>
</TooltipContent>
</Tooltip>
) : (
<Button
variant="default"
disabled={props.disabled}
onClick={() => {
setOpen(true);
}}
<AutoFormValueProvider value={[values, setValues]}>
<Dialog open={open} onOpenChange={setOpen}>
{/* <DialogTrigger disabled={props.disabled}> */}
{props.tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
className={props.disabled ? "opacity-50" : ""}
onClick={() => {
if (props.disabled) return;
setOpen(true);
}}
>
{props.buttonTitle ?? props.title}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{props.tooltip}</p>
</TooltipContent>
</Tooltip>
) : (
<Button
variant="default"
disabled={props.disabled}
onClick={() => {
setOpen(true);
}}
>
{props.title}
</Button>
)}
{/* </DialogTrigger> */}
<DialogContent
className={cn("sm:max-w-[425px]", props.dialogClassName)}
>
{props.title}
</Button>
)}
{/* </DialogTrigger> */}
<DialogContent className={cn("sm:max-w-[425px]", props.dialogClassName)}>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
<DialogDescription>{props.description}</DialogDescription>
</DialogHeader>
{/* <ScrollArea> */}
<AutoForm
fieldConfig={props.fieldConfig}
formSchema={props.formSchema}
onSubmit={async (data) => {
setIsLoading(true);
await callServerPromise(props.serverAction(data));
setIsLoading(false);
setOpen(false);
}}
>
<div className="flex justify-end">
<AutoFormSubmit>
Save Changes
{isLoading && <LoadingIcon />}
</AutoFormSubmit>
</div>
</AutoForm>
{/* </ScrollArea> */}
</DialogContent>
</Dialog>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
<DialogDescription>{props.description}</DialogDescription>
</DialogHeader>
{/* <ScrollArea> */}
<AutoForm
values={values}
onValuesChange={setValues}
fieldConfig={props.fieldConfig}
formSchema={props.formSchema}
onSubmit={async (data) => {
setIsLoading(true);
await callServerPromise(props.serverAction(data));
setIsLoading(false);
setOpen(false);
}}
>
<div className="flex justify-end">
<AutoFormSubmit>
Save Changes
{isLoading && <LoadingIcon />}
</AutoFormSubmit>
</div>
</AutoForm>
{/* </ScrollArea> */}
</DialogContent>
</Dialog>
</AutoFormValueProvider>
);
}
@@ -138,49 +173,53 @@ export function UpdateModal<
}, [props.data]);
return (
<Dialog open={open} onOpenChange={setOpen}>
{props.trigger ?? (
<DialogTrigger
className="appearance-none hover:cursor-pointer"
asChild
onClick={() => {
setOpen(true);
}}
<AutoFormValueProvider value={[values, setValues]}>
<Dialog open={open} onOpenChange={setOpen}>
{props.trigger ?? (
<DialogTrigger
className="appearance-none hover:cursor-pointer"
asChild
onClick={() => {
setOpen(true);
}}
>
{props.trigger}
</DialogTrigger>
)}
<DialogContent
className={cn("sm:max-w-[425px]", props.dialogClassName)}
>
{props.trigger}
</DialogTrigger>
)}
<DialogContent className={cn("sm:max-w-[425px]", props.dialogClassName)}>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
<DialogDescription>{props.description}</DialogDescription>
</DialogHeader>
<AutoForm
values={values}
onValuesChange={setValues}
fieldConfig={props.fieldConfig}
formSchema={props.formSchema}
onSubmit={async (data) => {
setIsLoading(true);
await callServerPromise(
props.serverAction({
...data,
id: props.data.id,
}),
);
setIsLoading(false);
setOpen(false);
}}
>
<div className="flex justify-end flex-wrap gap-2">
{props.extraButtons}
<AutoFormSubmit>
Save Changes
{isLoading && <LoadingIcon />}
</AutoFormSubmit>
</div>
</AutoForm>
</DialogContent>
</Dialog>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
<DialogDescription>{props.description}</DialogDescription>
</DialogHeader>
<AutoForm
values={values}
onValuesChange={setValues}
fieldConfig={props.fieldConfig}
formSchema={props.formSchema}
onSubmit={async (data) => {
setIsLoading(true);
await callServerPromise(
props.serverAction({
...data,
id: props.data.id,
}),
);
setIsLoading(false);
setOpen(false);
}}
>
<div className="flex justify-end flex-wrap gap-2">
{props.extraButtons}
<AutoFormSubmit>
Save Changes
{isLoading && <LoadingIcon />}
</AutoFormSubmit>
</div>
</AutoForm>
</DialogContent>
</Dialog>
</AutoFormValueProvider>
);
}
+4 -2
View File
@@ -16,7 +16,7 @@ export function LiveStatus({
(state) =>
state.data
.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;
@@ -51,7 +51,9 @@ export function LiveStatus({
<>
<TableCell>
{data && status != "success"
? `${data.json.event} - ${data.json.data.node}`
? `${data.json.event}${
data.json.data.node ? " - " + data.json.data.node : ""
}`
: "-"}
</TableCell>
<TableCell className="truncate text-right">
+41 -5
View File
@@ -1,17 +1,31 @@
"use client";
import React, { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
export type LogsType = {
machine_id?: 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);
useEffect(() => {
if (!stickToBottom) return;
// console.log(logs.length, container.current);
if (container.current) {
const scrollHeight = container.current.scrollHeight;
@@ -21,11 +35,12 @@ export function LogsViewer({ logs }: { logs: LogsType }) {
behavior: "smooth",
});
}
}, [logs.length]);
}, [logs.length, stickToBottom]);
return (
<div
ref={(ref) => {
if (!stickToBottom) return;
if (!container.current && ref) {
const scrollHeight = ref.scrollHeight;
@@ -36,10 +51,31 @@ export function LogsViewer({ logs }: { logs: LogsType }) {
}
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) => (
<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>
);
+54 -43
View File
@@ -16,7 +16,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
} from "@/components/ui/alert-dialog";
import { useRouter } from "next/navigation";
export function MachineBuildLog({
@@ -41,7 +41,7 @@ export function MachineBuildLog({
reconnectAttempts: 20,
reconnectInterval: 1000,
queryParams: query,
}
},
);
const connectionStatus = getConnectionStatus(readyState);
@@ -57,59 +57,70 @@ export function MachineBuildLog({
setLogs((logs) => [...(logs ?? []), message.data]);
} else if (message?.event === "FINISHED") {
setFinished(true);
setStatus(message.data.status)
setStatus(message.data.status);
}
}, [lastMessage]);
const router = useRouter()
const router = useRouter();
return (
<div>
{connectionStatus}
<LogsViewer logs={logs} />
<LogsViewer logs={logs} className="h-full max-h-[600px]" />
<AlertDialog open={finished}>
<AlertDialogContent>
{
status == "succuss" ? (
<>
<AlertDialogHeader>
<AlertDialogTitle>Machine Built</AlertDialogTitle>
<AlertDialogDescription>
Your machine is built, you can now integrate your API, or directly run to check this machines.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => {
router.push("/workflows")
}}>See Workflows</AlertDialogAction>
<AlertDialogAction onClick={() => {
router.push("/machines")
}}>See All Machines</AlertDialogAction>
</AlertDialogFooter></>
) : (
<>
<AlertDialogHeader>
<AlertDialogTitle>Machine Failed</AlertDialogTitle>
<AlertDialogDescription>
Something went wrong with the machine build, please check the log.
Possible cause could be conflits with custom nodes, build got stuck, timeout, or too many custom nodes installed.
Please attempt a rebuild or remove some of the custom nodes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>See logs</AlertDialogCancel>
<AlertDialogAction onClick={() => {
router.push("/machines")
}}>Back to machines</AlertDialogAction>
</AlertDialogFooter></>
)
}
{status == "succuss" ? (
<>
<AlertDialogHeader>
<AlertDialogTitle>Machine Built</AlertDialogTitle>
<AlertDialogDescription>
Your machine is built, you can now integrate your API, or
directly run to check this machines.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction
onClick={() => {
router.push("/workflows");
}}
>
See Workflows
</AlertDialogAction>
<AlertDialogAction
onClick={() => {
router.push("/machines");
}}
>
See All Machines
</AlertDialogAction>
</AlertDialogFooter>
</>
) : (
<>
<AlertDialogHeader>
<AlertDialogTitle>Machine Failed</AlertDialogTitle>
<AlertDialogDescription>
Something went wrong with the machine build, please check the
log. Possible cause could be conflits with custom nodes, build
got stuck, timeout, or too many custom nodes installed. Please
attempt a rebuild or remove some of the custom nodes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>See logs</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
router.push("/machines");
}}
>
Back to machines
</AlertDialogAction>
</AlertDialogFooter>
</>
)}
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+6 -4
View File
@@ -36,7 +36,7 @@ type State = {
json: {
event: string;
data: any;
}
},
) => void;
};
@@ -82,7 +82,7 @@ function MachineWS({
const logs = useStore((x) =>
x.logs
.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("");
@@ -96,7 +96,7 @@ function MachineWS({
// queryParams: {
// clientId: sid,
// },
}
},
);
const connectionStatus = getConnectionStatus(readyState);
@@ -135,7 +135,9 @@ function MachineWS({
You can view your run&apos;s outputs here
</DialogDescription>
</DialogHeader>
<LogsViewer logs={logs} />
<div className="h-[400px]">
<LogsViewer logs={logs} hideTimestamp />
</div>
</DialogContent>
</Dialog>
);
+45 -32
View File
@@ -7,6 +7,13 @@ import { Checkbox } from "@/components/ui/checkbox";
import { InsertModal } from "./InsertModal";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
@@ -30,9 +37,9 @@ import {
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ArrowUpDown } from "lucide-react";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { addModel } from "@/server/curdModel";
import { addModel, deleteModel } from "@/server/curdModel";
import { downloadUrlModelSchema } from "@/server/addCivitaiModelSchema";
import { modelEnumType } from "@/db/schema";
@@ -192,10 +199,16 @@ export const columns: ColumnDef<ModelItemList>[] = [
lora: "green",
embedding: "violet",
vae: "teal",
clip: "default",
clip_vision: "default",
configs: "default",
controlnet: "default",
upscale_models: "default",
ipadapter: "default",
};
function getBadgeColor(modelType: modelEnumType) {
return model_type_map[modelType] || "default";
return model_type_map[modelType]
}
const color = getBadgeColor(row.original.model_type);
@@ -225,35 +238,35 @@ export const columns: ColumnDef<ModelItemList>[] = [
),
},
// TODO: deletion and editing for future sprint
// {
// id: "actions",
// enableHiding: false,
// cell: ({ row }) => {
// const checkpoint = row.original;
//
// return (
// <DropdownMenu>
// <DropdownMenuTrigger asChild>
// <Button variant="ghost" className="h-8 w-8 p-0">
// <span className="sr-only">Open menu</span>
// <MoreHorizontal className="h-4 w-4" />
// </Button>
// </DropdownMenuTrigger>
// <DropdownMenuContent align="end">
// <DropdownMenuLabel>Actions</DropdownMenuLabel>
// <DropdownMenuItem
// className="text-destructive"
// onClick={() => {
// deleteWorkflow(checkpoint.id);
// }}
// >
// Delete Workflow
// </DropdownMenuItem>
// </DropdownMenuContent>
// </DropdownMenu>
// );
// },
// },
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const model = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
deleteModel(model.id);
}}
>
Delete Model
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
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 { getFileDownloadUrl } from "@/server/getFileDownloadUrl";
export async function OutputRender(props: {
export function OutputRender(props: {
run_id: 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 (
props.filename.endsWith(".png") ||
props.filename.endsWith(".gif") ||
props.filename.endsWith(".jpg") ||
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} />;
} else {
const url = await getFileDownloadUrl(
`outputs/runs/${props.run_id}/${props.filename}`
);
// console.log(url);
return <DownloadButton filename={props.filename} href={url} />;
}
}
+7 -5
View File
@@ -1,3 +1,5 @@
"use client";
import { RunInputs } from "@/components/RunInputs";
import { RunOutputs } from "@/components/RunOutputs";
import { Badge } from "@/components/ui/badge";
@@ -17,10 +19,10 @@ import {
} from "@/components/ui/tooltip";
import { getDuration, getRelativeTime } from "@/lib/getRelativeTime";
import { type findAllRuns } from "@/server/findAllRuns";
import { Suspense } from "react";
import { LiveStatus } from "./LiveStatus";
import { LoadingWrapper } from "@/components/LoadingWrapper";
export async function RunDisplay({
export function RunDisplay({
run,
}: {
run: Awaited<ReturnType<typeof findAllRuns>>[0];
@@ -73,9 +75,9 @@ export async function RunDisplay({
</DialogHeader>
<div className="max-h-96 overflow-y-scroll">
<RunInputs run={run} />
<Suspense>
<RunOutputs run_id={run.id} />
</Suspense>
<LoadingWrapper tag="output">
<RunOutputs run={run} />
</LoadingWrapper>
</div>
{/* <div className="max-h-96 overflow-y-scroll">{view}</div> */}
</DialogContent>
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from "@/components/ui/table";
import type { findAllRuns } from "@/server/findAllRuns";
export async function RunInputs({
export function RunInputs({
run,
}: {
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 { CodeBlock } from "@/components/CodeBlock";
import {
Table,
TableBody,
@@ -8,10 +19,25 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { findAllRuns } from "@/server/findAllRuns";
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 (
<Table className="table-fixed">
<TableHeader className="bg-background top-0 sticky">
@@ -21,7 +47,43 @@ export async function RunOutputs({ run_id }: { run_id: string }) {
</TableRow>
</TableHeader>
<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 =
run.data.images?.[0].filename ||
run.data.files?.[0].filename ||
@@ -32,7 +94,7 @@ export async function RunOutputs({ run_id }: { run_id: string }) {
<TableRow key={run.id}>
<TableCell>Output</TableCell>
<TableCell className="">
<CodeBlock
<CodeBlockClient
code={JSON.stringify(run.data, null, 2)}
lang="json"
/>
@@ -45,7 +107,7 @@ export async function RunOutputs({ run_id }: { run_id: string }) {
<TableRow key={run.id}>
<TableCell className="break-words">{fileName}</TableCell>
<TableCell>
<OutputRender run_id={run_id} filename={fileName} />
<OutputRender run_id={run.run_id} filename={fileName} />
</TableCell>
</TableRow>
);
+61 -74
View File
@@ -1,3 +1,5 @@
"use client";
import {
Table,
TableBody,
@@ -7,94 +9,79 @@ import {
TableRow,
} from "@/components/ui/table";
import { parseAsInteger } from "next-usequerystate";
import { headers } from "next/headers";
import {
findAllDeployments,
findAllRunsWithCounts,
findAllRunsWithCounts,
getAllRunstableContent,
} from "../server/findAllRuns";
import { DeploymentDisplay } from "./DeploymentDisplay";
import { PaginationControl } from "./PaginationControl";
import { RunDisplay } from "./RunDisplay";
import useSWR from "swr";
import { LoadingIcon } from "@/components/LoadingIcon";
const itemPerPage = 6;
const pageParser = parseAsInteger.withDefault(1);
export async function RunsTable(props: {
export function RunsTable(props: {
workflow_id: string;
searchParams: { [key: string]: string | string[] | undefined };
searchParams: { [key: string]: any };
}) {
// await new Promise((resolve) => setTimeout(resolve, 5000));
const page = pageParser.parseServerSide(
props.searchParams?.page ?? undefined
const page = pageParser.parse(props.searchParams?.page ?? undefined) ?? 1;
const { data, error, isLoading, isValidating } = useSWR(
"runs+" + page,
async () => {
const data = await findAllRunsWithCounts({
workflow_id: props.workflow_id,
limit: itemPerPage,
offset: (page - 1) * itemPerPage,
});
return data;
},
{
// suspense: false,
refreshInterval: 5000,
},
);
const { allRuns, total } = await findAllRunsWithCounts({
workflow_id: props.workflow_id,
limit: itemPerPage,
offset: (page - 1) * itemPerPage,
});
return (
<div>
<div className="overflow-auto h-fit w-full">
<Table className="">
{allRuns.length === 0 && (
<TableCaption>A list of your recent runs.</TableCaption>
)}
<TableHeader className="bg-background top-0 sticky">
<TableRow>
<TableHead className="truncate">Number</TableHead>
<TableHead className="truncate">Machine</TableHead>
<TableHead className="truncate">Time</TableHead>
<TableHead className="truncate">Version</TableHead>
<TableHead className="truncate">Origin</TableHead>
<TableHead className="truncate">Duration</TableHead>
<TableHead className="truncate">Live Status</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allRuns.map((run) => (
<RunDisplay run={run} key={run.id} />
))}
</TableBody>
</Table>
</div>
{Math.ceil(total / itemPerPage) > 0 && (
<PaginationControl
totalPage={Math.ceil(total / itemPerPage)}
currentPage={page}
/>
)}
</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}`;
// await new Promise((resolve) => setTimeout(resolve, 5000));
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>
{isValidating ? (
<div className="absolute right-8 top-8">
<LoadingIcon />
</div>
) : null}
<div className="overflow-auto h-fit w-full">
<Table className="">
{/* {data?.allRuns.length === 0 && (
<TableCaption>A list of your recent runs.</TableCaption>
)} */}
<TableHeader className="bg-background top-0 sticky">
<TableRow>
<TableHead className="truncate">Number</TableHead>
<TableHead className="truncate">Machine</TableHead>
<TableHead className="truncate">Time</TableHead>
<TableHead className="truncate">Version</TableHead>
<TableHead className="truncate">Origin</TableHead>
<TableHead className="truncate">Duration</TableHead>
<TableHead className="truncate">Live Status</TableHead>
<TableHead className="text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.allRuns.map((run) => (
<RunDisplay run={run} key={run.id} />
))}
</TableBody>
</Table>
</div>
{data && Math.ceil(data.total / itemPerPage) > 0 && (
<PaginationControl
totalPage={Math.ceil(data.total / itemPerPage)}
currentPage={page}
/>
)}
</div>
);
}
+2
View File
@@ -17,6 +17,8 @@ export function StatusBadge({
);
case "success":
return <Badge variant="success">{status}</Badge>;
case "timeout":
return <Badge variant="amber">{status}</Badge>;
case "failed":
return <Badge variant="destructive">{status}</Badge>;
}
+38 -184
View File
@@ -2,7 +2,6 @@
import { LoadingIcon } from "@/components/LoadingIcon";
import AutoForm, { AutoFormSubmit } from "@/components/ui/auto-form";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -28,36 +27,19 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { showcaseMediaNullable, workflowAPINodeType } from "@/db/schema";
import type { showcaseMediaNullable } from "@/db/schema";
import { checkStatus, createRun } from "@/server/createRun";
import { createDeployments } from "@/server/curdDeploments";
import type { getMachines } from "@/server/curdMachine";
import type { findFirstTableWithVersion } from "@/server/findFirstTableWithVersion";
import {
Copy,
Edit,
ExternalLink,
Info,
MoreVertical,
Play,
} from "lucide-react";
import { Copy, Edit, MoreVertical, Play } from "lucide-react";
import { parseAsInteger, useQueryState } from "next-usequerystate";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import type { z } from "zod";
import { create } from "zustand";
import { workflowVersionInputsToZod } from "../lib/workflowVersionInputsToZod";
import { callServerPromise } from "./callServerPromise";
import fetcher from "./fetcher";
import { ButtonAction } from "@/components/ButtonActionLoader";
import { editWorkflowOnMachine } from "@/server/editWorkflowOnMachine";
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(
machines: Awaited<ReturnType<typeof getMachines>>,
): [string, (v: string) => void] {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const { selectedMachine, setSelectedMachine } = selectedMachineStore();
return [selectedMachine ?? machines?.[0]?.id ?? "", setSelectedMachine];
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(name, value);
// const searchParams = useSearchParams();
// const pathname = usePathname();
// const router = useRouter();
return params.toString();
},
[searchParams],
);
// const createQueryString = useCallback(
// (name: string, value: string) => {
// const params = new URLSearchParams(searchParams.toString());
// params.set(name, value);
return [
searchParams.get("machine") ?? machines?.[0]?.id ?? "",
(v: string) => {
router.push(pathname + "?" + createQueryString("machine", v));
},
];
// return params.toString();
// },
// [searchParams],
// );
// 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 = {
@@ -500,150 +501,3 @@ export function getWorkflowVersionFromVersionIndex(
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,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { z } from "zod";
import { getBranchInfo } from "./getBranchInfo";
export function SnapshotPickerView({
field,
@@ -131,7 +130,7 @@ export function SnapshotPickerView({
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
disabled={key.endsWith("comfyui-deploy.git")}
disabled={key.endsWith("comfyui-deploy")}
// className="opacity-50"
onClick={() => {
const newNodeList = {
@@ -148,6 +147,29 @@ export function SnapshotPickerView({
>
Delete
</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>
</DropdownMenu>
</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({
field,
}: Pick<AutoFormInputComponentProps, "field">) {
@@ -362,43 +366,7 @@ function CustomNodesSelector({
delete newNodeList[currentValue];
nodeList = newNodeList;
} else {
const repoName = extractRepoName(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);
const branchInfo = await getBranchInfo(currentValue);
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;
}
+18 -7
View File
@@ -1,3 +1,4 @@
import { LogsType } from "@/components/LogsViewer";
import { CivitaiModelResponse } from "@/types/civitai";
import { type InferSelectModel, relations } from "drizzle-orm";
import {
@@ -6,10 +7,10 @@ import {
jsonb,
pgEnum,
pgSchema,
real,
text,
timestamp,
uuid,
real,
} from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { TypeOf, z } from "zod";
@@ -104,6 +105,7 @@ export const workflowRunStatus = pgEnum("workflow_run_status", [
"failed",
"started",
"queued",
"timeout",
]);
export const deploymentEnvironment = pgEnum("deployment_environment", [
@@ -148,8 +150,9 @@ export const workflowRunsTable = dbSchema.table("workflow_runs", {
onDelete: "set null",
},
),
workflow_inputs:
jsonb("workflow_inputs").$type<Record<string, string | number>>(),
workflow_inputs: jsonb("workflow_inputs").$type<
Record<string, string | number>
>(),
workflow_id: uuid("workflow_id")
.notNull()
.references(() => workflowTable.id, {
@@ -172,6 +175,7 @@ export const workflowRunsTable = dbSchema.table("workflow_runs", {
machine_type: machinesType("machine_type"),
user_id: text("user_id"),
org_id: text("org_id"),
run_log: jsonb("run_log").$type<LogsType>(),
});
export const workflowRunRelations = relations(
@@ -295,8 +299,9 @@ export const deploymentsTable = dbSchema.table("deployments", {
.references(() => machinesTable.id),
share_slug: text("share_slug").unique(),
description: text("description"),
showcase_media:
jsonb("showcase_media").$type<z.infer<typeof showcaseMedia>>(),
showcase_media: jsonb("showcase_media").$type<
z.infer<typeof showcaseMedia>
>(),
environment: deploymentEnvironment("environment").notNull(),
created_at: timestamp("created_at").defaultNow().notNull(),
updated_at: timestamp("updated_at").defaultNow().notNull(),
@@ -385,13 +390,19 @@ export const modelUploadType = pgEnum("model_upload_type", [
"other",
]);
// https://www.answeroverflow.com/m/1125106227387584552
// https://www.answeroverflow.com/m/1125106227387584552
export const modelTypes = [
"checkpoint",
"lora",
"embedding",
"vae",
] as const
"clip",
"clip_vision",
"configs",
"controlnet",
"upscale_models",
"ipadapter",
] as const;
export const modelType = pgEnum("model_type", modelTypes);
export type modelEnumType = (typeof modelTypes)[number];
+12 -2
View File
@@ -31,6 +31,16 @@ const getOutputRoute = createRoute({
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,
message: "Workflow not found",
},
400
400,
);
return c.json(run, 200);
@@ -94,7 +104,7 @@ export const registerGetOutputRoute = (app: App) => {
},
{
status: 500,
}
},
);
}
});
+3 -15
View File
@@ -16,26 +16,14 @@ export const insertCustomMachineSchema = createInsertSchema(machinesTable, {
schema.snapshot.default({
comfyui: "d0165d819afe76bd4e6bdd710eb5f3e571b6a804",
git_custom_nodes: {
"https://github.com/BennyKok/comfyui-deploy.git": {
hash: "a838cb7ad425e5652c3931fbafdc886b53c48a22",
"https://github.com/bennykok/comfyui-deploy": {
hash: "df46e3a0e5ad93fa71f5d216997e376af33b2a6d",
disabled: false,
},
},
file_custom_nodes: [],
}),
models: (schema) =>
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",
},
]),
models: (schema) => schema.models.default([]),
});
export const addCustomMachineSchema = insertCustomMachineSchema.pick({
+45 -2
View File
@@ -11,6 +11,7 @@ import {
import { withServerPromise } from "./withServerPromise";
import { db } from "@/db/db";
import type { z } from "zod";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { downloadUrlModelSchema } from "./addCivitaiModelSchema";
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) => {
const { url, modelVersionId } = getUrl(civitaiUrl);
const civitaiModelRes = await fetch(url)
@@ -301,8 +343,8 @@ export const addCivitaiModel = withServerPromise(
model_name: selectedModelVersion.files[0].name,
civitai_id: civitaiModelRes.id.toString(),
civitai_version_id: selectedModelVersionId,
civitai_url: data.url, // TODO: need to confirm
civitai_download_url: selectedModelVersion.files[0].downloadUrl,
civitai_url: data.url,
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,
user_volume_id: volumes[0].id,
model_type,
@@ -312,6 +354,7 @@ export const addCivitaiModel = withServerPromise(
const b = a[0];
await uploadModel(data, b, volumes[0]);
revalidatePath("/storage");
},
);
+10
View File
@@ -1,5 +1,6 @@
"use server";
import { RunDisplay } from "@/components/RunDisplay";
import { db } from "@/db/db";
import { deploymentsTable, workflowRunsTable } from "@/db/schema";
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) {
const a = await db
.select({
+2 -1
View File
@@ -1,5 +1,5 @@
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 { APIKeyUserType } from "@/server/APIKeyBodyRequest";
import { auth } from "@clerk/nextjs";
@@ -28,6 +28,7 @@ export async function getCurrentPlan({ user_id, org_id }: APIKeyUserType) {
isNull(subscriptionStatusTable.org_id),
eq(subscriptionStatusTable.org_id, ""),
),
ne(subscriptionStatusTable.status, "deleted"),
),
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++) {
const output = data.outputs[i];
if (output.data?.images !== undefined) {
for (let j = 0; j < output.data?.images.length; j++) {
const element = output.data?.images[j];
element.url = replaceCDNUrl(
`${process.env.SPACES_ENDPOINT}/${process.env.SPACES_BUCKET}/outputs/runs/${data.id}/${element.filename}`
);
}
} else if (output.data?.files !== undefined) {
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}`
);
}
}
if (output.data?.images !== undefined)
replaceUrls(output.data?.images, data.id);
if (output.data?.files !== undefined)
replaceUrls(output.data?.files, data.id);
if (output.data?.gifs !== undefined)
replaceUrls(output.data?.gifs, data.id);
}
}
}
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";
import { RunOutputs } from "@/components/RunOutputs";
import { db } from "@/db/db";
import { workflowRunOutputs } from "@/db/schema";
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) {
// throw new Error("Not implemented");
return await db
.select()
.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",
});
}