feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
This commit is contained in:
+60
-2
@@ -172,6 +172,33 @@ def cmd_zips_clear(args):
|
||||
print(f"Cleared {len(jobs)} zip job(s) and their archives")
|
||||
|
||||
|
||||
def cmd_forks_prune(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.services.jobs import queue
|
||||
now = datetime.now(timezone.utc)
|
||||
removed = 0
|
||||
for job in queue.list_jobs(kind="fork", status=queue.DONE):
|
||||
expires_at = job.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if expiry < now:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
print(f"Pruned {removed} expired fork job(s)")
|
||||
|
||||
|
||||
def cmd_forks_clear(args):
|
||||
from devplacepy.services.jobs import queue
|
||||
jobs = queue.list_jobs(kind="fork")
|
||||
for job in jobs:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
print(f"Cleared {len(jobs)} fork job(s)")
|
||||
|
||||
|
||||
def cmd_containers_list(args):
|
||||
from devplacepy.services.containers import store
|
||||
instances = store.all_instances()
|
||||
@@ -200,7 +227,30 @@ def cmd_containers_prune(args):
|
||||
await get_backend().image_prune()
|
||||
|
||||
asyncio.run(run())
|
||||
print("Reaped orphans and pruned dangling build images")
|
||||
print("Reaped orphans and pruned dangling images")
|
||||
|
||||
|
||||
def cmd_containers_prune_builds(args):
|
||||
import asyncio
|
||||
from devplacepy.database import db, get_table
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
|
||||
async def run():
|
||||
backend = get_backend()
|
||||
removed = 0
|
||||
if "builds" in db.tables:
|
||||
for build in list(get_table("builds").find()):
|
||||
tag = build.get("image_tag")
|
||||
if tag:
|
||||
await backend.remove_image(tag)
|
||||
removed += 1
|
||||
for table in ("builds", "dockerfile_versions", "dockerfiles"):
|
||||
if table in db.tables:
|
||||
get_table(table).delete()
|
||||
return removed
|
||||
|
||||
removed = asyncio.run(run())
|
||||
print(f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables")
|
||||
|
||||
|
||||
def cmd_containers_gc_workspaces(args):
|
||||
@@ -274,11 +324,19 @@ def main():
|
||||
zips_clear = zips_sub.add_parser("clear", help="Delete every zip archive and job row")
|
||||
zips_clear.set_defaults(func=cmd_zips_clear)
|
||||
|
||||
forks = sub.add_parser("forks", help="Fork job management")
|
||||
forks_sub = forks.add_subparsers(title="action", dest="action")
|
||||
forks_prune = forks_sub.add_parser("prune", help="Delete expired completed fork job rows (forked projects persist)")
|
||||
forks_prune.set_defaults(func=cmd_forks_prune)
|
||||
forks_clear = forks_sub.add_parser("clear", help="Delete every fork job row (forked projects persist)")
|
||||
forks_clear.set_defaults(func=cmd_forks_clear)
|
||||
|
||||
containers = sub.add_parser("containers", help="Container manager")
|
||||
containers_sub = containers.add_subparsers(title="action", dest="action")
|
||||
containers_sub.add_parser("list", help="List container instances").set_defaults(func=cmd_containers_list)
|
||||
containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults(func=cmd_containers_reconcile)
|
||||
containers_sub.add_parser("prune", help="Reap orphan containers and dangling build images").set_defaults(func=cmd_containers_prune)
|
||||
containers_sub.add_parser("prune", help="Reap orphan containers and dangling images").set_defaults(func=cmd_containers_prune)
|
||||
containers_sub.add_parser("prune-builds", help="Remove legacy per-project images and clear the dockerfiles/builds tables").set_defaults(func=cmd_containers_prune_builds)
|
||||
containers_sub.add_parser("gc-workspaces", help="Remove workspace dirs with no instances").set_defaults(func=cmd_containers_gc_workspaces)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from os import environ
|
||||
@@ -24,10 +23,10 @@ SERVICE_LOCK_FILE = BASE_DIR / "devplace-services.lock"
|
||||
INIT_LOCK_FILE = BASE_DIR / "devplace-init.lock"
|
||||
|
||||
# Container data lives OUTSIDE the package (never served via /static, never watched by --reload).
|
||||
# Build contexts are ephemeral, so they go in the OS temp dir; workspaces are persistent.
|
||||
DATA_DIR = Path(environ.get("DEVPLACE_DATA_DIR", str(BASE_DIR / "var")))
|
||||
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
|
||||
CONTAINER_BUILD_CONTEXTS_DIR = Path(tempfile.gettempdir()) / "devplace_build_contexts"
|
||||
# Every container instance runs this one prebuilt image (built once via `make ppy`).
|
||||
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
|
||||
# Host the /p/<slug> ingress proxy dials to reach a published container port.
|
||||
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "127.0.0.1")
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
|
||||
|
||||
REACTION_EMOJI = ["\U0001F44D", "❤️", "\U0001F680", "\U0001F389", "\U0001F602", "\U0001F440", "\U0001F525", "\U0001F92F"]
|
||||
|
||||
DEVII_GUEST_COOKIE = "devii_guest"
|
||||
|
||||
@@ -137,7 +137,9 @@ def delete_content_item(request, table_name: str, target_type: str, user: dict,
|
||||
delete_engagement("comment", comment_uids)
|
||||
if target_type == "project":
|
||||
from devplacepy.project_files import delete_all_project_files
|
||||
from devplacepy.database import delete_fork_relations
|
||||
delete_all_project_files(item["uid"])
|
||||
delete_fork_relations(item["uid"])
|
||||
if inline_image_field:
|
||||
delete_inline_image(item.get(inline_image_field))
|
||||
table.delete(id=item["id"])
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
from starlette.requests import Request
|
||||
|
||||
from devplacepy.constants import DEVII_GUEST_COOKIE
|
||||
from devplacepy.database import get_custom_overrides, get_setting
|
||||
from devplacepy.utils import get_current_user
|
||||
|
||||
logger = logging.getLogger("customization")
|
||||
|
||||
EMPTY = Markup("")
|
||||
|
||||
|
||||
def page_type_for(request: Request) -> str:
|
||||
route = request.scope.get("route")
|
||||
path = getattr(route, "path", None)
|
||||
if path:
|
||||
return path
|
||||
return request.url.path
|
||||
|
||||
|
||||
def owner_for(request: Request) -> tuple[str, str] | None:
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
return "user", user["uid"]
|
||||
guest = request.cookies.get(DEVII_GUEST_COOKIE)
|
||||
if guest:
|
||||
return "guest", guest
|
||||
return None
|
||||
|
||||
|
||||
def _overrides_for(request: Request) -> dict:
|
||||
if get_setting("customization_enabled", "1") != "1":
|
||||
return {"css": "", "js": ""}
|
||||
owner = owner_for(request)
|
||||
if owner is None:
|
||||
return {"css": "", "js": ""}
|
||||
return get_custom_overrides(owner[0], owner[1], page_type_for(request))
|
||||
|
||||
|
||||
def custom_css_tag(request: Request) -> Markup:
|
||||
try:
|
||||
css = _overrides_for(request).get("css", "")
|
||||
if not css.strip():
|
||||
return EMPTY
|
||||
safe = css.replace("</", "<\\/")
|
||||
return Markup(f'<style id="user-custom-css">\n{safe}\n</style>')
|
||||
except Exception as exc: # noqa: BLE001 - a customization bug must never break page render
|
||||
logger.warning("custom_css_tag failed: %s", exc)
|
||||
return EMPTY
|
||||
|
||||
|
||||
def custom_js_tag(request: Request) -> Markup:
|
||||
try:
|
||||
if get_setting("customization_js_enabled", "1") != "1":
|
||||
return EMPTY
|
||||
code = _overrides_for(request).get("js", "")
|
||||
if not code.strip():
|
||||
return EMPTY
|
||||
payload = json.dumps(code).replace("<", "\\u003c").replace(">", "\\u003e")
|
||||
runner = (
|
||||
f'<script type="application/json" id="user-custom-js-src">{payload}</script>'
|
||||
'<script>(function(){try{'
|
||||
'var src=document.getElementById("user-custom-js-src");'
|
||||
'if(src){new Function(JSON.parse(src.textContent))();}'
|
||||
'}catch(error){console.error("user custom js error",error);}})();</script>'
|
||||
)
|
||||
return Markup(runner)
|
||||
except Exception as exc: # noqa: BLE001 - a customization bug must never break page render
|
||||
logger.warning("custom_js_tag failed: %s", exc)
|
||||
return EMPTY
|
||||
+136
-6
@@ -161,6 +161,10 @@ def init_db():
|
||||
_index(db, "devii_tasks", "idx_devii_tasks_owner", ["owner_kind", "owner_id"])
|
||||
_index(db, "devii_tasks", "idx_devii_tasks_due", ["enabled", "status", "next_run_at"])
|
||||
_index(db, "devii_lessons", "idx_devii_lessons_owner", ["owner_kind", "owner_id"])
|
||||
_index(db, "devii_virtual_tools", "idx_devii_vtools_owner", ["owner_kind", "owner_id"])
|
||||
_index(db, "devii_virtual_tools", "idx_devii_vtools_name", ["owner_kind", "owner_id", "name"])
|
||||
_index(db, "user_customizations", "idx_user_cust_owner", ["owner_kind", "owner_id"])
|
||||
_index(db, "user_customizations", "idx_user_cust_scope", ["owner_kind", "owner_id", "scope", "lang"])
|
||||
_index(db, "gateway_usage_ledger", "idx_gw_usage_time", ["created_at"])
|
||||
_index(db, "gateway_usage_ledger", "idx_gw_usage_backend_time", ["backend", "created_at"])
|
||||
_index(db, "gateway_usage_ledger", "idx_gw_usage_model_time", ["model", "created_at"])
|
||||
@@ -170,12 +174,8 @@ def init_db():
|
||||
_index(db, "jobs", "idx_jobs_kind_status", ["kind", "status"])
|
||||
_index(db, "jobs", "idx_jobs_owner", ["owner_kind", "owner_id"])
|
||||
_index(db, "jobs", "idx_jobs_expires", ["expires_at"])
|
||||
_index(db, "dockerfiles", "idx_dockerfiles_project", ["project_uid"])
|
||||
_index(db, "dockerfiles", "idx_dockerfiles_slug", ["slug"])
|
||||
_index(db, "dockerfile_versions", "idx_dfv_dockerfile", ["dockerfile_uid", "version"])
|
||||
_index(db, "builds", "idx_builds_dockerfile", ["dockerfile_uid", "build_number"])
|
||||
_index(db, "builds", "idx_builds_status", ["status"])
|
||||
_index(db, "builds", "idx_builds_project", ["project_uid"])
|
||||
_index(db, "project_forks", "idx_project_forks_source", ["source_project_uid"])
|
||||
_index(db, "project_forks", "idx_project_forks_forked", ["forked_project_uid"])
|
||||
_index(db, "instances", "idx_instances_project", ["project_uid"])
|
||||
_index(db, "instances", "idx_instances_state", ["desired_state", "status"])
|
||||
_index(db, "instances", "idx_instances_container", ["container_id"])
|
||||
@@ -244,6 +244,8 @@ def init_db():
|
||||
"registration_open": "1",
|
||||
"maintenance_mode": "0",
|
||||
"maintenance_message": "DevPlace is undergoing scheduled maintenance. Please check back shortly.",
|
||||
"customization_enabled": "1",
|
||||
"customization_js_enabled": "1",
|
||||
}
|
||||
for key, value in operational_defaults.items():
|
||||
existing = db["site_settings"].find_one(key=key)
|
||||
@@ -674,6 +676,100 @@ def clear_settings_cache() -> None:
|
||||
bump_cache_version("settings")
|
||||
|
||||
|
||||
CUSTOMIZATION_GLOBAL_SCOPE = "global"
|
||||
CUSTOMIZATION_LANGS = ("css", "js")
|
||||
|
||||
_customizations_cache = TTLCache(ttl=300)
|
||||
|
||||
|
||||
def _customization_key(owner_kind: str, owner_id: str, page_type: str) -> str:
|
||||
return f"{owner_kind}\x1f{owner_id}\x1f{page_type}"
|
||||
|
||||
|
||||
def get_custom_overrides(owner_kind: str, owner_id: str, page_type: str) -> dict:
|
||||
sync_local_cache("customizations", _customizations_cache)
|
||||
key = _customization_key(owner_kind, owner_id, page_type)
|
||||
cached = _customizations_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = {"css": "", "js": ""}
|
||||
if "user_customizations" in db.tables:
|
||||
scopes = (CUSTOMIZATION_GLOBAL_SCOPE, page_type)
|
||||
rows = db["user_customizations"].find(
|
||||
owner_kind=owner_kind, owner_id=owner_id, enabled=1,
|
||||
)
|
||||
pieces: dict[str, dict[str, str]] = {lang: {} for lang in CUSTOMIZATION_LANGS}
|
||||
for row in rows:
|
||||
lang = row.get("lang")
|
||||
scope = row.get("scope")
|
||||
if lang in pieces and scope in scopes:
|
||||
pieces[lang][scope] = row.get("code") or ""
|
||||
for lang in CUSTOMIZATION_LANGS:
|
||||
ordered = [pieces[lang][scope] for scope in scopes if scope in pieces[lang]]
|
||||
result[lang] = "\n".join(part for part in ordered if part.strip())
|
||||
_customizations_cache.set(key, result)
|
||||
return result
|
||||
|
||||
|
||||
def get_custom_override(owner_kind: str, owner_id: str, scope: str, lang: str) -> dict | None:
|
||||
if "user_customizations" not in db.tables:
|
||||
return None
|
||||
return db["user_customizations"].find_one(
|
||||
owner_kind=owner_kind, owner_id=owner_id, scope=scope, lang=lang,
|
||||
)
|
||||
|
||||
|
||||
def list_custom_overrides(owner_kind: str, owner_id: str) -> list:
|
||||
if "user_customizations" not in db.tables:
|
||||
return []
|
||||
return list(db["user_customizations"].find(owner_kind=owner_kind, owner_id=owner_id))
|
||||
|
||||
|
||||
def set_custom_override(owner_kind: str, owner_id: str, scope: str, lang: str, code: str) -> dict:
|
||||
from devplacepy.utils import generate_uid
|
||||
table = get_table("user_customizations")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing = table.find_one(owner_kind=owner_kind, owner_id=owner_id, scope=scope, lang=lang)
|
||||
if existing:
|
||||
record = {
|
||||
"id": existing["id"],
|
||||
"code": code,
|
||||
"enabled": 1,
|
||||
"updated_at": now,
|
||||
}
|
||||
table.update(record, ["id"])
|
||||
result = {**existing, **record}
|
||||
else:
|
||||
result = {
|
||||
"uid": generate_uid(),
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"scope": scope,
|
||||
"lang": lang,
|
||||
"code": code,
|
||||
"enabled": 1,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
table.insert(result)
|
||||
bump_cache_version("customizations")
|
||||
return result
|
||||
|
||||
|
||||
def delete_custom_override(owner_kind: str, owner_id: str, scope: str | None = None, lang: str | None = None) -> int:
|
||||
if "user_customizations" not in db.tables:
|
||||
return 0
|
||||
criteria: dict = {"owner_kind": owner_kind, "owner_id": owner_id}
|
||||
if scope is not None:
|
||||
criteria["scope"] = scope
|
||||
if lang is not None:
|
||||
criteria["lang"] = lang
|
||||
count = db["user_customizations"].count(**criteria)
|
||||
db["user_customizations"].delete(**criteria)
|
||||
bump_cache_version("customizations")
|
||||
return int(count)
|
||||
|
||||
|
||||
_stats_cache = TTLCache(ttl=30)
|
||||
|
||||
|
||||
@@ -984,6 +1080,40 @@ def resolve_by_slug(table, slug):
|
||||
return entry
|
||||
|
||||
|
||||
def record_fork(source_project_uid: str, forked_project_uid: str, forked_by_uid: str) -> None:
|
||||
from devplacepy.utils import generate_uid
|
||||
get_table("project_forks").insert({
|
||||
"uid": generate_uid(),
|
||||
"source_project_uid": source_project_uid,
|
||||
"forked_project_uid": forked_project_uid,
|
||||
"forked_by_uid": forked_by_uid,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
|
||||
def get_fork_parent(forked_project_uid: str) -> dict | None:
|
||||
if "project_forks" not in db.tables:
|
||||
return None
|
||||
relation = get_table("project_forks").find_one(forked_project_uid=forked_project_uid)
|
||||
if not relation:
|
||||
return None
|
||||
return get_table("projects").find_one(uid=relation["source_project_uid"])
|
||||
|
||||
|
||||
def count_forks(source_project_uid: str) -> int:
|
||||
if "project_forks" not in db.tables:
|
||||
return 0
|
||||
return get_table("project_forks").count(source_project_uid=source_project_uid)
|
||||
|
||||
|
||||
def delete_fork_relations(project_uid: str) -> None:
|
||||
if "project_forks" not in db.tables:
|
||||
return
|
||||
forks = get_table("project_forks")
|
||||
forks.delete(forked_project_uid=project_uid)
|
||||
forks.delete(source_project_uid=project_uid)
|
||||
|
||||
|
||||
def resolve_object_url(target_type: str, target_uid: str) -> str:
|
||||
if target_type == "post":
|
||||
post = resolve_by_slug(get_table("posts"), target_uid)
|
||||
|
||||
+51
-35
@@ -611,6 +611,41 @@ four ways to sign requests.
|
||||
interactive=True,
|
||||
params=[field("uid", "path", "string", True, "ZIP_JOB_UID", "Zip job uid of a finished job.")],
|
||||
),
|
||||
endpoint(
|
||||
id="projects-fork",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/fork",
|
||||
title="Queue a project fork",
|
||||
summary="Start a background job that copies the whole project into a new project owned by you. Returns the job uid and status URL to poll.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project to fork."),
|
||||
field("title", "form", "string", True, "My Fork", "Title for the new forked project."),
|
||||
],
|
||||
sample_response={"uid": "FORK_JOB_UID", "status_url": "/forks/FORK_JOB_UID"},
|
||||
),
|
||||
endpoint(
|
||||
id="forks-status",
|
||||
method="GET",
|
||||
path="/forks/{uid}",
|
||||
title="Fork job status",
|
||||
summary="Poll a fork job. While pending or running project_url is null; once done it points at the new project.",
|
||||
auth="public",
|
||||
params=[field("uid", "path", "string", True, "FORK_JOB_UID", "Fork job uid returned when the job was queued.")],
|
||||
sample_response={
|
||||
"uid": "FORK_JOB_UID",
|
||||
"kind": "fork",
|
||||
"status": "done",
|
||||
"preferred_name": "My Fork",
|
||||
"project_uid": "NEW_PROJECT_UID",
|
||||
"project_url": "/projects/new-project-slug",
|
||||
"source_project_uid": "SOURCE_PROJECT_UID",
|
||||
"error": None,
|
||||
"item_count": 12,
|
||||
"created_at": "2026-06-09T10:00:00+00:00",
|
||||
"completed_at": "2026-06-09T10:00:05+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="gists-list",
|
||||
method="GET",
|
||||
@@ -1235,55 +1270,36 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="containers-page", method="GET", path="/projects/{project_slug}/containers",
|
||||
title="Container manager page", summary="The admin container manager UI for a project.",
|
||||
title="Container manager page", summary="The admin per-project container manager UI (Dockerfiles, builds, instance creation).",
|
||||
auth="admin", interactive=True,
|
||||
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid.")],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-create-dockerfile", method="POST",
|
||||
path="/projects/{project_slug}/containers/dockerfiles",
|
||||
title="Create a Dockerfile", summary="Create a Dockerfile and queue its first build (default template if content is empty).",
|
||||
auth="admin", encoding="form", destructive=True,
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
||||
field("name", "form", "string", True, "myapp", "Image name (lowercase)."),
|
||||
field("description", "form", "string", False, "", "Optional description."),
|
||||
field("tags", "form", "string", False, "", "Optional comma separated tags."),
|
||||
field("content", "form", "textarea", False, "", "Optional Dockerfile content."),
|
||||
],
|
||||
sample_response={"ok": True, "data": {"dockerfile": {"uid": "DF_UID", "name": "myapp"}, "build_uid": "BUILD_UID"}},
|
||||
id="containers-admin-index", method="GET", path="/admin/containers",
|
||||
title="Admin containers list", summary="The admin Containers section: every instance across all projects, each linking to its detail page.",
|
||||
auth="admin", interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="containers-save-version", method="POST",
|
||||
path="/projects/{project_slug}/containers/dockerfiles/{df_ref}/version",
|
||||
title="Save a new version", summary="Create a new immutable version and auto-build if content changed.",
|
||||
auth="admin", encoding="form", destructive=True,
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
||||
field("df_ref", "path", "string", True, "DF_REF", "Dockerfile name, slug, or uid."),
|
||||
field("content", "form", "textarea", True, "FROM python:3.13-slim", "New content."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-build-status", method="GET",
|
||||
path="/projects/{project_slug}/containers/builds/{uid}",
|
||||
title="Build status", summary="Status, image tag, and stored logs for a build.",
|
||||
id="containers-admin-data", method="GET", path="/admin/containers/data",
|
||||
title="Admin containers list data", summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
||||
field("uid", "path", "string", True, "BUILD_UID", "Build uid."),
|
||||
],
|
||||
sample_response={"uid": "BUILD_UID", "image_tag": "myapp:1", "status": "success", "logs": "..."},
|
||||
sample_response={"instances": [{"uid": "INSTANCE_UID", "name": "staging", "status": "running",
|
||||
"project_slug": "PROJECT_SLUG", "project_title": "My Project",
|
||||
"ingress_slug": "my-service", "restart_policy": "always"}]},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-instance", method="GET", path="/admin/containers/{uid}",
|
||||
title="Instance detail page", summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
|
||||
auth="admin", interactive=True,
|
||||
params=[field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid.")],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-create-instance", method="POST",
|
||||
path="/projects/{project_slug}/containers/instances",
|
||||
title="Create an instance", summary="Create and (by default) start an instance from a build; /app is mounted to the project workspace.",
|
||||
title="Create an instance", summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
|
||||
auth="admin", encoding="form", destructive=True,
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
||||
field("dockerfile_uid", "form", "string", True, "DF_UID", "Dockerfile uid."),
|
||||
field("build_uid", "form", "string", False, "", "Specific build uid (defaults to latest success)."),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Optional boot command."),
|
||||
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
|
||||
|
||||
+4
-3
@@ -17,14 +17,14 @@ from devplacepy.schemas import ValidationErrorOut
|
||||
from fastapi.responses import JSONResponse
|
||||
from devplacepy.utils import get_current_user, time_ago
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.routers import auth, feed, posts, comments, projects, project_files, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push, leaderboard, reactions, bookmarks, polls, docs, openai_gateway, devii, zips, containers, containers_admin, proxy
|
||||
from devplacepy.routers import auth, feed, posts, comments, projects, project_files, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push, leaderboard, reactions, bookmarks, polls, docs, openai_gateway, devii, zips, forks, containers, containers_admin, proxy
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.news import NewsService
|
||||
from devplacepy.services.bot import BotsService
|
||||
from devplacepy.services.openai_gateway import GatewayService
|
||||
from devplacepy.services.devii import DeviiService
|
||||
from devplacepy.services.jobs.zip_service import ZipService
|
||||
from devplacepy.services.containers.build_service import ContainerBuildService
|
||||
from devplacepy.services.jobs.fork_service import ForkService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -182,6 +182,7 @@ app.include_router(news.router, prefix="/news")
|
||||
app.include_router(services_router.router, prefix="/admin/services")
|
||||
app.include_router(uploads.router, prefix="/uploads")
|
||||
app.include_router(zips.router, prefix="/zips")
|
||||
app.include_router(forks.router, prefix="/forks")
|
||||
app.include_router(containers.router, prefix="/projects")
|
||||
app.include_router(containers_admin.router, prefix="/admin/containers")
|
||||
app.include_router(proxy.router, prefix="/p")
|
||||
@@ -252,7 +253,7 @@ async def startup():
|
||||
service_manager.register(GatewayService())
|
||||
service_manager.register(DeviiService())
|
||||
service_manager.register(ZipService())
|
||||
service_manager.register(ContainerBuildService())
|
||||
service_manager.register(ForkService())
|
||||
service_manager.register(ContainerService())
|
||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||
if acquire_service_lock():
|
||||
|
||||
+4
-13
@@ -163,6 +163,10 @@ class ProjectFlagForm(BaseModel):
|
||||
value: bool = False
|
||||
|
||||
|
||||
class ForkForm(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class ProjectFileWriteForm(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1024)
|
||||
content: str = Field(default="", max_length=400000)
|
||||
@@ -205,20 +209,7 @@ class ProjectFileAppendForm(BaseModel):
|
||||
content: str = Field(default="", max_length=400000)
|
||||
|
||||
|
||||
class ContainerDockerfileForm(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
description: str = Field(default="", max_length=500)
|
||||
tags: str = Field(default="", max_length=200)
|
||||
content: str = Field(default="", max_length=200000)
|
||||
|
||||
|
||||
class ContainerVersionForm(BaseModel):
|
||||
content: str = Field(default="", max_length=200000)
|
||||
|
||||
|
||||
class ContainerInstanceForm(BaseModel):
|
||||
dockerfile_uid: str = Field(min_length=1, max_length=64)
|
||||
build_uid: str = Field(default="", max_length=64)
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
boot_command: str = Field(default="", max_length=500)
|
||||
env: str = Field(default="", max_length=10000)
|
||||
|
||||
@@ -433,6 +433,8 @@ def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.is_symlink() or target.is_file():
|
||||
target.unlink()
|
||||
if row.get("is_binary") and row.get("stored_name") and row.get("directory"):
|
||||
shutil.copyfile(PROJECT_FILES_DIR / row["directory"] / row["stored_name"], target)
|
||||
else:
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import signal
|
||||
import struct
|
||||
import termios
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Form, Request, WebSocket, WebSocketDisconnect
|
||||
@@ -12,11 +17,9 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.models import (
|
||||
ContainerDockerfileForm,
|
||||
ContainerExecForm,
|
||||
ContainerInstanceForm,
|
||||
ContainerScheduleForm,
|
||||
ContainerVersionForm,
|
||||
)
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import ContainersOut
|
||||
@@ -47,13 +50,6 @@ def _fail(exc: ContainerError):
|
||||
return json_error(400, str(exc))
|
||||
|
||||
|
||||
def _dockerfile(project: dict, df_ref: str) -> dict:
|
||||
df = store.get_dockerfile(df_ref)
|
||||
if not df or df["project_uid"] != project["uid"]:
|
||||
raise not_found("Dockerfile not found")
|
||||
return df
|
||||
|
||||
|
||||
def _instance(project: dict, uid: str) -> dict:
|
||||
inst = store.get_instance(uid)
|
||||
if not inst or inst["project_uid"] != project["uid"]:
|
||||
@@ -75,7 +71,6 @@ async def containers_page(request: Request, project_slug: str):
|
||||
{"name": "Containers", "url": f"/projects/{slug}/containers"},
|
||||
]),
|
||||
"request": request, "user": user, "project": project,
|
||||
"dockerfiles": store.list_dockerfiles(project["uid"]),
|
||||
"instances": store.list_instances(project["uid"]),
|
||||
}, model=ContainersOut)
|
||||
|
||||
@@ -85,105 +80,19 @@ async def containers_data(request: Request, project_slug: str):
|
||||
require_admin(request)
|
||||
project = _project(project_slug)
|
||||
return JSONResponse({
|
||||
"dockerfiles": store.list_dockerfiles(project["uid"]),
|
||||
"instances": store.list_instances(project["uid"]),
|
||||
})
|
||||
|
||||
|
||||
# ---------------- dockerfiles + builds ----------------
|
||||
|
||||
@router.post("/{project_slug}/containers/dockerfiles")
|
||||
async def create_dockerfile(request: Request, project_slug: str, data: Annotated[ContainerDockerfileForm, Form()]):
|
||||
user = require_admin(request)
|
||||
project = _project(project_slug)
|
||||
try:
|
||||
result = await api.create_dockerfile(project, user, name=data.name, description=data.description,
|
||||
tags=data.tags, content=data.content or None)
|
||||
except ContainerError as exc:
|
||||
return _fail(exc)
|
||||
return action_result(request, f"/projects/{_slug(project)}/containers", data={
|
||||
"dockerfile": result["dockerfile"], "build_uid": result["build"]["uid"]})
|
||||
|
||||
|
||||
@router.get("/{project_slug}/containers/dockerfiles/{df_ref}")
|
||||
async def dockerfile_detail(request: Request, project_slug: str, df_ref: str):
|
||||
require_admin(request)
|
||||
project = _project(project_slug)
|
||||
df = _dockerfile(project, df_ref)
|
||||
version = store.current_version(df)
|
||||
return JSONResponse({
|
||||
"dockerfile": df, "content": version["content"] if version else "",
|
||||
"versions": store.list_versions(df["uid"]),
|
||||
"builds": store.list_builds(df["uid"]),
|
||||
"instances": store.list_instances(dockerfile_uid=df["uid"]),
|
||||
"stats": api.dockerfile_stats(df["uid"]),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{project_slug}/containers/dockerfiles/{df_ref}/version")
|
||||
async def save_version(request: Request, project_slug: str, df_ref: str, data: Annotated[ContainerVersionForm, Form()]):
|
||||
user = require_admin(request)
|
||||
project = _project(project_slug)
|
||||
df = _dockerfile(project, df_ref)
|
||||
try:
|
||||
result = await api.save_version(df, user, data.content)
|
||||
except ContainerError as exc:
|
||||
return _fail(exc)
|
||||
return action_result(request, f"/projects/{_slug(project)}/containers", data={
|
||||
"changed": result["changed"],
|
||||
"build_uid": result["build"]["uid"] if result["build"] else None})
|
||||
|
||||
|
||||
@router.post("/{project_slug}/containers/dockerfiles/{df_ref}/build")
|
||||
async def rebuild(request: Request, project_slug: str, df_ref: str):
|
||||
user = require_admin(request)
|
||||
project = _project(project_slug)
|
||||
df = _dockerfile(project, df_ref)
|
||||
version = store.current_version(df)
|
||||
if version is None:
|
||||
return json_error(400, "Dockerfile has no version to build")
|
||||
build = await api.enqueue_build(df, version, owner=("user", user["uid"]))
|
||||
return action_result(request, f"/projects/{_slug(project)}/containers", data={"build_uid": build["uid"]})
|
||||
|
||||
|
||||
@router.get("/{project_slug}/containers/builds/{uid}")
|
||||
async def build_status(request: Request, project_slug: str, uid: str):
|
||||
require_admin(request)
|
||||
project = _project(project_slug)
|
||||
build = store.get_build(uid)
|
||||
if not build or build["project_uid"] != project["uid"]:
|
||||
raise not_found("Build not found")
|
||||
return JSONResponse(build)
|
||||
|
||||
|
||||
@router.post("/{project_slug}/containers/builds/{uid}/cancel")
|
||||
async def cancel_build(request: Request, project_slug: str, uid: str):
|
||||
require_admin(request)
|
||||
project = _project(project_slug)
|
||||
build = store.get_build(uid)
|
||||
if not build or build["project_uid"] != project["uid"]:
|
||||
raise not_found("Build not found")
|
||||
if build["status"] in (store.BUILD_QUEUED, store.BUILD_BUILDING):
|
||||
store.update_build(uid, {"status": store.BUILD_CANCELLED})
|
||||
return action_result(request, f"/projects/{_slug(project)}/containers", data={"uid": uid})
|
||||
|
||||
|
||||
# ---------------- instances ----------------
|
||||
|
||||
@router.post("/{project_slug}/containers/instances")
|
||||
async def create_instance(request: Request, project_slug: str, data: Annotated[ContainerInstanceForm, Form()]):
|
||||
user = require_admin(request)
|
||||
project = _project(project_slug)
|
||||
df = _dockerfile(project, data.dockerfile_uid)
|
||||
build = store.get_build(data.build_uid) if data.build_uid else None
|
||||
if build is None:
|
||||
successes = [b for b in store.list_builds(df["uid"]) if b["status"] == store.BUILD_SUCCESS]
|
||||
build = successes[0] if successes else None
|
||||
if build is None:
|
||||
return json_error(400, "no successful build available for this Dockerfile")
|
||||
try:
|
||||
inst = await api.create_instance(
|
||||
project, df, build, name=data.name, boot_command=data.boot_command, env=data.env,
|
||||
project, name=data.name, boot_command=data.boot_command, env=data.env,
|
||||
cpu_limit=data.cpu_limit, mem_limit=data.mem_limit, ports=data.ports, volumes=data.volumes,
|
||||
restart_policy=data.restart_policy, autostart=data.autostart,
|
||||
ingress_slug=data.ingress_slug, ingress_port=data.ingress_port, actor=("user", user["uid"]))
|
||||
@@ -210,21 +119,6 @@ _ACTIONS = {
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/{action}")
|
||||
async def instance_action(request: Request, project_slug: str, uid: str, action: str):
|
||||
user = require_admin(request)
|
||||
project = _project(project_slug)
|
||||
inst = _instance(project, uid)
|
||||
actor = ("user", user["uid"])
|
||||
if action == "restart":
|
||||
api.request_restart(inst, actor=actor)
|
||||
elif action in _ACTIONS:
|
||||
api.set_desired_state(inst, _ACTIONS[action], actor=actor)
|
||||
else:
|
||||
return json_error(400, f"unknown action: {action}")
|
||||
return action_result(request, f"/projects/{_slug(project)}/containers", data={"uid": uid, "action": action})
|
||||
|
||||
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/delete")
|
||||
async def delete_instance(request: Request, project_slug: str, uid: str):
|
||||
user = require_admin(request)
|
||||
@@ -307,6 +201,24 @@ async def delete_schedule(request: Request, project_slug: str, uid: str, sid: st
|
||||
return action_result(request, f"/projects/{_slug(project)}/containers", data={"uid": sid})
|
||||
|
||||
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/start")
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/stop")
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/pause")
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/resume")
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/restart")
|
||||
async def instance_action(request: Request, project_slug: str, uid: str):
|
||||
user = require_admin(request)
|
||||
project = _project(project_slug)
|
||||
inst = _instance(project, uid)
|
||||
actor = ("user", user["uid"])
|
||||
action = request.url.path.rsplit("/", 1)[-1]
|
||||
if action == "restart":
|
||||
api.request_restart(inst, actor=actor)
|
||||
else:
|
||||
api.set_desired_state(inst, _ACTIONS[action], actor=actor)
|
||||
return action_result(request, f"/projects/{_slug(project)}/containers", data={"uid": uid, "action": action})
|
||||
|
||||
|
||||
@router.websocket("/{project_slug}/containers/instances/{uid}/exec/ws")
|
||||
async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
|
||||
await websocket.accept()
|
||||
@@ -324,8 +236,14 @@ async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
|
||||
return
|
||||
|
||||
master, slave = pty.openpty()
|
||||
session = (websocket.query_params.get("session") or "").strip()
|
||||
if session and re.match(r"^[A-Za-z0-9_-]{1,64}$", session):
|
||||
shell_cmd = (f"if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s {session}; "
|
||||
"elif command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi")
|
||||
else:
|
||||
shell_cmd = "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker", "exec", "-it", inst["container_id"], "/bin/sh", "-c", "exec /bin/bash || exec /bin/sh",
|
||||
"docker", "exec", "-it", inst["container_id"], "/bin/sh", "-c", shell_cmd,
|
||||
stdin=slave, stdout=slave, stderr=slave, start_new_session=True)
|
||||
os.close(slave)
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -340,10 +258,31 @@ async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_winsize(rows: int, cols: int) -> None:
|
||||
try:
|
||||
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
proc.send_signal(signal.SIGWINCH)
|
||||
except (ProcessLookupError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
reader = asyncio.create_task(pump_out())
|
||||
try:
|
||||
while True:
|
||||
text = await websocket.receive_text()
|
||||
if text[:1] == "{":
|
||||
try:
|
||||
control = json.loads(text)
|
||||
except ValueError:
|
||||
control = None
|
||||
if isinstance(control, dict) and control.get("type") == "resize":
|
||||
rows = int(control.get("rows") or 0)
|
||||
cols = int(control.get("cols") or 0)
|
||||
if rows > 0 and cols > 0:
|
||||
await loop.run_in_executor(None, set_winsize, rows, cols)
|
||||
continue
|
||||
await loop.run_in_executor(None, os.write, master, text.encode("utf-8"))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
@@ -36,17 +36,6 @@ def _decorate(instances: list) -> list:
|
||||
return decorated
|
||||
|
||||
|
||||
def _projects_with_dockerfiles() -> list:
|
||||
if "dockerfiles" not in db.tables:
|
||||
return []
|
||||
project_uids = {df["project_uid"] for df in get_table("dockerfiles").find()}
|
||||
index = _project_index(project_uids)
|
||||
rows = [{"title": p.get("title", ""), "slug": p.get("slug") or p.get("uid")}
|
||||
for p in index.values()]
|
||||
rows.sort(key=lambda r: r["title"].lower())
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def containers_index(request: Request):
|
||||
admin = require_admin(request)
|
||||
@@ -69,7 +58,6 @@ async def containers_index(request: Request):
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"instances": instances,
|
||||
"projects": _projects_with_dockerfiles(),
|
||||
"admin_section": "containers",
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import uuid_utils
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.constants import DEVII_GUEST_COOKIE
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.seo import site_url
|
||||
from devplacepy.services.manager import service_manager
|
||||
@@ -17,7 +18,7 @@ logger = logging.getLogger("devii.router")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
GUEST_COOKIE = "devii_guest"
|
||||
GUEST_COOKIE = DEVII_GUEST_COOKIE
|
||||
GUEST_COOKIE_MAX_AGE = 31536000
|
||||
|
||||
|
||||
@@ -154,7 +155,7 @@ async def devii_ws(websocket: WebSocket):
|
||||
await websocket.close(code=1013)
|
||||
return
|
||||
if not service_manager.owns_lock():
|
||||
await websocket.close(code=1013)
|
||||
await websocket.close(code=4013)
|
||||
return
|
||||
owner_kind, owner_id, username, api_key, owner_is_admin = _resolve_ws_owner(websocket)
|
||||
if owner_kind == "guest" and not svc.guests_enabled():
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.schemas import ForkJobOut
|
||||
from devplacepy.utils import not_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _status_payload(job: dict) -> dict:
|
||||
result = job.get("result", {})
|
||||
done = job.get("status") == queue.DONE
|
||||
return {
|
||||
"uid": job.get("uid", ""),
|
||||
"kind": job.get("kind", ""),
|
||||
"status": job.get("status", ""),
|
||||
"preferred_name": job.get("preferred_name"),
|
||||
"project_uid": result.get("project_uid") if done else None,
|
||||
"project_url": result.get("project_url") if done else None,
|
||||
"source_project_uid": result.get("source_project_uid") if done else None,
|
||||
"error": job.get("error") or None,
|
||||
"item_count": int(job.get("item_count") or 0),
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at") or None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{uid}")
|
||||
async def fork_status(request: Request, uid: str):
|
||||
job = queue.get_job(uid)
|
||||
if not job or job.get("kind") != "fork":
|
||||
raise not_found("Job not found")
|
||||
return JSONResponse(ForkJobOut.model_validate(_status_payload(job)).model_dump(mode="json"))
|
||||
@@ -2,9 +2,9 @@ import logging
|
||||
from typing import Annotated
|
||||
from sqlalchemy import or_
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from devplacepy.models import ProjectForm, ProjectFlagForm
|
||||
from devplacepy.models import ProjectForm, ProjectFlagForm, ForkForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate, resolve_by_slug
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate, resolve_by_slug, get_fork_parent, count_forks
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url, is_owner, can_view_project
|
||||
from devplacepy.templating import templates
|
||||
@@ -118,10 +118,18 @@ async def project_detail(request: Request, project_slug: str):
|
||||
],
|
||||
schemas=[website_schema(base), software_application_schema(project, base)],
|
||||
)
|
||||
parent = get_fork_parent(project["uid"])
|
||||
forked_from = {
|
||||
"uid": parent["uid"],
|
||||
"slug": parent.get("slug") or parent["uid"],
|
||||
"title": parent.get("title") or "project",
|
||||
} if parent else None
|
||||
return respond(request, "project_detail.html", detail_context(request, user, detail, "project", seo_ctx, {
|
||||
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
|
||||
"is_private": bool(project.get("is_private")),
|
||||
"read_only": bool(project.get("read_only")),
|
||||
"forked_from": forked_from,
|
||||
"fork_count": count_forks(project["uid"]),
|
||||
}), model=ProjectDetailOut)
|
||||
|
||||
|
||||
@@ -143,6 +151,24 @@ async def zip_project(request: Request, project_slug: str):
|
||||
return JSONResponse({"uid": uid, "status_url": f"/zips/{uid}"})
|
||||
|
||||
|
||||
@router.post("/{project_slug}/fork")
|
||||
async def fork_project(request: Request, project_slug: str, data: Annotated[ForkForm, Form()]):
|
||||
user = require_user(request)
|
||||
source = resolve_by_slug(get_table("projects"), project_slug)
|
||||
if not source:
|
||||
raise not_found("Project not found")
|
||||
if not can_view_project(source, user):
|
||||
raise not_found("Project not found")
|
||||
title = data.title.strip()
|
||||
uid = queue.enqueue(
|
||||
"fork",
|
||||
{"source_project_uid": source["uid"], "title": title, "forked_by_uid": user["uid"]},
|
||||
"user", user["uid"],
|
||||
title,
|
||||
)
|
||||
return JSONResponse({"uid": uid, "status_url": f"/forks/{uid}"})
|
||||
|
||||
|
||||
@router.post("/delete/{project_slug}")
|
||||
async def delete_project(request: Request, project_slug: str):
|
||||
user = require_user(request)
|
||||
|
||||
@@ -19,10 +19,39 @@ router = APIRouter()
|
||||
HOP_HEADERS = {
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade", "host", "content-length",
|
||||
"content-encoding",
|
||||
}
|
||||
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||
|
||||
|
||||
def _forward_headers(request: Request, prefix: str) -> dict:
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
|
||||
headers["X-Forwarded-Prefix"] = prefix
|
||||
headers["X-Script-Name"] = prefix
|
||||
headers["X-Forwarded-Host"] = request.headers.get("host", request.url.hostname or "")
|
||||
headers["X-Forwarded-Proto"] = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
return headers
|
||||
|
||||
|
||||
def _inject_base(body: bytes, prefix: str) -> bytes:
|
||||
lowered = body.lower()
|
||||
if b"<base" in lowered:
|
||||
return body
|
||||
tag = f'<base href="{prefix}/">'.encode()
|
||||
head = lowered.find(b"<head")
|
||||
anchor = lowered.find(b">", head) if head != -1 else lowered.find(b">", lowered.find(b"<html"))
|
||||
if anchor == -1:
|
||||
return tag + body
|
||||
return body[: anchor + 1] + tag + body[anchor + 1 :]
|
||||
|
||||
|
||||
def _rewrite_location(value: str, prefix: str) -> str:
|
||||
if value.startswith("/") and not value.startswith("//"):
|
||||
return prefix + value
|
||||
return value
|
||||
|
||||
|
||||
def _resolve(slug: str):
|
||||
instance = store.find_instance_by_ingress(slug)
|
||||
if not instance or instance.get("status") != store.ST_RUNNING:
|
||||
@@ -47,18 +76,29 @@ async def proxy_http(request: Request, slug: str, path: str = ""):
|
||||
raise not_found("No running container is published at this address")
|
||||
if port is None:
|
||||
return Response("the published container has no reachable port", status_code=502)
|
||||
prefix = f"/p/{slug}"
|
||||
url = f"http://{config.CONTAINER_PROXY_HOST}:{port}/{path}"
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
|
||||
headers = _forward_headers(request, prefix)
|
||||
body = await request.body()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
async with httpx.AsyncClient(timeout=60.0, follow_redirects=False) as client:
|
||||
upstream = await client.request(
|
||||
request.method, url, params=request.query_params, headers=headers, content=body)
|
||||
except httpx.HTTPError as exc:
|
||||
return Response(f"upstream error: {exc}", status_code=502)
|
||||
out_headers = {k: v for k, v in upstream.headers.items() if k.lower() not in HOP_HEADERS}
|
||||
return Response(content=upstream.content, status_code=upstream.status_code,
|
||||
headers=out_headers, media_type=upstream.headers.get("content-type"))
|
||||
out_headers = {k: v for k, v in upstream.headers.items()
|
||||
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"}
|
||||
if "location" in out_headers:
|
||||
out_headers["location"] = _rewrite_location(out_headers["location"], prefix)
|
||||
content_type = upstream.headers.get("content-type", "")
|
||||
content = upstream.content
|
||||
if "text/html" in content_type.lower():
|
||||
content = _inject_base(content, prefix)
|
||||
response = Response(content=content, status_code=upstream.status_code,
|
||||
headers=out_headers, media_type=content_type or None)
|
||||
for cookie in upstream.headers.get_list("set-cookie"):
|
||||
response.headers.append("set-cookie", cookie)
|
||||
return response
|
||||
|
||||
|
||||
@router.websocket("/{slug}")
|
||||
|
||||
+16
-32
@@ -34,40 +34,9 @@ class ValidationErrorOut(_Out):
|
||||
messages: list = []
|
||||
|
||||
|
||||
class DockerfileOut(_Out):
|
||||
uid: str = ""
|
||||
project_uid: str = ""
|
||||
name: str = ""
|
||||
slug: str = ""
|
||||
description: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
current_version: int = 0
|
||||
latest_build_number: int = 0
|
||||
status: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class BuildOut(_Out):
|
||||
uid: str = ""
|
||||
dockerfile_uid: str = ""
|
||||
build_number: int = 0
|
||||
image_tag: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
image_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
duration_ms: int = 0
|
||||
queued_at: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class InstanceOut(_Out):
|
||||
uid: str = ""
|
||||
project_uid: str = ""
|
||||
dockerfile_uid: str = ""
|
||||
build_uid: str = ""
|
||||
name: str = ""
|
||||
slug: str = ""
|
||||
container_id: Optional[str] = None
|
||||
@@ -97,7 +66,6 @@ class ScheduleOut(_Out):
|
||||
|
||||
class ContainersOut(_Out):
|
||||
project: Optional[dict] = None
|
||||
dockerfiles: list[DockerfileOut] = []
|
||||
instances: list[InstanceOut] = []
|
||||
|
||||
|
||||
@@ -117,6 +85,20 @@ class ZipJobOut(_Out):
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class ForkJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
preferred_name: Optional[str] = None
|
||||
project_uid: Optional[str] = None
|
||||
project_url: Optional[str] = None
|
||||
source_project_uid: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
item_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- shared resource projections ----------
|
||||
|
||||
class UserOut(_Out):
|
||||
@@ -467,6 +449,8 @@ class ProjectDetailOut(_Out):
|
||||
platforms: Optional[Any] = None
|
||||
is_private: bool = False
|
||||
read_only: bool = False
|
||||
forked_from: Optional[dict] = None
|
||||
fork_count: int = 0
|
||||
|
||||
|
||||
class GistsOut(_Out):
|
||||
|
||||
@@ -10,11 +10,9 @@ import httpx
|
||||
|
||||
from devplacepy import config, project_files
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.locks import NUMBERING_LOCK
|
||||
from devplacepy.services.containers.templates import DEFAULT_DOCKERFILE
|
||||
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
from devplacepy.services.devii.tasks.schedule import Schedule, next_run, now_utc, to_iso, from_iso
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
IMAGE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
|
||||
INGRESS_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
@@ -31,13 +29,6 @@ class ContainerError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_image_name(name: str) -> str:
|
||||
name = (name or "").strip().lower()
|
||||
if not IMAGE_NAME_RE.match(name):
|
||||
raise ContainerError("name must be lowercase letters, digits, '.', '_' or '-' (max 63 chars)")
|
||||
return name
|
||||
|
||||
|
||||
def _validate_limits(cpu_limit: str, mem_limit: str) -> None:
|
||||
if cpu_limit and not CPU_RE.match(str(cpu_limit)):
|
||||
raise ContainerError("cpu limit must be a number, e.g. 1 or 1.5")
|
||||
@@ -126,41 +117,6 @@ def parse_env(value) -> dict:
|
||||
return env
|
||||
|
||||
|
||||
# ---------------- dockerfiles + builds ----------------
|
||||
|
||||
async def enqueue_build(dockerfile: dict, version: dict, *, also_latest: bool = True, owner=("system", "system")) -> dict:
|
||||
async with NUMBERING_LOCK:
|
||||
head = store.get_dockerfile(dockerfile["uid"])
|
||||
build_number = int(head.get("latest_build_number") or 0) + 1
|
||||
store.update_dockerfile(head["uid"], {"latest_build_number": build_number})
|
||||
image_tag = f"{head['name']}:{build_number}"
|
||||
build = store.create_build(head, version, build_number, image_tag, also_latest)
|
||||
job_uid = queue.enqueue("container_build", {"build_uid": build["uid"]}, owner[0], owner[1], f"build {image_tag}")
|
||||
store.update_build(build["uid"], {"job_uid": job_uid})
|
||||
return store.get_build(build["uid"])
|
||||
|
||||
|
||||
async def create_dockerfile(project: dict, user: dict, *, name: str, description: str = "",
|
||||
tags: str = "", content: str = None) -> dict:
|
||||
name = validate_image_name(name)
|
||||
for existing in store.list_dockerfiles(project["uid"]):
|
||||
if existing["name"] == name:
|
||||
raise ContainerError(f"a Dockerfile named '{name}' already exists in this project")
|
||||
content = content if content is not None and content.strip() else DEFAULT_DOCKERFILE
|
||||
dockerfile = store.create_dockerfile(project["uid"], user, name, description, tags)
|
||||
version = store.create_version(dockerfile, content, user)
|
||||
build = await enqueue_build(dockerfile, version, owner=("user", user["uid"]))
|
||||
return {"dockerfile": store.get_dockerfile(dockerfile["uid"]), "version": version, "build": build}
|
||||
|
||||
|
||||
async def save_version(dockerfile: dict, user: dict, content: str, *, force: bool = False) -> dict:
|
||||
if not force and store.content_hash(content) == dockerfile.get("content_hash"):
|
||||
return {"version": None, "build": None, "changed": False}
|
||||
version = store.create_version(dockerfile, content, user)
|
||||
build = await enqueue_build(store.get_dockerfile(dockerfile["uid"]), version, owner=("user", user["uid"]))
|
||||
return {"version": version, "build": build, "changed": True}
|
||||
|
||||
|
||||
# ---------------- instances ----------------
|
||||
|
||||
def validate_ingress(slug: str, port, port_list) -> tuple:
|
||||
@@ -181,13 +137,13 @@ def validate_ingress(slug: str, port, port_list) -> tuple:
|
||||
return slug, ingress_port
|
||||
|
||||
|
||||
async def create_instance(project: dict, dockerfile: dict, build: dict, *, name: str,
|
||||
async def create_instance(project: dict, *, name: str,
|
||||
boot_command: str = "", env="", cpu_limit: str = "", mem_limit: str = "",
|
||||
ports="", volumes="", restart_policy: str = "never",
|
||||
autostart: bool = True, ingress_slug: str = "", ingress_port=None,
|
||||
actor=("system", "system")) -> dict:
|
||||
if build.get("status") != store.BUILD_SUCCESS:
|
||||
raise ContainerError("the selected build has not completed successfully")
|
||||
if not await get_backend().image_exists(config.CONTAINER_IMAGE):
|
||||
raise ContainerError(f"the '{config.CONTAINER_IMAGE}' image is not built - run 'make ppy'")
|
||||
if restart_policy not in store.RESTART_POLICIES:
|
||||
raise ContainerError(f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}")
|
||||
_validate_limits(cpu_limit, mem_limit)
|
||||
@@ -202,7 +158,9 @@ async def create_instance(project: dict, dockerfile: dict, build: dict, *, name:
|
||||
await asyncio.to_thread(project_files.export_to_dir, project["uid"], "", str(workspace))
|
||||
|
||||
row = {
|
||||
"project_uid": project["uid"], "dockerfile_uid": dockerfile["uid"], "build_uid": build["uid"],
|
||||
"project_uid": project["uid"],
|
||||
"created_by": actor[1] if actor and actor[0] == "user" else "",
|
||||
"owner_uid": project.get("user_uid", ""),
|
||||
"name": name, "boot_command": boot_command or "",
|
||||
"env_json": json.dumps(env_map),
|
||||
"cpu_limit": str(cpu_limit or ""), "mem_limit": str(mem_limit or ""),
|
||||
@@ -215,7 +173,7 @@ async def create_instance(project: dict, dockerfile: dict, build: dict, *, name:
|
||||
"workspace_dir": str(workspace),
|
||||
}
|
||||
instance = store.create_instance(row)
|
||||
store.record_event(instance, "created", actor[0], actor[1], {"build": build["uid"]})
|
||||
store.record_event(instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE})
|
||||
return instance
|
||||
|
||||
|
||||
@@ -238,18 +196,40 @@ def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
|
||||
store.record_event(instance, "remove", actor[0], actor[1])
|
||||
|
||||
|
||||
def pravda_env(instance: dict) -> dict:
|
||||
from devplacepy import database, seo
|
||||
base_url = seo.public_base_url()
|
||||
api_key = ""
|
||||
for uid in (instance.get("created_by"), instance.get("owner_uid")):
|
||||
if not uid:
|
||||
continue
|
||||
user = database.get_users_by_uids([uid]).get(uid)
|
||||
if user and user.get("api_key"):
|
||||
api_key = user["api_key"]
|
||||
break
|
||||
slug = instance.get("ingress_slug") or ""
|
||||
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
|
||||
return {
|
||||
"PRAVDA_BASE_URL": base_url,
|
||||
"PRAVDA_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
|
||||
"PRAVDA_API_KEY": api_key,
|
||||
"PRAVDA_USER_UID": instance.get("owner_uid") or "",
|
||||
"PRAVDA_CONTAINER_NAME": instance.get("name") or "",
|
||||
"PRAVDA_CONTAINER_UID": instance.get("uid") or "",
|
||||
"PRAVDA_INGRESS_URL": ingress_url,
|
||||
}
|
||||
|
||||
|
||||
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
|
||||
env = json.loads(instance.get("env_json") or "{}")
|
||||
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
|
||||
ports = [PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
|
||||
for p in json.loads(instance.get("ports_json") or "[]")]
|
||||
mounts = [Mount(instance["workspace_dir"], "/app", "rw")]
|
||||
for extra in json.loads(instance.get("volumes_json") or "[]"):
|
||||
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
|
||||
mounts.append(Mount(extra["host"], extra["container"], extra.get("mode", "rw")))
|
||||
command = []
|
||||
boot = (instance.get("boot_command") or "").strip()
|
||||
if boot:
|
||||
command = ["/bin/sh", "-c", boot]
|
||||
command = ["/bin/sh", "-c", boot] if boot else ["sleep", "infinity"]
|
||||
return RunSpec(
|
||||
image=image_tag, name=instance["slug"],
|
||||
labels={INSTANCE_LABEL: instance["uid"], PROJECT_LABEL: instance["project_uid"]},
|
||||
@@ -284,21 +264,6 @@ def _percentile(values: list, pct: float) -> float:
|
||||
return float(ordered[index])
|
||||
|
||||
|
||||
def dockerfile_stats(dockerfile_uid: str) -> dict:
|
||||
builds = store.list_builds(dockerfile_uid, limit=1000)
|
||||
done = [b for b in builds if b["status"] in (store.BUILD_SUCCESS, store.BUILD_FAILED)]
|
||||
success = [b for b in done if b["status"] == store.BUILD_SUCCESS]
|
||||
durations = [int(b.get("duration_ms") or 0) for b in success if b.get("duration_ms")]
|
||||
return {
|
||||
"total_builds": len(builds),
|
||||
"success": len(success),
|
||||
"failed": len(done) - len(success),
|
||||
"success_rate": round(len(success) / len(done) * 100, 1) if done else 0.0,
|
||||
"avg_build_ms": int(sum(durations) / len(durations)) if durations else 0,
|
||||
"p95_build_ms": int(_percentile(durations, 95)),
|
||||
}
|
||||
|
||||
|
||||
def instance_stats(instance_uid: str) -> dict:
|
||||
metrics = store.recent_metrics(instance_uid, limit=720)
|
||||
cpu = [m.get("cpu_pct", 0) for m in metrics]
|
||||
|
||||
@@ -116,3 +116,9 @@ class Backend(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def image_prune(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def remove_image(self, ref: str, force: bool = False) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def image_exists(self, ref: str) -> bool: ...
|
||||
|
||||
@@ -265,3 +265,16 @@ class DockerCliBackend(Backend):
|
||||
await self._run([self._binary, "image", "prune", "-f", "--filter", "label=devplace.build"], timeout=120)
|
||||
except RuntimeError as exc:
|
||||
logger.warning("image prune failed: %s", exc)
|
||||
|
||||
async def remove_image(self, ref: str, force: bool = False) -> None:
|
||||
argv = [self._binary, "rmi"]
|
||||
if force:
|
||||
argv.append("-f")
|
||||
argv.append(ref)
|
||||
code, _, err = await self._run(argv, timeout=60)
|
||||
if code != 0 and "No such image" not in (err or ""):
|
||||
logger.warning("rmi %s failed: %s", ref, (err or "").strip()[:200])
|
||||
|
||||
async def image_exists(self, ref: str) -> bool:
|
||||
code, _, _ = await self._run([self._binary, "image", "inspect", ref], timeout=30)
|
||||
return code == 0
|
||||
|
||||
@@ -17,6 +17,7 @@ class FakeBackend(Backend):
|
||||
self.containers: dict = {}
|
||||
self.built_images: list = []
|
||||
self.removed: list = []
|
||||
self.removed_images: list = []
|
||||
self.pruned = 0
|
||||
self._counter = 0
|
||||
self.fail_build = False
|
||||
@@ -97,3 +98,9 @@ class FakeBackend(Backend):
|
||||
|
||||
async def image_prune(self) -> None:
|
||||
self.pruned += 1
|
||||
|
||||
async def remove_image(self, ref: str, force: bool = False) -> None:
|
||||
self.removed_images.append(ref)
|
||||
|
||||
async def image_exists(self, ref: str) -> bool:
|
||||
return True
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy import config, project_files
|
||||
from devplacepy.services.base import ConfigField
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
from devplacepy.services.jobs.base import JobService
|
||||
|
||||
LOG_CAP = 256 * 1024
|
||||
|
||||
|
||||
def _cap(text: str) -> str:
|
||||
return text[-LOG_CAP:]
|
||||
|
||||
|
||||
class ContainerBuildService(JobService):
|
||||
kind = "container_build"
|
||||
default_enabled = False
|
||||
title = "Container builds"
|
||||
description = (
|
||||
"Builds container images for project Dockerfiles asynchronously via the docker CLI, "
|
||||
"streaming logs and recording every build in the permanent build history."
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="container_build", interval_seconds=2)
|
||||
self.config_fields = self.config_fields + [
|
||||
ConfigField("container_build_network", "Build network", type="str", default="host",
|
||||
help="docker build --network value so the builder can reach the internet "
|
||||
"(e.g. 'host' for PyPI access). Empty uses the docker default.",
|
||||
group="Builds"),
|
||||
]
|
||||
|
||||
async def process(self, job: dict) -> dict:
|
||||
build_uid = job["payload"].get("build_uid")
|
||||
build = store.get_build(build_uid)
|
||||
if build is None:
|
||||
raise RuntimeError("build row missing")
|
||||
if build["status"] == store.BUILD_CANCELLED:
|
||||
return {}
|
||||
version = store.get_version(build["dockerfile_version_uid"])
|
||||
dockerfile = store.get_dockerfile(build["dockerfile_uid"])
|
||||
if version is None or dockerfile is None:
|
||||
store.update_build(build_uid, {"status": store.BUILD_FAILED, "error": "version or dockerfile missing"})
|
||||
return {}
|
||||
|
||||
store.update_build(build_uid, {"status": store.BUILD_BUILDING, "started_at": store.now()})
|
||||
ctx = Path(config.CONTAINER_BUILD_CONTEXTS_DIR) / build_uid
|
||||
await asyncio.to_thread(project_files.export_to_dir, build["project_uid"], "", str(ctx))
|
||||
|
||||
lines: list = []
|
||||
|
||||
async def on_log(line: str) -> None:
|
||||
lines.append(line)
|
||||
if len(lines) % 20 == 0:
|
||||
store.update_build(build_uid, {"logs": _cap("\n".join(lines))})
|
||||
|
||||
tags = [build["image_tag"]]
|
||||
if build.get("also_latest"):
|
||||
tags.append(f"{dockerfile['name']}:latest")
|
||||
|
||||
network = str(self.get_config().get("container_build_network", "host") or "")
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result = await get_backend().build(
|
||||
context_dir=str(ctx), dockerfile_text=version["content"], tags=tags, on_log=on_log, network=network)
|
||||
except Exception as exc:
|
||||
result = None
|
||||
error = str(exc)
|
||||
else:
|
||||
error = result.error
|
||||
finally:
|
||||
await asyncio.to_thread(shutil.rmtree, str(ctx), ignore_errors=True)
|
||||
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
logs = _cap("\n".join(lines))
|
||||
|
||||
latest = store.get_build(build_uid)
|
||||
if latest and latest["status"] == store.BUILD_CANCELLED:
|
||||
store.update_build(build_uid, {"logs": logs, "completed_at": store.now(), "duration_ms": duration_ms})
|
||||
self.log(f"build {build['image_tag']} cancelled")
|
||||
return {"item_count": len(lines)}
|
||||
|
||||
if result is not None and result.success:
|
||||
store.update_build(build_uid, {
|
||||
"status": store.BUILD_SUCCESS, "image_id": result.image_id, "logs": logs,
|
||||
"completed_at": store.now(), "duration_ms": duration_ms,
|
||||
})
|
||||
self.log(f"build {build['image_tag']} succeeded ({duration_ms}ms)")
|
||||
else:
|
||||
store.update_build(build_uid, {
|
||||
"status": store.BUILD_FAILED, "error": (error or "build failed")[:2000], "logs": logs,
|
||||
"completed_at": store.now(), "duration_ms": duration_ms,
|
||||
})
|
||||
self.log(f"build {build['image_tag']} failed")
|
||||
return {"item_count": len(lines)}
|
||||
@@ -0,0 +1,245 @@
|
||||
et nocompatible
|
||||
filetype off
|
||||
set rtp+=~/.vim/bundle/Vundle.vim
|
||||
|
||||
let g:ycm_auto_trigger = 1
|
||||
|
||||
filetype plugin indent on " required
|
||||
|
||||
set clipboard=unnamedplus
|
||||
|
||||
function! GitBranch()
|
||||
let l:branch = system("bash -c 'uptime'")
|
||||
return l:branch
|
||||
endfunction
|
||||
|
||||
call plug#begin()
|
||||
" List your plugins here
|
||||
Plug 'prabirshrestha/asyncomplete.vim'
|
||||
Plug 'prabirshrestha/asyncomplete-lsp.vim'
|
||||
Plug 'tpope/vim-sensible'
|
||||
Plug 'Exafunction/codeium.vim', { 'branch': 'main' }
|
||||
Plug 'prabirshrestha/vim-lsp'
|
||||
Plug 'mattn/vim-lsp-settings'
|
||||
set statusline=\{…\}%3{codeium#GetStatusString()}\%h%m%r\ [%l,%c]\ %p%%
|
||||
highlight StatusLine guifg=#ffffff guibg=#005f87
|
||||
highlight StatusLineNC guifg=#aaaaaa guibg=#303030
|
||||
highlight ErrorMsg ctermfg=Red ctermbg=NONE guifg=#FF0000 guibg=NONE
|
||||
|
||||
call plug#end()
|
||||
|
||||
call vundle#begin()
|
||||
Plugin 'VundleVim/Vundle.vim'
|
||||
Plugin 'ycm-core/YouCompleteMe'
|
||||
call vundle#end()
|
||||
|
||||
set mouse=a
|
||||
set backspace=indent,eol,start
|
||||
syntax on
|
||||
filetype plugin indent on
|
||||
|
||||
set showtabline=2
|
||||
set enc=utf-8
|
||||
set fenc=utf-8
|
||||
set termencoding=utf-8
|
||||
set autoindent
|
||||
set smartindent
|
||||
set tabstop=4
|
||||
set shiftwidth=4
|
||||
set expandtab
|
||||
set number
|
||||
set showmatch
|
||||
set comments=sl:/*,mb:\ *,elx:\ */
|
||||
|
||||
let mapleader = ","
|
||||
|
||||
function! ShowInTerminal(content)
|
||||
" Open a new terminal split
|
||||
belowright split | terminal
|
||||
" Wait for terminal to initialize
|
||||
call feedkeys("i") " go into insert mode to send keys
|
||||
call chansend(b:terminal_job_id, a:content . "\n")
|
||||
endfunction
|
||||
|
||||
function! AIR()
|
||||
let l:ai_prompt = input("Enter AI instructions: ")
|
||||
let $AI_PROMPT = l:ai_prompt
|
||||
let l:result = system('r ' . l:ai_prompt)
|
||||
call ShowInTerminal(l:result)
|
||||
|
||||
endfunction
|
||||
|
||||
function! AIPromptSelection()
|
||||
" Save cursor position and the unnamed register
|
||||
let l:pos = getpos('.')
|
||||
let l:save_reg = @"
|
||||
let l:save_regtype = getregtype('"')
|
||||
|
||||
" Yank the visual selection into the unnamed register
|
||||
normal! gvy
|
||||
let l:selected_text = @"
|
||||
|
||||
" Restore the unnamed register
|
||||
call setreg('"', l:save_reg, l:save_regtype)
|
||||
|
||||
" Prompt the user for AI instructions
|
||||
let l:ai_prompt = input('Enter AI instructions: ')
|
||||
let $AI_PROMPT = l:ai_prompt
|
||||
|
||||
" Call your external AI formatter (replace `c` with your command)
|
||||
let l:formatted = system('c', l:selected_text)
|
||||
|
||||
" Handle errors or replace the selection with the AI response
|
||||
if v:shell_error
|
||||
echohl ErrorMsg | echom 'Formatting failed' | echohl None
|
||||
else
|
||||
" Delete the original selection
|
||||
normal! gvd
|
||||
" Insert the formatted text *before* the cursor (same spot)
|
||||
put! =l:formatted
|
||||
" Restore the original cursor position
|
||||
call setpos('.', l:pos)
|
||||
endif
|
||||
|
||||
" Restore the unnamed register again (good measure)
|
||||
call setreg('"', l:save_reg, l:save_regtype)
|
||||
endfunction
|
||||
|
||||
" Map the AI prompt function to a key combination
|
||||
vnoremap <Leader>f :<C-u>call AIPromptSelection()<CR>
|
||||
nnoremap <Leader>r :call AIR()<CR>
|
||||
|
||||
" Existing keymappings and other configurations...
|
||||
inoremap <C-n> <ESC>:tabnext<CR>
|
||||
inoremap <C-p> <ESC>:tabnext<CR>
|
||||
nnoremap <C-n> :tabnext<CR>
|
||||
nnoremap <C-p> :tabnext<CR>
|
||||
nnoremap <Tab> :tabnext<CR>
|
||||
nnoremap <C-Tab> :tabprevious<CR>
|
||||
inoremap <C-t> <ESC>:terminal<CR><CR><C-w>r<CR><C-w>-<C-w>-<C-w>-<C-w>-<C-w>-<C-w>-<C-w>-
|
||||
nnoremap <C-e> :tabnew<Space>
|
||||
inoremap <C-e> <ESC>:tabnew<Space>
|
||||
nnoremap e :tabnew<Space>
|
||||
nnoremap q <ESC>:q<CR>
|
||||
|
||||
set undofile
|
||||
set undodir=~/.vim/undo
|
||||
|
||||
" Existing commands and other configurations...
|
||||
command! GPT py3file ~/bin/gpt
|
||||
command! Refactor py3file /home/retoor/.vim/plugin/refactor.py
|
||||
command! HelloVim py3file ~/.vim/plugin/hello.py | py3 say_hello()
|
||||
|
||||
if has("autocmd")
|
||||
au BufReadPost * if line("'\"") > 0 && line("'\"") <= line("$") | exe "normal! g'\"" | endif
|
||||
endif
|
||||
|
||||
execute pathogen#infect()
|
||||
execute pathogen#helptags()
|
||||
|
||||
" Helper: get visually selected text
|
||||
function! GetVisualSelection()
|
||||
let [line_start, col_start] = [line("'<"), col("'<")]
|
||||
let [line_end, col_end] = [line("'>"), col("'>")]
|
||||
let lines = getline(line_start, line_end)
|
||||
if len(lines) == 0
|
||||
return ''
|
||||
endif
|
||||
" Trim col for first/last line if selection is characterwise
|
||||
let lines[0] = lines[0][col_start - 1 :]
|
||||
let lines[-1] = lines[-1][: col_end - (line_start == line_end ? 1 : 2)]
|
||||
return join(lines, "\n")
|
||||
endfunction
|
||||
|
||||
function! AiEditSelection()
|
||||
" (1) Get user prompt
|
||||
let l:instruction = input("AI instruction: ")
|
||||
if empty(l:instruction)
|
||||
echo "No instruction given, cancelled."
|
||||
return
|
||||
endif
|
||||
|
||||
" (2) Get visual selection
|
||||
let l:origText = GetVisualSelection()
|
||||
if empty(l:origText)
|
||||
echo "No selection."
|
||||
return
|
||||
endif
|
||||
|
||||
" (3) Compose the API prompt
|
||||
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:origText . "\n\nOutput only the transformed text. Do not include explanations, markdown, or code blocks."
|
||||
|
||||
" (4) Send to the same gateway pagent uses (PRAVDA_OPENAI_URL / PRAVDA_API_KEY)
|
||||
let l:base = substitute($PRAVDA_OPENAI_URL, '/\+$', '', '')
|
||||
if empty(l:base)
|
||||
let l:url = 'https://openai.app.molodetz.nl/v1/chat/completions'
|
||||
elseif l:base =~# '/chat/completions$'
|
||||
let l:url = l:base
|
||||
else
|
||||
let l:url = l:base . '/chat/completions'
|
||||
endif
|
||||
let l:api_key = !empty($PRAVDA_API_KEY) ? $PRAVDA_API_KEY : $DEEPSEEK_API_KEY
|
||||
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":'.json_encode(l:prompt).'}]}'
|
||||
let l:cmd = 'curl -sS -X POST ' . shellescape(l:url) . ' -H "Authorization: Bearer ' . l:api_key . '" -H "Content-Type: application/json" -d ' . shellescape(l:json)
|
||||
let l:reply = system(l:cmd)
|
||||
" --- Extract output (simple JSON parsing) ---
|
||||
let l:text = matchstr(l:reply, '"content":\s*"\zs\(.\{-}\)\ze"\s*}')
|
||||
let l:text = substitute(l:text, '\\n', "\n", 'g')
|
||||
let l:text = substitute(l:text, '\\"', '"', 'g')
|
||||
|
||||
" (5) Replace selected text
|
||||
normal! gv
|
||||
" Use c to change or d (delete and then 'put') depending on visual mode
|
||||
normal! c
|
||||
call feedkeys(l:text, 'n')
|
||||
endfunction
|
||||
|
||||
" Visual mode mapping
|
||||
xnoremap <silent> <Leader>a :<C-u>call AiEditSelection()<CR>
|
||||
|
||||
command! GreetPython py3 greet()
|
||||
vnoremap <leader>gr :GPTRefactor<CR>
|
||||
vnoremap <leader>gd :GPTRefactorDefault<CR>
|
||||
|
||||
|
||||
if executable('pylsp')
|
||||
" pip install python-lsp-server
|
||||
au User lsp_setup call lsp#register_server({
|
||||
\ 'name': 'pylsp',
|
||||
\ 'cmd': {server_info->['pylsp']},
|
||||
\ 'allowlist': ['python'],
|
||||
\ })
|
||||
endif
|
||||
|
||||
function! s:on_lsp_buffer_enabled() abort
|
||||
setlocal omnifunc=lsp#complete
|
||||
setlocal signcolumn=yes
|
||||
if exists('+tagfunc') | setlocal tagfunc=lsp#tagfunc | endif
|
||||
nmap <buffer> gd <plug>(lsp-definition)
|
||||
nmap <buffer> gs <plug>(lsp-document-symbol-search)
|
||||
nmap <buffer> gS <plug>(lsp-workspace-symbol-search)
|
||||
nmap <buffer> gr <plug>(lsp-references)
|
||||
nmap <buffer> gi <plug>(lsp-implementation)
|
||||
nmap <buffer> gt <plug>(lsp-type-definition)
|
||||
nmap <buffer> <leader>rn <plug>(lsp-rename)
|
||||
nmap <buffer> [g <plug>(lsp-previous-diagnostic)
|
||||
nmap <buffer> ]g <plug>(lsp-next-diagnostic)
|
||||
nmap <buffer> K <plug>(lsp-hover)
|
||||
nnoremap <buffer> <expr><c-f> lsp#scroll(+4)
|
||||
nnoremap <buffer> <expr><c-d> lsp#scroll(-4)
|
||||
|
||||
let g:lsp_format_sync_timeout = 1000
|
||||
autocmd! BufWritePre *.rs,*.go call execute('LspDocumentFormatSync')
|
||||
|
||||
" refer to doc to add more commands
|
||||
endfunction
|
||||
|
||||
augroup lsp_install
|
||||
au!
|
||||
" call s:on_lsp_buffer_enabled only for languages that has the server registered.
|
||||
autocmd User lsp_buffer_enabled call s:on_lsp_buffer_enabled()
|
||||
augroup END
|
||||
|
||||
let g:lsp_document_highlight_enabled = 1
|
||||
let g:lsp_diagnostics_enabled = 0 " disable diagnostics support
|
||||
|
||||
Executable
+2669
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
#!/bin/sh
|
||||
# devplace sudo superclone: sudo-compatible CLI, no privilege change.
|
||||
# It never swaps user; it executes the command as the current user (pravda),
|
||||
# so nothing a container does can ever create a root-owned file on the host.
|
||||
|
||||
_dp_usage() {
|
||||
cat <<'EOF'
|
||||
usage: sudo -h | -K | -k | -V
|
||||
usage: sudo -v [-ABkNnS] [-g group] [-h host] [-p prompt] [-u user]
|
||||
usage: sudo -l [-ABkNnS] [-g group] [-h host] [-p prompt] [-U user] [-u user] [command]
|
||||
usage: sudo [-ABbEHnPS] [-C num] [-g group] [-h host] [-p prompt] [-R dir] [-T timeout] [-u user] [VAR=value] [-i | -s] [command [arg ...]]
|
||||
EOF
|
||||
}
|
||||
|
||||
_dp_login=0
|
||||
_dp_shell=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--) shift; break ;;
|
||||
-V|--version) echo "Sudo version 1.9.15p5"; echo "Sudoers policy plugin version 1.9.15p5"; exit 0 ;;
|
||||
--help) _dp_usage; exit 0 ;;
|
||||
-K|-k|--remove-timestamp|--reset-timestamp) exit 0 ;;
|
||||
-v|--validate) exit 0 ;;
|
||||
-l|-ll|--list) echo "User $(id -un 2>/dev/null) may run any command"; exit 0 ;;
|
||||
-i|--login) _dp_login=1; shift ;;
|
||||
-s|--shell) _dp_shell=1; shift ;;
|
||||
--user=*|--group=*|--prompt=*|--chdir=*|--chroot=*|--host=*|--role=*|--type=*|--other-user=*|--command-timeout=*|--close-from=*|--preserve-env=*) shift ;;
|
||||
-A|--askpass|-b|--background|-B|--bell|-E|--preserve-env|-H|--set-home|-n|--non-interactive|-N|--no-update|-P|--preserve-groups|-S|--stdin) shift ;;
|
||||
-u|--user|-g|--group|-p|--prompt|-C|--close-from|-D|--chdir|-R|--chroot|-T|--command-timeout|-U|--other-user|-r|--role|-t|--type|-c|--class|-h|--host)
|
||||
if [ $# -ge 2 ]; then shift 2; else shift; fi ;;
|
||||
-*) shift ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
_dp_env=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
[A-Za-z_]*=*) _dp_env="$_dp_env $1"; shift ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SUDO_USER="$(id -un 2>/dev/null)"; export SUDO_USER
|
||||
SUDO_UID="$(id -u 2>/dev/null)"; export SUDO_UID
|
||||
SUDO_GID="$(id -g 2>/dev/null)"; export SUDO_GID
|
||||
SUDO_COMMAND="$*"; export SUDO_COMMAND
|
||||
|
||||
if [ "$_dp_login" -eq 1 ] || [ "$_dp_shell" -eq 1 ]; then
|
||||
_dp_sh="${SHELL:-/bin/sh}"
|
||||
if [ $# -gt 0 ]; then
|
||||
[ -n "$_dp_env" ] && exec env $_dp_env "$_dp_sh" -c "$*"
|
||||
exec "$_dp_sh" -c "$*"
|
||||
fi
|
||||
[ -n "$_dp_env" ] && exec env $_dp_env "$_dp_sh"
|
||||
exec "$_dp_sh"
|
||||
fi
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
_dp_usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -n "$_dp_env" ] && exec env $_dp_env "$@"
|
||||
exec "$@"
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
|
||||
from devplacepy import config
|
||||
from devplacepy.services.base import BaseService, ConfigField
|
||||
from devplacepy.services.containers import api, store
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
@@ -106,13 +107,7 @@ class ContainerService(BaseService):
|
||||
store.update_instance(uid, {"status": store.ST_PAUSED})
|
||||
|
||||
async def _launch(self, backend, inst) -> None:
|
||||
build = store.get_build(inst["build_uid"])
|
||||
if build is None or build.get("status") != store.BUILD_SUCCESS:
|
||||
store.update_instance(inst["uid"], {"status": store.ST_CRASHED})
|
||||
store.record_event(inst, "launch_failed", "reconciler", "", {"reason": "no successful build"})
|
||||
self.log(f"instance {inst['name']} has no successful build")
|
||||
return
|
||||
spec = api.run_spec_for(inst, build["image_tag"])
|
||||
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
|
||||
try:
|
||||
cid = await backend.run(spec)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.database import db, get_table
|
||||
from devplacepy.utils import generate_uid, make_combined_slug
|
||||
|
||||
DF_ACTIVE = "active"
|
||||
DF_ARCHIVED = "archived"
|
||||
|
||||
BUILD_QUEUED = "queued"
|
||||
BUILD_BUILDING = "building"
|
||||
BUILD_SUCCESS = "success"
|
||||
BUILD_FAILED = "failed"
|
||||
BUILD_CANCELLED = "cancelled"
|
||||
|
||||
ST_CREATED = "created"
|
||||
ST_STARTING = "starting"
|
||||
ST_RUNNING = "running"
|
||||
@@ -38,114 +28,10 @@ def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def content_hash(text: str) -> str:
|
||||
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _exists(table: str) -> bool:
|
||||
return table in db.tables
|
||||
|
||||
|
||||
# ---------------- dockerfiles ----------------
|
||||
|
||||
def create_dockerfile(project_uid, user, name, description, tags) -> dict:
|
||||
uid = generate_uid()
|
||||
row = {
|
||||
"uid": uid, "project_uid": project_uid, "user_uid": user["uid"],
|
||||
"name": name, "slug": make_combined_slug(name, uid),
|
||||
"description": description or "", "tags": tags or "", "author": user.get("username", ""),
|
||||
"current_version": 0, "latest_build_number": 0,
|
||||
"content_hash": "", "status": DF_ACTIVE,
|
||||
"created_at": now(), "updated_at": now(),
|
||||
}
|
||||
get_table("dockerfiles").insert(row)
|
||||
return get_dockerfile(uid)
|
||||
|
||||
|
||||
def get_dockerfile(uid_or_slug: str):
|
||||
table = get_table("dockerfiles")
|
||||
if not _exists("dockerfiles"):
|
||||
return None
|
||||
return table.find_one(uid=uid_or_slug) or table.find_one(slug=uid_or_slug)
|
||||
|
||||
|
||||
def list_dockerfiles(project_uid: str) -> list:
|
||||
if not _exists("dockerfiles"):
|
||||
return []
|
||||
return sorted(get_table("dockerfiles").find(project_uid=project_uid),
|
||||
key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
|
||||
|
||||
def update_dockerfile(uid: str, changes: dict) -> None:
|
||||
get_table("dockerfiles").update({"uid": uid, "updated_at": now(), **changes}, ["uid"])
|
||||
|
||||
|
||||
# ---------------- versions (immutable) ----------------
|
||||
|
||||
def create_version(dockerfile: dict, content: str, user: dict) -> dict:
|
||||
version = int(dockerfile.get("current_version") or 0) + 1
|
||||
uid = generate_uid()
|
||||
get_table("dockerfile_versions").insert({
|
||||
"uid": uid, "dockerfile_uid": dockerfile["uid"], "project_uid": dockerfile["project_uid"],
|
||||
"version": version, "content": content, "content_hash": content_hash(content),
|
||||
"created_at": now(), "created_by": user["uid"],
|
||||
})
|
||||
update_dockerfile(dockerfile["uid"], {"current_version": version, "content_hash": content_hash(content)})
|
||||
return get_table("dockerfile_versions").find_one(uid=uid)
|
||||
|
||||
|
||||
def get_version(uid: str):
|
||||
if not _exists("dockerfile_versions"):
|
||||
return None
|
||||
return get_table("dockerfile_versions").find_one(uid=uid)
|
||||
|
||||
|
||||
def current_version(dockerfile: dict):
|
||||
if not _exists("dockerfile_versions"):
|
||||
return None
|
||||
return get_table("dockerfile_versions").find_one(
|
||||
dockerfile_uid=dockerfile["uid"], version=int(dockerfile.get("current_version") or 0))
|
||||
|
||||
|
||||
def list_versions(dockerfile_uid: str) -> list:
|
||||
if not _exists("dockerfile_versions"):
|
||||
return []
|
||||
return sorted(get_table("dockerfile_versions").find(dockerfile_uid=dockerfile_uid),
|
||||
key=lambda r: r.get("version", 0), reverse=True)
|
||||
|
||||
|
||||
# ---------------- builds ----------------
|
||||
|
||||
def create_build(dockerfile: dict, version: dict, build_number: int, image_tag: str, also_latest: bool) -> dict:
|
||||
uid = generate_uid()
|
||||
get_table("builds").insert({
|
||||
"uid": uid, "dockerfile_uid": dockerfile["uid"], "dockerfile_version_uid": version["uid"],
|
||||
"project_uid": dockerfile["project_uid"], "build_number": build_number,
|
||||
"image_tag": image_tag, "also_latest": 1 if also_latest else 0,
|
||||
"status": BUILD_QUEUED, "job_uid": "", "image_id": "", "logs": "", "error": "",
|
||||
"duration_ms": 0, "queued_at": now(), "started_at": "", "completed_at": "", "created_at": now(),
|
||||
})
|
||||
return get_build(uid)
|
||||
|
||||
|
||||
def get_build(uid: str):
|
||||
if not _exists("builds"):
|
||||
return None
|
||||
return get_table("builds").find_one(uid=uid)
|
||||
|
||||
|
||||
def update_build(uid: str, changes: dict) -> None:
|
||||
get_table("builds").update({"uid": uid, **changes}, ["uid"])
|
||||
|
||||
|
||||
def list_builds(dockerfile_uid: str, limit: int = 50) -> list:
|
||||
if not _exists("builds"):
|
||||
return []
|
||||
rows = sorted(get_table("builds").find(dockerfile_uid=dockerfile_uid),
|
||||
key=lambda r: r.get("build_number", 0), reverse=True)
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
# ---------------- instances ----------------
|
||||
|
||||
def create_instance(row: dict) -> dict:
|
||||
@@ -169,14 +55,12 @@ def get_instance(uid: str):
|
||||
return table.find_one(uid=uid) or table.find_one(slug=uid)
|
||||
|
||||
|
||||
def list_instances(project_uid: str = None, dockerfile_uid: str = None) -> list:
|
||||
def list_instances(project_uid: str = None) -> list:
|
||||
if not _exists("instances"):
|
||||
return []
|
||||
filters = {}
|
||||
if project_uid:
|
||||
filters["project_uid"] = project_uid
|
||||
if dockerfile_uid:
|
||||
filters["dockerfile_uid"] = dockerfile_uid
|
||||
return sorted(get_table("instances").find(**filters),
|
||||
key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
DEFAULT_DOCKERFILE = """# syntax=docker/dockerfile:1
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \\
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash app
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN chown -R app:app /app
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["python3", "-m", "http.server", "8000", "--directory", "/app"]
|
||||
"""
|
||||
|
||||
PLAYWRIGHT_DOCKERFILE = """# syntax=docker/dockerfile:1
|
||||
FROM python:3.13-slim-bookworm AS base
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \\
|
||||
PYTHONDONTWRITEBYTECODE=1 \\
|
||||
PIP_NO_CACHE_DIR=1 \\
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \\
|
||||
DEBIAN_FRONTEND=noninteractive \\
|
||||
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
git curl ca-certificates build-essential libpq-dev \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS deps
|
||||
RUN pip install \\
|
||||
numpy pandas requests \\
|
||||
fastapi "uvicorn[standard]" pydantic \\
|
||||
sqlalchemy psycopg2-binary redis \\
|
||||
httpx aiofiles python-dotenv \\
|
||||
playwright \\
|
||||
&& playwright install --with-deps chromium
|
||||
|
||||
FROM deps AS runtime
|
||||
RUN useradd --create-home --shell /bin/bash app
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN chown -R app:app /app /opt/playwright
|
||||
USER app
|
||||
|
||||
CMD ["sleep", "infinity"]
|
||||
"""
|
||||
@@ -212,11 +212,14 @@ ACTIONS: tuple[Action, ...] = (
|
||||
summary="Mark a project private (owner-only) or public",
|
||||
description=(
|
||||
"Set value=true to hide the project from everyone except its owner (and administrators), "
|
||||
"or value=false to make it public again. Only the project owner may change this."
|
||||
"or value=false to make it public again. This is a significant change: you MUST ask the "
|
||||
"user for explicit confirmation BEFORE calling it, and only pass confirm=true once they "
|
||||
"have agreed. Only the project owner may change this."
|
||||
),
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("value", "true to make the project private, false to make it public.", required=True),
|
||||
body("confirm", "Must be true, set only after the user has explicitly confirmed.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
@@ -419,6 +422,33 @@ ACTIONS: tuple[Action, ...] = (
|
||||
params=(path("uid", "Zip job uid returned by zip_project."),),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="fork_project",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/fork",
|
||||
summary="Fork a project into a new project owned by the current user",
|
||||
description=(
|
||||
"Queues a background fork job and returns {uid, status_url}. Poll the status_url with "
|
||||
"fork_status until status is 'done', then give the user the project_url of the new fork."
|
||||
),
|
||||
params=(
|
||||
path("project_slug", "Slug or uid of the project to fork."),
|
||||
body("title", "Title for the new forked project.", required=True),
|
||||
),
|
||||
requires_auth=True,
|
||||
),
|
||||
Action(
|
||||
name="fork_status",
|
||||
method="GET",
|
||||
path="/forks/{uid}",
|
||||
summary="Check a fork job and obtain the new project once finished",
|
||||
description=(
|
||||
"Returns the job status. When status is 'done', project_url points at the new forked "
|
||||
"project; while 'pending' or 'running', poll again shortly."
|
||||
),
|
||||
params=(path("uid", "Fork job uid returned by fork_project."),),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="search_users",
|
||||
method="GET",
|
||||
|
||||
@@ -110,4 +110,22 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="open_terminal",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Open a floating interactive terminal window attached to a running container instance",
|
||||
description=(
|
||||
CLIENT + " Opens a new xterm.js window in the user's browser connected to the container's "
|
||||
"interactive shell (admin only). Resolve the instance first with container_list_instances, "
|
||||
"then pass the project slug and the instance slug or uid."
|
||||
),
|
||||
handler="client",
|
||||
requires_admin=True,
|
||||
params=(
|
||||
arg("project_slug", "Project slug or uid that owns the container.", required=True),
|
||||
arg("instance", "Container instance slug or uid.", required=True),
|
||||
arg("label", "Optional window title (defaults to the instance name)."),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -10,47 +10,6 @@ def arg(name: str, description: str, required: bool = False, kind: str = "string
|
||||
SLUG = arg("project_slug", "Project slug or uid that owns the container resources.", required=True)
|
||||
|
||||
CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="container_list_dockerfiles",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
|
||||
summary="List the Dockerfiles, builds, and instances of a project",
|
||||
params=(SLUG,),
|
||||
),
|
||||
Action(
|
||||
name="container_create_dockerfile",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Create a Dockerfile in a project and queue its first build",
|
||||
description="With no content a lean python-slim default builds in seconds and serves /app on port 8000 (long-lived, ingress-ready). Pass full content for anything else; for browser automation start from python-slim and add 'pip install playwright && playwright install --with-deps chromium'.",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("name", "Image name, lowercase letters/digits/.-_ (max 63 chars).", required=True),
|
||||
arg("description", "Optional description."),
|
||||
arg("tags", "Optional comma separated tags."),
|
||||
arg("content", "Optional full Dockerfile content."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_update_dockerfile",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Replace a Dockerfile's content, creating a new immutable version and queueing a build",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("dockerfile", "Dockerfile name, slug, or uid.", required=True),
|
||||
arg("content", "New Dockerfile content.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_build",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Rebuild the current version of a Dockerfile",
|
||||
params=(SLUG, arg("dockerfile", "Dockerfile name, slug, or uid.", required=True)),
|
||||
),
|
||||
Action(
|
||||
name="container_build_status",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
|
||||
summary="Get a build's status and recent log tail",
|
||||
params=(SLUG, arg("build_uid", "Build uid.", required=True)),
|
||||
),
|
||||
Action(
|
||||
name="container_list_instances",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
|
||||
@@ -60,10 +19,9 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="container_create_instance",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Create and start a container instance from a Dockerfile's latest successful build",
|
||||
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("dockerfile", "Dockerfile name, slug, or uid.", required=True),
|
||||
arg("name", "Instance name.", required=True),
|
||||
arg("boot_command", "Optional command to run on boot, e.g. 'python app.py'."),
|
||||
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
|
||||
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
|
||||
return Param(name=name, location="body", description=description, required=required, type=kind)
|
||||
|
||||
|
||||
SCOPE = (
|
||||
"Either 'global' (applies to every page of the site for this user) or a page-type string "
|
||||
"as returned by get_page_context.pageType (e.g. '/posts/{slug}'), which applies to every "
|
||||
"page of that type."
|
||||
)
|
||||
|
||||
CUSTOMIZATION_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="customize_list",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List the user's saved CSS/JS customizations (scopes, languages, sizes)",
|
||||
description=(
|
||||
"Returns every stored customization for the current user (or guest session), with its "
|
||||
"scope, language, enabled flag and a short preview of the code."
|
||||
),
|
||||
handler="customization",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="customize_get",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Read the full saved CSS or JS code for one scope",
|
||||
description="Use this before editing so you build on existing customization rather than overwriting it.",
|
||||
handler="customization",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
arg("scope", SCOPE, required=True),
|
||||
arg("lang", "Which code to read: 'css' or 'js'.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="customize_set_css",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Permanently save custom CSS for a page type or the whole site",
|
||||
description=(
|
||||
"Persists custom CSS for the current user. Before calling, ask the user whether it should "
|
||||
"apply to the current page TYPE or to the whole site (global). The CSS is the final, full "
|
||||
"stylesheet for that scope and replaces any previously saved CSS for it."
|
||||
),
|
||||
handler="customization",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("scope", SCOPE, required=True),
|
||||
arg("css", "The full CSS source to save for this scope.", required=True),
|
||||
arg("confirm", "Must be true after the user has confirmed the scope.", required=True, kind="boolean"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="customize_set_js",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Permanently save custom JavaScript for a page type or the whole site",
|
||||
description=(
|
||||
"Persists custom JavaScript for the current user. Before calling, ask the user whether it "
|
||||
"should apply to the current page TYPE or to the whole site (global). The code runs after "
|
||||
"the application boots, with access to window, document and the global 'app'. It is the "
|
||||
"final, full script for that scope and replaces any previously saved JS for it."
|
||||
),
|
||||
handler="customization",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("scope", SCOPE, required=True),
|
||||
arg("js", "The full JavaScript source to save for this scope.", required=True),
|
||||
arg("confirm", "Must be true after the user has confirmed the scope.", required=True, kind="boolean"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="customize_reset",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Delete saved customizations, restoring the default look and behaviour",
|
||||
description=(
|
||||
"Removes saved customizations. Omit lang to remove both CSS and JS for the scope. Pass "
|
||||
"scope='all' to remove every customization for this user. This cannot be undone."
|
||||
),
|
||||
handler="customization",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("scope", SCOPE + " Use 'all' to remove every customization.", required=True),
|
||||
arg("lang", "Optional: limit the reset to 'css' or 'js'. Omit to remove both."),
|
||||
arg("confirm", "Must be true after the user has confirmed the deletion.", required=True, kind="boolean"),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -30,7 +30,7 @@ from .spec import Action, Catalog
|
||||
|
||||
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
|
||||
|
||||
CONFIRM_REQUIRED = {"project_set_readonly"}
|
||||
CONFIRM_REQUIRED = {"project_set_readonly", "project_set_private", "customize_set_css", "customize_set_js", "customize_reset"}
|
||||
|
||||
logger = logging.getLogger("devii.dispatch")
|
||||
|
||||
@@ -40,13 +40,36 @@ def _is_confirmed(arguments: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
|
||||
if name in CONFIRM_REQUIRED and not _is_confirmed(arguments):
|
||||
if name not in CONFIRM_REQUIRED or _is_confirmed(arguments):
|
||||
return None
|
||||
if name in ("customize_set_css", "customize_set_js"):
|
||||
scope = str(arguments.get("scope", "")).strip() or "(unspecified)"
|
||||
return ToolInputError(
|
||||
"Setting a project read-only makes every file immutable and blocks all further "
|
||||
"edits. Ask the user to confirm this explicitly first, then call again with "
|
||||
"Before saving, confirm the scope with the user: should this apply to THIS page type "
|
||||
f"('{scope}') or to the whole site ('global')? Ask them, then call again with the chosen "
|
||||
"scope and confirm=true."
|
||||
)
|
||||
if name == "customize_reset":
|
||||
return ToolInputError(
|
||||
"Deleting customizations cannot be undone. Ask the user to confirm, then call again with "
|
||||
"confirm=true."
|
||||
)
|
||||
return None
|
||||
if name == "project_set_private":
|
||||
making_private = str(arguments.get("value", "")).strip().lower() in ("true", "1", "yes", "on")
|
||||
change = (
|
||||
"hide the project from everyone except its owner and administrators"
|
||||
if making_private
|
||||
else "make the project public so everyone can see it and all its files"
|
||||
)
|
||||
return ToolInputError(
|
||||
f"Changing a project's visibility will {change}. Ask the user to confirm this explicitly "
|
||||
"first, then call again with confirm=true."
|
||||
)
|
||||
return ToolInputError(
|
||||
"Setting a project read-only makes every file immutable and blocks all further "
|
||||
"edits. Ask the user to confirm this explicitly first, then call again with "
|
||||
"confirm=true."
|
||||
)
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
@@ -61,6 +84,9 @@ class Dispatcher:
|
||||
browser: Any = None,
|
||||
is_admin: bool = False,
|
||||
quota_provider: Any = None,
|
||||
owner_kind: str = "guest",
|
||||
owner_id: str = "",
|
||||
virtual_tools: Any = None,
|
||||
) -> None:
|
||||
self._actions = catalog.by_name()
|
||||
self._client = client
|
||||
@@ -77,6 +103,9 @@ class Dispatcher:
|
||||
self._rsearch = RsearchController(settings)
|
||||
from ..container import ContainerController
|
||||
self._container = ContainerController(client)
|
||||
from ..customization import CustomizationController
|
||||
self._customization = CustomizationController(owner_kind, owner_id)
|
||||
self._virtual_tools = virtual_tools
|
||||
self._read_files: set[tuple[str, str]] = set()
|
||||
|
||||
@staticmethod
|
||||
@@ -98,6 +127,15 @@ class Dispatcher:
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
action = self._actions.get(name)
|
||||
if action is None:
|
||||
if self._virtual_tools is not None and self._virtual_tools.has(name):
|
||||
logger.info("Dispatch virtual tool %s args=%s", name, list(arguments))
|
||||
try:
|
||||
return await self._virtual_tools.run(name, arguments)
|
||||
except DeviiError as exc:
|
||||
return error_result(exc)
|
||||
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
|
||||
logger.exception("Virtual tool %s crashed", name)
|
||||
return unexpected_result(exc)
|
||||
return error_result(ToolInputError(f"Unknown tool: {name}"))
|
||||
|
||||
logger.info("Dispatch %s args=%s", name, list(arguments))
|
||||
@@ -177,6 +215,16 @@ class Dispatcher:
|
||||
if action.handler == "container":
|
||||
return await self._container.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "customization":
|
||||
return await self._customization.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "virtual_tool":
|
||||
if self._virtual_tools is None:
|
||||
return error_result(
|
||||
ToolInputError("User-defined tools are not available in this context.")
|
||||
)
|
||||
return await self._virtual_tools.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "avatar":
|
||||
if self._avatar is None:
|
||||
return error_result(
|
||||
|
||||
@@ -87,23 +87,27 @@ SYSTEM_PROMPT = (
|
||||
"A project owner can mark a project private (project_set_private) so only the owner and "
|
||||
"administrators can see it, and read-only (project_set_readonly) so every file becomes immutable. "
|
||||
"When a project is read-only, all file writes fail with 'project is read-only'; to edit such a "
|
||||
"project you must first ask the owner for permission and set it writable again. Marking a project "
|
||||
"read-only is significant and largely locks it down, so you MUST obtain explicit user confirmation "
|
||||
"before calling project_set_readonly with value=true, and only then pass confirm=true; never set a "
|
||||
"project read-only on your own initiative.\n\n"
|
||||
"project you must first ask the owner for permission and set it writable again. Changing a "
|
||||
"project's visibility or read-only state is significant, so you MUST obtain explicit user "
|
||||
"confirmation before calling project_set_private or project_set_readonly (in either direction), "
|
||||
"and only then pass confirm=true; never change visibility or lock a project on your own "
|
||||
"initiative.\n\n"
|
||||
"CONTAINERS (admin only)\n"
|
||||
"When you are an administrator you can manage a project's Docker containers with the container_* "
|
||||
"tools: author Dockerfiles (a high-quality default is provided), build versioned images "
|
||||
"asynchronously, create and control instances "
|
||||
"(container_instance_action: start/stop/restart/pause/resume/delete/sync), read logs, run one-shot "
|
||||
"commands (container_exec), inspect stats, and schedule starts/stops. Builds and instances run on "
|
||||
"When you are an administrator you can manage a project's containers with the container_* tools: "
|
||||
"create and control instances (container_create_instance; container_instance_action: "
|
||||
"start/stop/restart/pause/resume/delete/sync), read logs, run one-shot commands (container_exec), "
|
||||
"inspect stats, and schedule starts/stops. There is no image building: every instance runs the "
|
||||
"shared prebuilt 'ppy' image (Python + Playwright + common libraries) with the project's files "
|
||||
"mounted at /app, so creating an instance is instant. If a project needs an extra Python package, "
|
||||
"install it at runtime via container_exec ('pip install ...') - no sudo is needed. Instances run on "
|
||||
"the host docker daemon, so confirm destructive actions and never expose backend or infrastructure "
|
||||
"detail. These tools are unavailable to non-administrators. "
|
||||
"Builds are asynchronous and can take a while for heavy base images. "
|
||||
"container_build_status reports a 'status' (queued, building, success, failed, cancelled) "
|
||||
"and a 'terminal' flag: a status of queued or building is NORMAL in-progress, not an error - keep "
|
||||
"polling patiently (wait between polls) and do NOT change the Dockerfile or start a new build until "
|
||||
"'terminal' is true. Only treat status 'failed' (or a 'build_error' field) as a failure to fix. "
|
||||
"When the user asks to attach, open, show, reopen, or get back a terminal/shell for a container, "
|
||||
"ALWAYS call open_terminal (resolve the instance with container_list_instances first if you do not "
|
||||
"already know its slug/uid). Call it EVERY time it is asked - even if you opened a terminal earlier "
|
||||
"in this conversation - because the user may have closed the window; open_terminal reopens the "
|
||||
"window or focuses the existing one, and (thanks to tmux) it re-attaches to the same running shell. "
|
||||
"Never reply that the terminal is 'already open' without actually calling open_terminal. "
|
||||
"A running instance can be published with an ingress slug; the container tools then return an "
|
||||
"'ingress_url'. To reach a published service, use that 'ingress_url' (the configured public URL, "
|
||||
"e.g. https://host/p/<slug>) - never localhost or 127.0.0.1, which web tools refuse. If ingress_url "
|
||||
@@ -116,6 +120,35 @@ SYSTEM_PROMPT = (
|
||||
"information the platform cannot provide and the user wants it. Never use them to answer questions "
|
||||
"about this DevPlace instance, its users, posts, settings, or metrics - those have dedicated "
|
||||
"platform tools. When platform tools can serve the request, do not call rsearch.\n\n"
|
||||
"CUSTOMIZATION (CSS AND JAVASCRIPT)\n"
|
||||
"The user can permanently customize the LOOK (custom CSS) and BEHAVIOUR (custom JavaScript) of "
|
||||
"the site through the customize_* tools. Every customization is scoped either to the current "
|
||||
"page TYPE (it then applies to every page of that type, for example all post pages, never a "
|
||||
"single specific post) or GLOBALLY (every page). It applies only to this user's own sessions; a "
|
||||
"signed-out guest gets customizations for their current session. To learn the current page type, "
|
||||
"call get_page_context and read its pageType field, and pass that exact value as the scope; pass "
|
||||
"scope='global' for the whole site. "
|
||||
"Iterate by PREVIEWING live first: inject a draft with run_js, replacing an element with id "
|
||||
"'devii-preview-css' (a <style>) or 'devii-preview-js' (run the code), so the user sees it "
|
||||
"immediately WITHOUT saving. Do not persist while experimenting. Before you SAVE "
|
||||
"(customize_set_css / customize_set_js) you MUST ask the user whether it is for the current page "
|
||||
"type or the whole site; only after they choose, call the tool with that scope and confirm=true. "
|
||||
"Saved code is the full, final stylesheet or script for that scope and replaces what was there, "
|
||||
"so call customize_get first and build on the existing code instead of dropping it. Saved JS runs "
|
||||
"after the application boots, with access to window, document and the global 'app'. After saving, "
|
||||
"call reload_page so the server-applied version is shown. List existing customizations with "
|
||||
"customize_list, and remove them with customize_reset (confirm=true; scope='all' removes "
|
||||
"everything) to restore the default look and behaviour.\n\n"
|
||||
"USER-DEFINED TOOLS (VIBE TOOLS)\n"
|
||||
"The user can invent new tools for you by describing them ('when I say X, do Y'). When they do, "
|
||||
"call tool_create with a short trigger-oriented description (so you know when to use it), the "
|
||||
"instruction prompt the tool should run, and an input_description for its single free-form input. "
|
||||
"The new tool becomes callable on the next turn and appears in your toolset; when called, it "
|
||||
"re-runs you on its stored prompt plus the user's input and returns the result. Manage these with "
|
||||
"tool_list, tool_get, tool_update (including enabling/disabling), and tool_delete. Use eval(prompt) "
|
||||
"to run yourself on an arbitrary prompt and get the result. A user-defined tool covers all of that "
|
||||
"user's sessions (for a guest, only the current session). Nesting is bounded: do not design a tool "
|
||||
"or eval that keeps calling itself, as the self-evaluation depth is limited.\n\n"
|
||||
"RESPONSE STYLE\n"
|
||||
"Replies are plain, concise, and professional. Never use emojis, decorative symbols, or "
|
||||
"celebratory language; report outcomes matter-of-factly. State what changed using the "
|
||||
|
||||
@@ -98,4 +98,20 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
|
||||
arg("allowed_tools", "Optional list of tool names the sub-agent may use.", kind="array"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="eval",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Run a prompt through a fresh Devii sub-agent and return its result (self-evaluation)",
|
||||
description=(
|
||||
"Executes the given prompt as if it were a new request to yourself, with full tool "
|
||||
"access, and returns the result. This is the engine behind user-defined tools. Nesting is "
|
||||
"limited, so do not build deep self-calling chains."
|
||||
),
|
||||
handler="agentic",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("prompt", "The instruction to run as a fresh sub-agent request.", required=True),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,14 @@ from ..config import Settings
|
||||
from ..errors import ToolInputError
|
||||
from .lessons import LessonStore
|
||||
from .loop import TraceCallback, react_loop
|
||||
from .state import AgentState, get_state
|
||||
from .state import (
|
||||
MAX_EVAL_DEPTH,
|
||||
AgentState,
|
||||
get_eval_depth,
|
||||
get_state,
|
||||
reset_eval_depth,
|
||||
set_eval_depth,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("devii.agentic.controller")
|
||||
|
||||
@@ -58,6 +65,7 @@ class AgenticController:
|
||||
"forget_lessons": self._forget,
|
||||
"verify": self._verify,
|
||||
"delegate": self._delegate,
|
||||
"eval": self._eval,
|
||||
}
|
||||
handler = handlers.get(name)
|
||||
if handler is None:
|
||||
@@ -147,12 +155,53 @@ class AgenticController:
|
||||
{"status": "success", "verified": bool(confirmed), "summary": summary}, ensure_ascii=False
|
||||
)
|
||||
|
||||
async def _spawn(
|
||||
self, prompt: str, tools: list[dict[str, Any]], system_prompt: str = SUB_AGENT_SYSTEM_PROMPT
|
||||
) -> tuple[str, AgentState]:
|
||||
if self._llm is None or self._dispatcher is None:
|
||||
raise ToolInputError("Sub-agent execution is not available in this context.")
|
||||
depth = get_eval_depth()
|
||||
if depth >= MAX_EVAL_DEPTH:
|
||||
raise ToolInputError(
|
||||
"Nested self-evaluation limit reached; a tool or eval cannot keep calling itself."
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
sub_state = AgentState()
|
||||
token = set_eval_depth(depth + 1)
|
||||
try:
|
||||
result = await react_loop(
|
||||
llm=self._llm,
|
||||
dispatcher=self._dispatcher,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
state=sub_state,
|
||||
settings=self._settings,
|
||||
max_iterations=self._settings.delegate_max_iterations,
|
||||
plan_required=self._settings.plan_required,
|
||||
verify_required=self._settings.verify_required,
|
||||
on_trace=self._on_trace,
|
||||
cost_tracker=self._cost_tracker,
|
||||
chunk_store=self._chunk_store,
|
||||
)
|
||||
finally:
|
||||
reset_eval_depth(token)
|
||||
return result, sub_state
|
||||
|
||||
async def run_subagent(self, prompt: str) -> str:
|
||||
prompt = str(prompt or "").strip()
|
||||
if not prompt:
|
||||
raise ToolInputError("A non-empty prompt is required.")
|
||||
tools = [tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE]
|
||||
result, _ = await self._spawn(prompt, tools)
|
||||
return result
|
||||
|
||||
async def _delegate(self, arguments: dict[str, Any]) -> str:
|
||||
task = str(arguments.get("task", "")).strip()
|
||||
if not task:
|
||||
raise ToolInputError("delegate requires a task description.")
|
||||
if self._llm is None or self._dispatcher is None:
|
||||
raise ToolInputError("Delegation is not available in this context.")
|
||||
allowed = arguments.get("allowed_tools")
|
||||
if allowed:
|
||||
allowed_set = {str(name) for name in allowed}
|
||||
@@ -163,26 +212,7 @@ class AgenticController:
|
||||
]
|
||||
else:
|
||||
tools = [tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE]
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SUB_AGENT_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": task},
|
||||
]
|
||||
sub_state = AgentState()
|
||||
result = await react_loop(
|
||||
llm=self._llm,
|
||||
dispatcher=self._dispatcher,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
state=sub_state,
|
||||
settings=self._settings,
|
||||
max_iterations=self._settings.delegate_max_iterations,
|
||||
plan_required=self._settings.plan_required,
|
||||
verify_required=self._settings.verify_required,
|
||||
on_trace=self._on_trace,
|
||||
cost_tracker=self._cost_tracker,
|
||||
chunk_store=self._chunk_store,
|
||||
)
|
||||
result, sub_state = await self._spawn(task, tools)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
@@ -193,3 +223,10 @@ class AgenticController:
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
async def _eval(self, arguments: dict[str, Any]) -> str:
|
||||
prompt = str(arguments.get("prompt", "")).strip()
|
||||
if not prompt:
|
||||
raise ToolInputError("eval requires a non-empty prompt.")
|
||||
result = await self.run_subagent(prompt)
|
||||
return json.dumps({"status": "success", "result": result}, ensure_ascii=False)
|
||||
|
||||
@@ -7,6 +7,9 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
_active_state: contextvars.ContextVar = contextvars.ContextVar("devii_agent_state", default=None)
|
||||
_eval_depth: contextvars.ContextVar = contextvars.ContextVar("devii_eval_depth", default=0)
|
||||
|
||||
MAX_EVAL_DEPTH = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -35,3 +38,15 @@ def record_mutation(name: str) -> None:
|
||||
state = _active_state.get()
|
||||
if state is not None:
|
||||
state.mutations.add(name)
|
||||
|
||||
|
||||
def get_eval_depth() -> int:
|
||||
return _eval_depth.get()
|
||||
|
||||
|
||||
def set_eval_depth(value: int) -> contextvars.Token:
|
||||
return _eval_depth.set(value)
|
||||
|
||||
|
||||
def reset_eval_depth(token: contextvars.Token) -> None:
|
||||
_eval_depth.reset(token)
|
||||
|
||||
@@ -41,15 +41,6 @@ class ContainerController:
|
||||
raise ToolInputError(f"project not found: {slug}")
|
||||
return project
|
||||
|
||||
def _dockerfile(self, project: dict, ref: str) -> dict:
|
||||
df = store.get_dockerfile(ref)
|
||||
if df is None or df["project_uid"] != project["uid"]:
|
||||
for candidate in store.list_dockerfiles(project["uid"]):
|
||||
if candidate["name"] == ref:
|
||||
return candidate
|
||||
raise ToolInputError(f"dockerfile not found: {ref}")
|
||||
return df
|
||||
|
||||
def _instance(self, project: dict, ref: str) -> dict:
|
||||
inst = store.get_instance(ref)
|
||||
if inst is None or inst["project_uid"] != project["uid"]:
|
||||
@@ -59,19 +50,6 @@ class ContainerController:
|
||||
raise ToolInputError(f"instance not found: {ref}")
|
||||
return inst
|
||||
|
||||
def _build_view(self, build: dict) -> dict:
|
||||
if not build:
|
||||
return {}
|
||||
terminal = build["status"] in (store.BUILD_SUCCESS, store.BUILD_FAILED, store.BUILD_CANCELLED)
|
||||
view = {
|
||||
"uid": build["uid"], "status": build["status"], "terminal": terminal,
|
||||
"build_number": build.get("build_number"), "image_tag": build.get("image_tag"),
|
||||
"image_id": build.get("image_id") or None, "duration_ms": build.get("duration_ms"),
|
||||
}
|
||||
if build.get("error"):
|
||||
view["build_error"] = build["error"]
|
||||
return view
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
handler = getattr(self, "_" + name[len("container_"):], None) if name.startswith("container_") else None
|
||||
if handler is None:
|
||||
@@ -81,44 +59,6 @@ class ContainerController:
|
||||
except ContainerError as exc:
|
||||
return json.dumps({"error": "container_error", "message": str(exc)})
|
||||
|
||||
async def _list_dockerfiles(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
return json.dumps({
|
||||
"dockerfiles": store.list_dockerfiles(project["uid"]),
|
||||
"instances": store.list_instances(project["uid"]),
|
||||
}, ensure_ascii=False, default=str)
|
||||
|
||||
async def _create_dockerfile(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
result = await api.create_dockerfile(
|
||||
project, self._actor_user(), name=str(arguments.get("name", "")),
|
||||
description=str(arguments.get("description", "")), tags=str(arguments.get("tags", "")),
|
||||
content=arguments.get("content") or None)
|
||||
return json.dumps({"dockerfile": result["dockerfile"], "build": self._build_view(result["build"])}, default=str)
|
||||
|
||||
async def _update_dockerfile(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
df = self._dockerfile(project, str(arguments.get("dockerfile", "")))
|
||||
result = await api.save_version(df, self._actor_user(), str(arguments.get("content", "")))
|
||||
return json.dumps({"changed": result["changed"], "build": self._build_view(result["build"])}, default=str)
|
||||
|
||||
async def _build(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
df = self._dockerfile(project, str(arguments.get("dockerfile", "")))
|
||||
version = store.current_version(df)
|
||||
if version is None:
|
||||
raise ToolInputError("dockerfile has no version to build")
|
||||
build = await api.enqueue_build(df, version, owner=("user", self._actor_user()["uid"]))
|
||||
return json.dumps({"build": self._build_view(build)}, default=str)
|
||||
|
||||
async def _build_status(self, arguments) -> str:
|
||||
build = store.get_build(str(arguments.get("build_uid", "")))
|
||||
if build is None:
|
||||
raise ToolInputError("build not found")
|
||||
view = self._build_view(build)
|
||||
view["logs_tail"] = (build.get("logs") or "")[-4000:]
|
||||
return json.dumps(view, default=str)
|
||||
|
||||
async def _list_instances(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
instances = store.list_instances(project["uid"])
|
||||
@@ -128,13 +68,9 @@ class ContainerController:
|
||||
|
||||
async def _create_instance(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
df = self._dockerfile(project, str(arguments.get("dockerfile", "")))
|
||||
successes = [b for b in store.list_builds(df["uid"]) if b["status"] == store.BUILD_SUCCESS]
|
||||
if not successes:
|
||||
raise ToolInputError("dockerfile has no successful build yet")
|
||||
autostart = str(arguments.get("autostart", "true")).lower() not in ("false", "0", "no", "off")
|
||||
inst = await api.create_instance(
|
||||
project, df, successes[0], name=str(arguments.get("name", "")),
|
||||
project, name=str(arguments.get("name", "")),
|
||||
boot_command=str(arguments.get("boot_command", "")), env=arguments.get("env", ""),
|
||||
cpu_limit=str(arguments.get("cpu_limit", "")), mem_limit=str(arguments.get("mem_limit", "")),
|
||||
ports=arguments.get("ports", ""), restart_policy=str(arguments.get("restart_policy", "never")),
|
||||
@@ -199,7 +135,6 @@ class ContainerController:
|
||||
"ingress_url": self._ingress_url(inst),
|
||||
"runtime": api.instance_runtime(inst),
|
||||
"stats": api.instance_stats(inst["uid"]),
|
||||
"dockerfile_stats": api.dockerfile_stats(inst["dockerfile_uid"]),
|
||||
}, default=str)
|
||||
|
||||
async def _schedule(self, arguments) -> str:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.customization.controller import CustomizationController
|
||||
|
||||
__all__ = ["CustomizationController"]
|
||||
@@ -0,0 +1,125 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import (
|
||||
CUSTOMIZATION_LANGS,
|
||||
delete_custom_override,
|
||||
get_custom_override,
|
||||
list_custom_overrides,
|
||||
set_custom_override,
|
||||
)
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
|
||||
logger = logging.getLogger("devii.customization")
|
||||
|
||||
PREVIEW_CHARS = 160
|
||||
|
||||
|
||||
class CustomizationController:
|
||||
def __init__(self, owner_kind: str, owner_id: str) -> None:
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name == "customize_list":
|
||||
return self._list()
|
||||
if name == "customize_get":
|
||||
return self._get(arguments)
|
||||
if name == "customize_set_css":
|
||||
return self._set(arguments, "css", "css")
|
||||
if name == "customize_set_js":
|
||||
return self._set(arguments, "js", "js")
|
||||
if name == "customize_reset":
|
||||
return self._reset(arguments)
|
||||
raise ToolInputError(f"Unknown customization tool: {name}")
|
||||
|
||||
def _scope(self, arguments: dict[str, Any]) -> str:
|
||||
scope = str(arguments.get("scope", "")).strip()
|
||||
if not scope:
|
||||
raise ToolInputError("A 'scope' is required: 'global' or a page-type string.")
|
||||
return scope
|
||||
|
||||
def _lang(self, arguments: dict[str, Any]) -> str | None:
|
||||
raw = arguments.get("lang")
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return None
|
||||
lang = str(raw).strip().lower()
|
||||
if lang not in CUSTOMIZATION_LANGS:
|
||||
raise ToolInputError("'lang' must be 'css' or 'js'.")
|
||||
return lang
|
||||
|
||||
def _list(self) -> str:
|
||||
rows = list_custom_overrides(self._owner_kind, self._owner_id)
|
||||
items = [
|
||||
{
|
||||
"scope": row.get("scope"),
|
||||
"lang": row.get("lang"),
|
||||
"enabled": bool(row.get("enabled", 1)),
|
||||
"chars": len(row.get("code") or ""),
|
||||
"preview": (row.get("code") or "")[:PREVIEW_CHARS],
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return json.dumps({"status": "success", "customizations": items}, ensure_ascii=False)
|
||||
|
||||
def _get(self, arguments: dict[str, Any]) -> str:
|
||||
scope = self._scope(arguments)
|
||||
lang = self._lang(arguments)
|
||||
if lang is None:
|
||||
raise ToolInputError("'lang' must be 'css' or 'js'.")
|
||||
row = get_custom_override(self._owner_kind, self._owner_id, scope, lang)
|
||||
if not row:
|
||||
return json.dumps(
|
||||
{"status": "success", "scope": scope, "lang": lang, "code": None}, ensure_ascii=False
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"scope": scope,
|
||||
"lang": lang,
|
||||
"enabled": bool(row.get("enabled", 1)),
|
||||
"code": row.get("code") or "",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _set(self, arguments: dict[str, Any], lang: str, code_key: str) -> str:
|
||||
scope = self._scope(arguments)
|
||||
code = arguments.get(code_key)
|
||||
if code is None or str(code).strip() == "":
|
||||
raise ToolInputError(f"'{code_key}' code is required.")
|
||||
set_custom_override(self._owner_kind, self._owner_id, scope, lang, str(code))
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"saved": True,
|
||||
"scope": scope,
|
||||
"lang": lang,
|
||||
"note": "Saved. Call reload_page so the user sees it applied by the server.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _reset(self, arguments: dict[str, Any]) -> str:
|
||||
scope = self._scope(arguments)
|
||||
lang = self._lang(arguments)
|
||||
if scope == "all":
|
||||
removed = delete_custom_override(self._owner_kind, self._owner_id, scope=None, lang=lang)
|
||||
else:
|
||||
removed = delete_custom_override(self._owner_kind, self._owner_id, scope=scope, lang=lang)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"removed": removed,
|
||||
"scope": scope,
|
||||
"lang": lang or "all",
|
||||
"note": "Removed. Call reload_page so the user sees the default restored.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from .llm import LLMClient
|
||||
from .session import DeviiSession
|
||||
from .store import ConversationStore, TurnAudit, UsageLedger
|
||||
from .tasks.store import TaskStore, memory_db
|
||||
from .virtual_tools import VirtualToolStore
|
||||
|
||||
logger = logging.getLogger("devii.hub")
|
||||
|
||||
@@ -48,9 +49,10 @@ class DeviiHub:
|
||||
owned_db = db if owner_kind == "user" else memory_db()
|
||||
task_store = TaskStore(owned_db, owner_kind, owner_id)
|
||||
lessons = LessonStore(owned_db, owner_kind, owner_id)
|
||||
virtual_tool_store = VirtualToolStore(owned_db, owner_kind, owner_id)
|
||||
session = DeviiSession(
|
||||
owner_kind, owner_id, username, settings, llm, lessons, pricing, task_store,
|
||||
self._stores, is_admin=is_admin,
|
||||
virtual_tool_store, self._stores, is_admin=is_admin,
|
||||
)
|
||||
if owner_kind == "user":
|
||||
saved = self._stores["conversations"].load(owner_kind, owner_id)
|
||||
|
||||
@@ -8,10 +8,12 @@ from .actions.chunk_actions import CHUNK_ACTIONS
|
||||
from .actions.client_actions import CLIENT_ACTIONS
|
||||
from .actions.container_actions import CONTAINER_ACTIONS
|
||||
from .actions.cost_actions import COST_ACTIONS
|
||||
from .actions.customization_actions import CUSTOMIZATION_ACTIONS
|
||||
from .actions.docs_actions import DOCS_ACTIONS
|
||||
from .actions.fetch_actions import FETCH_ACTIONS
|
||||
from .actions.rsearch_actions import RSEARCH_ACTIONS
|
||||
from .actions.spec import Catalog
|
||||
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
|
||||
from .agentic.actions import AGENTIC_ACTIONS
|
||||
from .tasks.actions import TASK_ACTIONS
|
||||
|
||||
@@ -27,4 +29,6 @@ CATALOG = Catalog(
|
||||
+ CHUNK_ACTIONS
|
||||
+ RSEARCH_ACTIONS
|
||||
+ CONTAINER_ACTIONS
|
||||
+ CUSTOMIZATION_ACTIONS
|
||||
+ VIRTUAL_TOOL_ACTIONS
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from .http_client import PlatformClient
|
||||
from .llm import LLMClient
|
||||
from .registry import CATALOG
|
||||
from .tasks import Scheduler, TaskController, TaskStore
|
||||
from .virtual_tools import VirtualToolController, VirtualToolStore
|
||||
|
||||
logger = logging.getLogger("devii.session")
|
||||
|
||||
@@ -53,6 +54,7 @@ class DeviiSession:
|
||||
lessons: LessonStore,
|
||||
pricing: Pricing,
|
||||
task_store: TaskStore,
|
||||
virtual_tool_store: VirtualToolStore,
|
||||
stores: dict[str, Any],
|
||||
is_admin: bool = False,
|
||||
) -> None:
|
||||
@@ -72,10 +74,16 @@ class DeviiSession:
|
||||
self.agentic = AgenticController(lessons, settings)
|
||||
self.cost = CostTracker(pricing=pricing)
|
||||
self.chunks = ChunkStore()
|
||||
self._virtual_tool_store = virtual_tool_store
|
||||
self.virtual_tools = VirtualToolController(
|
||||
virtual_tool_store, self.agentic.run_subagent, set(CATALOG.by_name())
|
||||
)
|
||||
self.dispatcher = Dispatcher(
|
||||
CATALOG, self.client, settings, self.task_controller, self.agentic,
|
||||
avatar=self.avatar, browser=self.browser,
|
||||
is_admin=is_admin, quota_provider=self._quota_snapshot,
|
||||
owner_kind=owner_kind, owner_id=owner_id,
|
||||
virtual_tools=self.virtual_tools,
|
||||
)
|
||||
self.tools = CATALOG.tool_schemas_for(self.client.authenticated, is_admin)
|
||||
self._system_prompt = _system_prompt_for(is_admin)
|
||||
@@ -248,6 +256,7 @@ class DeviiSession:
|
||||
error = ""
|
||||
try:
|
||||
async with self._lock:
|
||||
self._refresh_tools()
|
||||
reply = await self.agent.respond(text)
|
||||
await self._emit({"type": "reply", "text": reply}, buffer=True)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the browser, recorded for audit
|
||||
@@ -257,6 +266,11 @@ class DeviiSession:
|
||||
finally:
|
||||
self._record_turn(turn_id, started_at, text, reply, error, before)
|
||||
|
||||
def _refresh_tools(self) -> None:
|
||||
builtin = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
|
||||
virtual = self._virtual_tool_store.tool_schemas()
|
||||
self.tools[:] = builtin + virtual
|
||||
|
||||
def _quota_snapshot(self) -> dict[str, Any]:
|
||||
spent = self._ledger.spent_24h(self.owner_kind, self.owner_id)
|
||||
turns = self._ledger.turns_24h(self.owner_kind, self.owner_id)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.virtual_tools.controller import VirtualToolController
|
||||
from devplacepy.services.devii.virtual_tools.store import VirtualToolStore
|
||||
|
||||
__all__ = ["VirtualToolController", "VirtualToolStore"]
|
||||
@@ -0,0 +1,73 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..actions.spec import Action, Param
|
||||
|
||||
|
||||
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
|
||||
return Param(name=name, location="body", description=description, required=required, type=kind)
|
||||
|
||||
|
||||
VIRTUAL_TOOL_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="tool_create",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Create a new user-defined tool that runs a stored prompt when called",
|
||||
description=(
|
||||
"Use when the user asks you to remember a new capability ('when I say X, do Y'). The new "
|
||||
"tool becomes callable on the next turn; when called it re-runs you on its stored prompt "
|
||||
"plus the user's input. Give a clear trigger-oriented description so you know when to use it."
|
||||
),
|
||||
handler="virtual_tool",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("name", "Tool name: a letter then letters, digits, or underscores (2-41 chars).", required=True),
|
||||
arg("description", "When to use the tool and what it does (your trigger hint).", required=True),
|
||||
arg("prompt", "The instruction the tool runs each time it is called.", required=True),
|
||||
arg("input_description", "What the single free-form 'input' argument should contain."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="tool_list",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List the user's defined tools with their descriptions and status",
|
||||
handler="virtual_tool",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="tool_get",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Read a user-defined tool's full prompt and settings",
|
||||
handler="virtual_tool",
|
||||
requires_auth=False,
|
||||
params=(arg("name", "Tool name to read.", required=True),),
|
||||
),
|
||||
Action(
|
||||
name="tool_update",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Update a user-defined tool's description, prompt, input hint, or enabled state",
|
||||
handler="virtual_tool",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("name", "Tool name to update.", required=True),
|
||||
arg("description", "New description (optional)."),
|
||||
arg("prompt", "New stored prompt (optional)."),
|
||||
arg("input_description", "New input hint (optional)."),
|
||||
arg("enabled", "Enable (true) or disable (false) the tool without deleting it.", kind="boolean"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="tool_delete",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Delete a user-defined tool",
|
||||
handler="virtual_tool",
|
||||
requires_auth=False,
|
||||
params=(arg("name", "Tool name to delete.", required=True),),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
from devplacepy.services.devii.virtual_tools.store import VirtualToolStore
|
||||
|
||||
logger = logging.getLogger("devii.virtual_tools.controller")
|
||||
|
||||
NAME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]{1,40}$")
|
||||
PREVIEW_CHARS = 200
|
||||
|
||||
Evaluator = Callable[[str], Awaitable[str]]
|
||||
|
||||
|
||||
def _serialize(row: dict[str, Any], full: bool = False) -> dict[str, Any]:
|
||||
data = {
|
||||
"name": row.get("name"),
|
||||
"description": row.get("description"),
|
||||
"input_description": row.get("input_description") or "",
|
||||
"enabled": bool(row.get("enabled", 1)),
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
if full:
|
||||
data["prompt"] = row.get("prompt") or ""
|
||||
else:
|
||||
data["prompt_preview"] = (row.get("prompt") or "")[:PREVIEW_CHARS]
|
||||
return data
|
||||
|
||||
|
||||
class VirtualToolController:
|
||||
def __init__(self, store: VirtualToolStore, evaluator: Evaluator, builtin_names: set[str]) -> None:
|
||||
self._store = store
|
||||
self._evaluator = evaluator
|
||||
self._builtin_names = builtin_names
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
handlers = {
|
||||
"tool_create": self._create,
|
||||
"tool_list": self._list,
|
||||
"tool_get": self._get,
|
||||
"tool_update": self._update,
|
||||
"tool_delete": self._delete,
|
||||
}
|
||||
handler = handlers.get(name)
|
||||
if handler is None:
|
||||
raise ToolInputError(f"Unknown virtual-tool action: {name}")
|
||||
return handler(arguments)
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
row = self._store.find(name)
|
||||
return bool(row and row.get("enabled", 1))
|
||||
|
||||
async def run(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
row = self._store.find(name)
|
||||
if not row or not row.get("enabled", 1):
|
||||
raise ToolInputError(f"No enabled virtual tool named '{name}'.")
|
||||
user_input = str(arguments.get("input", "")).strip()
|
||||
prompt = row.get("prompt") or ""
|
||||
composed = f"{prompt}\n\nUser input: {user_input}" if user_input else prompt
|
||||
result = await self._evaluator(composed)
|
||||
return json.dumps({"status": "success", "tool": name, "result": result}, ensure_ascii=False)
|
||||
|
||||
def _require_name(self, arguments: dict[str, Any]) -> str:
|
||||
name = str(arguments.get("name", "")).strip()
|
||||
if not name:
|
||||
raise ToolInputError("A tool 'name' is required.")
|
||||
return name
|
||||
|
||||
def _create(self, arguments: dict[str, Any]) -> str:
|
||||
name = self._require_name(arguments)
|
||||
if not NAME_PATTERN.match(name):
|
||||
raise ToolInputError(
|
||||
"Tool name must be 2-41 characters: a letter followed by letters, digits, or underscores."
|
||||
)
|
||||
if name in self._builtin_names:
|
||||
raise ToolInputError(f"'{name}' is a built-in tool name; choose a different name.")
|
||||
if self._store.find(name):
|
||||
raise ToolInputError(f"A tool named '{name}' already exists; use tool_update to change it.")
|
||||
description = str(arguments.get("description", "")).strip()
|
||||
prompt = str(arguments.get("prompt", "")).strip()
|
||||
if not description:
|
||||
raise ToolInputError("A 'description' is required so the tool can be triggered correctly.")
|
||||
if not prompt:
|
||||
raise ToolInputError("A 'prompt' is required: the instruction the tool runs when called.")
|
||||
record = {
|
||||
"uid": uuid.uuid4().hex,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"prompt": prompt,
|
||||
"input_description": str(arguments.get("input_description", "")).strip(),
|
||||
"enabled": 1,
|
||||
}
|
||||
stored = self._store.create(record)
|
||||
return json.dumps({"status": "created", "tool": _serialize(stored)}, ensure_ascii=False)
|
||||
|
||||
def _list(self, arguments: dict[str, Any]) -> str:
|
||||
rows = self._store.list()
|
||||
return json.dumps(
|
||||
{"count": len(rows), "tools": [_serialize(row) for row in rows]}, ensure_ascii=False
|
||||
)
|
||||
|
||||
def _get(self, arguments: dict[str, Any]) -> str:
|
||||
name = self._require_name(arguments)
|
||||
row = self._store.find(name)
|
||||
if not row:
|
||||
raise ToolInputError(f"No tool named '{name}'.")
|
||||
return json.dumps({"tool": _serialize(row, full=True)}, ensure_ascii=False)
|
||||
|
||||
def _update(self, arguments: dict[str, Any]) -> str:
|
||||
name = self._require_name(arguments)
|
||||
row = self._store.find(name)
|
||||
if not row:
|
||||
raise ToolInputError(f"No tool named '{name}'.")
|
||||
changes: dict[str, Any] = {}
|
||||
if "description" in arguments and str(arguments["description"]).strip():
|
||||
changes["description"] = str(arguments["description"]).strip()
|
||||
if "prompt" in arguments and str(arguments["prompt"]).strip():
|
||||
changes["prompt"] = str(arguments["prompt"]).strip()
|
||||
if "input_description" in arguments:
|
||||
changes["input_description"] = str(arguments["input_description"]).strip()
|
||||
if "enabled" in arguments and arguments["enabled"] is not None:
|
||||
changes["enabled"] = 1 if str(arguments["enabled"]).strip().lower() in ("1", "true", "yes", "on") else 0
|
||||
if not changes:
|
||||
raise ToolInputError("No updatable fields supplied (description, prompt, input_description, enabled).")
|
||||
self._store.update(name, changes)
|
||||
return json.dumps({"status": "updated", "tool": _serialize(self._store.find(name))}, ensure_ascii=False)
|
||||
|
||||
def _delete(self, arguments: dict[str, Any]) -> str:
|
||||
name = self._require_name(arguments)
|
||||
deleted = self._store.delete(name)
|
||||
if not deleted:
|
||||
raise ToolInputError(f"No tool named '{name}'.")
|
||||
return json.dumps({"status": "deleted", "name": name}, ensure_ascii=False)
|
||||
@@ -0,0 +1,88 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("devii.virtual_tools.store")
|
||||
|
||||
TABLE = "devii_virtual_tools"
|
||||
INDEXED_COLUMNS = (["owner_kind", "owner_id"], ["owner_kind", "owner_id", "name"])
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _schema_for(row: dict[str, Any]) -> dict[str, Any]:
|
||||
input_description = row.get("input_description") or "Free-form input for this tool."
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": row["name"],
|
||||
"description": f"{row.get('description', '')} (your custom tool)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {"type": "string", "description": input_description},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VirtualToolStore:
|
||||
def __init__(self, db: Any, owner_kind: str, owner_id: str) -> None:
|
||||
self._db = db
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
self._ensure_indexes()
|
||||
|
||||
def _ensure_indexes(self) -> None:
|
||||
if TABLE not in self._db.tables:
|
||||
return
|
||||
table = self._db[TABLE]
|
||||
for columns in INDEXED_COLUMNS:
|
||||
table.create_index(columns)
|
||||
|
||||
@property
|
||||
def _table(self) -> Any:
|
||||
return self._db[TABLE]
|
||||
|
||||
@property
|
||||
def _scope(self) -> dict[str, str]:
|
||||
return {"owner_kind": self._owner_kind, "owner_id": self._owner_id}
|
||||
|
||||
def find(self, name: str) -> dict[str, Any] | None:
|
||||
if TABLE not in self._db.tables:
|
||||
return None
|
||||
return self._table.find_one(name=name, **self._scope)
|
||||
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
if TABLE not in self._db.tables:
|
||||
return []
|
||||
return list(self._table.find(**self._scope))
|
||||
|
||||
def create(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
now = _now_iso()
|
||||
record = {**record, **self._scope, "created_at": now, "updated_at": now}
|
||||
self._table.insert(record)
|
||||
logger.info("Virtual tool created name=%s owner=%s/%s", record.get("name"), self._owner_kind, self._owner_id)
|
||||
return record
|
||||
|
||||
def update(self, name: str, changes: dict[str, Any]) -> None:
|
||||
changes = {**changes, "name": name, **self._scope, "updated_at": _now_iso()}
|
||||
self._table.update(changes, ["name", "owner_kind", "owner_id"])
|
||||
logger.debug("Virtual tool updated name=%s changes=%s", name, list(changes))
|
||||
|
||||
def delete(self, name: str) -> bool:
|
||||
deleted = self._table.delete(name=name, **self._scope)
|
||||
logger.info("Virtual tool deleted name=%s ok=%s", name, deleted)
|
||||
return bool(deleted)
|
||||
|
||||
def tool_schemas(self) -> list[dict[str, Any]]:
|
||||
return [_schema_for(row) for row in self.list() if row.get("enabled", 1)]
|
||||
@@ -0,0 +1,91 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy.config import DATA_DIR
|
||||
from devplacepy import project_files
|
||||
from devplacepy.content import create_content_item
|
||||
from devplacepy.database import get_table, record_fork, delete_fork_relations
|
||||
from devplacepy.services.jobs.base import JobService
|
||||
from devplacepy.utils import XP_PROJECT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STAGING_DIR = DATA_DIR / "fork_staging"
|
||||
|
||||
|
||||
class ForkService(JobService):
|
||||
kind = "fork"
|
||||
title = "Fork"
|
||||
description = (
|
||||
"Copies a project into a brand-new project owned by the forking user off the request "
|
||||
"path: it creates the destination project, duplicates the whole virtual filesystem, and "
|
||||
"records the source-to-fork relation. The resulting project is permanent; only the job "
|
||||
"tracking row is swept on retention."
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="fork", interval_seconds=2)
|
||||
|
||||
async def process(self, job: dict) -> dict:
|
||||
payload = job["payload"]
|
||||
source_uid = payload.get("source_project_uid", "")
|
||||
forked_by_uid = payload.get("forked_by_uid", "")
|
||||
title = (payload.get("title") or "").strip()
|
||||
|
||||
source = get_table("projects").find_one(uid=source_uid)
|
||||
if not source:
|
||||
raise ValueError(f"source project not found: {source_uid}")
|
||||
user = get_table("users").find_one(uid=forked_by_uid)
|
||||
if not user:
|
||||
raise ValueError(f"forking user not found: {forked_by_uid}")
|
||||
if not title:
|
||||
raise ValueError("a destination title is required")
|
||||
|
||||
new_uid, new_slug = create_content_item("projects", "project", user, {
|
||||
"title": title,
|
||||
"description": source.get("description") or "",
|
||||
"release_date": source.get("release_date") or None,
|
||||
"demo_date": source.get("demo_date") or None,
|
||||
"project_type": source.get("project_type") or "software",
|
||||
"platforms": source.get("platforms") or "",
|
||||
"status": source.get("status") or "In Development",
|
||||
"is_private": 1 if source.get("is_private") else 0,
|
||||
"read_only": 0,
|
||||
}, title, XP_PROJECT, "First Project", source.get("description") or "", None)
|
||||
|
||||
try:
|
||||
item_count = await asyncio.to_thread(self._copy_files, source_uid, new_uid, user, job["uid"])
|
||||
record_fork(source_uid, new_uid, forked_by_uid)
|
||||
except Exception:
|
||||
self._rollback(new_uid)
|
||||
raise
|
||||
|
||||
return {
|
||||
"project_uid": new_uid,
|
||||
"project_url": f"/projects/{new_slug}",
|
||||
"source_project_uid": source_uid,
|
||||
"item_count": item_count,
|
||||
}
|
||||
|
||||
def _copy_files(self, source_uid: str, new_uid: str, user: dict, job_uid: str) -> int:
|
||||
staging = STAGING_DIR / job_uid
|
||||
try:
|
||||
project_files.export_to_dir(source_uid, "", staging)
|
||||
return project_files.import_from_dir(new_uid, staging, user, skip_names=set())
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
def _rollback(self, new_uid: str) -> None:
|
||||
try:
|
||||
project_files.delete_all_project_files(new_uid)
|
||||
delete_fork_relations(new_uid)
|
||||
get_table("projects").delete(uid=new_uid)
|
||||
except Exception:
|
||||
logger.exception("fork rollback failed for %s", new_uid)
|
||||
|
||||
def cleanup(self, job: dict) -> None:
|
||||
shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)
|
||||
@@ -858,6 +858,18 @@ button.comment-form-submit:hover {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.topnav,
|
||||
.topnav-mobile-overlay,
|
||||
.topnav-mobile-panel,
|
||||
.modal-overlay,
|
||||
.dialog-overlay,
|
||||
.context-menu,
|
||||
.dp-toast-host,
|
||||
.feed-fab,
|
||||
.attachment-lightbox {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-card {
|
||||
margin: 0.5rem;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--overlay-dark);
|
||||
z-index: 2100;
|
||||
z-index: 2147483647;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
.context-menu {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1900;
|
||||
z-index: 2147483500;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
background: var(--bg-card);
|
||||
|
||||
@@ -1,31 +1,134 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.cm-page { max-width: 1400px; margin: 0 auto; padding: 1rem; }
|
||||
.cm-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
||||
.cm-title { font-size: 1.4rem; margin: 0; }
|
||||
.cm-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
|
||||
.cm-col { background: var(--surface, #1b1f24); border: 1px solid var(--border, #2a2f36); border-radius: 8px; padding: 1rem; }
|
||||
/* ---------------- shared ---------------- */
|
||||
|
||||
.cm-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
background: var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.cm-badge.cm-running { background: var(--success); color: var(--white); }
|
||||
.cm-badge.cm-crashed { background: var(--danger); color: var(--white); }
|
||||
.cm-badge.cm-paused { background: var(--warning); color: var(--white); }
|
||||
.cm-badge.cm-stopped { background: var(--text-muted); color: var(--white); }
|
||||
.cm-badge.cm-removed { background: var(--text-muted); color: var(--white); }
|
||||
.cm-badge.cm-created,
|
||||
.cm-badge.cm-starting,
|
||||
.cm-badge.cm-restarting { background: var(--info); color: var(--white); }
|
||||
.cm-badge.cm-success { background: var(--success); color: var(--white); }
|
||||
.cm-badge.cm-failed { background: var(--danger); color: var(--white); }
|
||||
.cm-badge.cm-building,
|
||||
.cm-badge.cm-queued { background: var(--info); color: var(--white); }
|
||||
.cm-badge.cm-cancelled { background: var(--text-muted); color: var(--white); }
|
||||
|
||||
.cm-muted { color: var(--text-muted); }
|
||||
|
||||
/* ---------------- project manager ---------------- */
|
||||
|
||||
.cm-page { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.cm-header { display: flex; align-items: center; gap: 1rem; }
|
||||
.cm-header-link { margin-left: auto; color: var(--text-secondary); font-size: 0.875rem; }
|
||||
.cm-header-link:hover { color: var(--accent); }
|
||||
|
||||
.cm-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
|
||||
.cm-col { min-width: 0; }
|
||||
.cm-col-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
|
||||
.cm-col-head h2 { font-size: 1.1rem; margin: 0; }
|
||||
|
||||
.cm-list { list-style: none; margin: 0; padding: 0; }
|
||||
.cm-item { padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; gap: 0.5rem; }
|
||||
.cm-item:hover { background: var(--surface-2, #232830); }
|
||||
.cm-item.active { background: var(--accent-soft, #2d3748); }
|
||||
.cm-detail { margin-top: 1rem; border-top: 1px solid var(--border, #2a2f36); padding-top: 1rem; }
|
||||
.cm-item { border-radius: var(--radius); }
|
||||
.cm-item + .cm-item { margin-top: 0.25rem; }
|
||||
.cm-item:not(:has(.cm-item-link)) { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; justify-content: space-between; gap: 0.5rem; }
|
||||
.cm-item:not(:has(.cm-item-link)):hover { background: var(--bg-card-hover); }
|
||||
.cm-item.active { background: var(--accent-light); }
|
||||
.cm-item-link { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; color: var(--text-primary); border-radius: var(--radius); }
|
||||
.cm-item-link:hover { background: var(--bg-card-hover); }
|
||||
|
||||
.cm-detail { margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 1rem; }
|
||||
.cm-detail-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
|
||||
.cm-detail-name { font-weight: 600; flex: 1; }
|
||||
.cm-editor { width: 100%; min-height: 260px; font-family: monospace; font-size: 0.85rem; background: #0f1216; color: #d7dde3; border: 1px solid var(--border, #2a2f36); border-radius: 6px; padding: 0.5rem; resize: vertical; }
|
||||
.cm-editor { width: 100%; min-height: 260px; font-family: var(--font-mono); font-size: 0.85rem; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.5rem; resize: vertical; }
|
||||
.cm-builds { margin-top: 1rem; }
|
||||
.cm-builds h3 { font-size: 0.95rem; margin: 0 0 0.5rem; }
|
||||
.cm-log { background: #0f1216; color: #cfe3cf; font-family: monospace; font-size: 0.8rem; padding: 0.75rem; border-radius: 6px; max-height: 320px; overflow: auto; white-space: pre-wrap; margin-top: 0.75rem; }
|
||||
.cm-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.75rem; }
|
||||
.cm-metrics { font-family: monospace; font-size: 0.8rem; color: var(--muted, #9aa4b2); margin-bottom: 0.5rem; }
|
||||
.cm-exec { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.cm-exec input { flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid var(--border, #2a2f36); background: #0f1216; color: #d7dde3; font-family: monospace; }
|
||||
.cm-badge { font-size: 0.7rem; padding: 0.1rem 0.45rem; border-radius: 10px; background: #2a2f36; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.cm-badge.cm-running { background: #1f7a3d; color: #fff; }
|
||||
.cm-badge.cm-crashed { background: #8a2b2b; color: #fff; }
|
||||
.cm-badge.cm-stopped { background: #4a5160; color: #fff; }
|
||||
.cm-badge.cm-paused { background: #8a6d1f; color: #fff; }
|
||||
|
||||
@media (max-width: 900px) { .cm-body { grid-template-columns: 1fr; } }
|
||||
.cm-log { background: var(--bg-input); color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.8rem; padding: 0.75rem; border-radius: var(--radius); max-height: 320px; overflow: auto; white-space: pre-wrap; margin-top: 0.75rem; }
|
||||
|
||||
.cm-modal-form .auth-field select { width: 100%; }
|
||||
.cm-build-label { cursor: pointer; flex: 1; }
|
||||
.cm-build-link { color: var(--text-muted); font-size: 0.8rem; }
|
||||
.cm-build-link:hover { color: var(--accent); }
|
||||
.cm-modal-form .topic-label { display: flex; align-items: center; justify-content: flex-start; gap: 0.5rem; }
|
||||
.cm-modal-form .topic-label input { width: auto; margin: 0; }
|
||||
|
||||
/* ---------------- admin list ---------------- */
|
||||
|
||||
.admin-toolbar-action { margin-left: auto; }
|
||||
.cm-row-name { color: var(--accent); font-weight: 600; }
|
||||
.cm-row-project { color: var(--text-secondary); }
|
||||
.cm-row-project:hover { color: var(--accent); }
|
||||
|
||||
/* ---------------- instance detail ---------------- */
|
||||
|
||||
.ci-page { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.ci-header { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.ci-titlebar { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.ci-title { font-size: 1.5rem; margin: 0; }
|
||||
.ci-project { color: var(--text-secondary); font-size: 0.875rem; }
|
||||
.ci-project:hover { color: var(--accent); }
|
||||
|
||||
.ci-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
|
||||
.ci-card { min-width: 0; }
|
||||
.ci-card h2 { font-size: 1.05rem; margin: 0 0 0.75rem; }
|
||||
.ci-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
|
||||
.ci-card-head h2 { margin: 0; }
|
||||
|
||||
.ci-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; }
|
||||
.ci-actions .ci-danger { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
.ci-runtime { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.ci-kv { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.85rem; }
|
||||
.ci-kv span { color: var(--text-muted); }
|
||||
.ci-kv code { font-family: var(--font-mono); color: var(--text-secondary); word-break: break-all; text-align: right; }
|
||||
|
||||
.ci-ingress { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--border); display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-size: 0.85rem; }
|
||||
.ci-ingress-label { color: var(--text-muted); }
|
||||
|
||||
.ci-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.ci-metric { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.ci-metric-label { color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.ci-metric-value { font-family: var(--font-mono); font-size: 1.1rem; color: var(--text-primary); }
|
||||
|
||||
.ci-schedules { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.ci-schedule { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.ci-schedule-action { font-weight: 600; text-transform: uppercase; font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: var(--accent-light); color: var(--accent); }
|
||||
.ci-schedule-next { color: var(--text-secondary); font-family: var(--font-mono); }
|
||||
.ci-schedule button { margin-left: auto; }
|
||||
|
||||
.ci-exec { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||
.ci-exec input { flex: 1; min-width: 200px; padding: 0.5rem 0.75rem; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-family: var(--font-mono); }
|
||||
|
||||
.ci-log { background: var(--bg-input); color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.8rem; padding: 0.75rem; border-radius: var(--radius); max-height: 360px; overflow: auto; white-space: pre-wrap; margin: 0; }
|
||||
.ci-log:focus { outline: 1px solid var(--accent); }
|
||||
.ci-log.ci-empty { color: var(--text-muted); font-style: italic; }
|
||||
|
||||
.cb-tag { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted); }
|
||||
.cb-error { color: var(--danger); font-size: 0.85rem; margin: 0.75rem 0 0; }
|
||||
.cb-dockerfile { max-height: 420px; }
|
||||
|
||||
.ci-terminal { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||
.ci-terminal .ci-term-output { border: none; border-radius: 0; max-height: 320px; }
|
||||
.ci-term-bar { display: flex; align-items: center; gap: 0.5rem; background: var(--bg-input); border-top: 1px solid var(--border); padding: 0.25rem 0.6rem; }
|
||||
.ci-term-prompt { color: var(--success); font-family: var(--font-mono); font-weight: 600; }
|
||||
.ci-term-input { flex: 1; background: transparent; border: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 0.8rem; padding: 0.4rem 0; }
|
||||
.ci-term-input:focus { outline: none; }
|
||||
.ci-term-input:disabled { color: var(--text-muted); }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.cm-body { grid-template-columns: 1fr; }
|
||||
.ci-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -27,6 +27,14 @@ devii-terminal {
|
||||
font-size: var(--devii-font-size);
|
||||
}
|
||||
|
||||
devii-terminal .devii-launcher,
|
||||
devii-terminal .devii-window,
|
||||
.devii-hl-box,
|
||||
.devii-hl-callout,
|
||||
.devii-toast {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Launcher FAB (shown only when closed) */
|
||||
devii-terminal .devii-launcher {
|
||||
position: fixed;
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(229, 57, 53, 0.4);
|
||||
z-index: 100;
|
||||
z-index: 900;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.fw-host {
|
||||
--fw-bg: #000000;
|
||||
--fw-fg: #d0d0d0;
|
||||
--fw-dim: #6a737d;
|
||||
--fw-accent: #3fb950;
|
||||
--fw-bar-bg: #0b0b0b;
|
||||
--fw-border: #1c1f24;
|
||||
--fw-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
|
||||
--fw-radius: 10px;
|
||||
--fw-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||
--fw-font-size: 14px;
|
||||
--fw-z: 2147480000;
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: var(--fw-z);
|
||||
font-family: var(--fw-font);
|
||||
font-size: var(--fw-font-size);
|
||||
}
|
||||
|
||||
.fw-host .fw-window {
|
||||
position: fixed;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 320px;
|
||||
min-height: 220px;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
background: var(--fw-bg);
|
||||
color: var(--fw-fg);
|
||||
border: 1px solid var(--fw-border);
|
||||
border-radius: var(--fw-radius);
|
||||
box-shadow: var(--fw-shadow);
|
||||
line-height: 1.5;
|
||||
animation: fw-pop 0.16s ease;
|
||||
transition: top 0.18s ease, left 0.18s ease, width 0.18s ease, height 0.18s ease;
|
||||
}
|
||||
|
||||
@keyframes fw-pop {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.fw-host.fw-dragging .fw-window {
|
||||
transition: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fw-host.fw-state-normal .fw-titlebar {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.fw-host .fw-resize {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
z-index: 6;
|
||||
cursor: nwse-resize;
|
||||
pointer-events: auto;
|
||||
background-image: linear-gradient(135deg, transparent 0 46%, var(--fw-dim) 46% 54%,
|
||||
transparent 54% 66%, var(--fw-dim) 66% 74%, transparent 74%);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.fw-host.fw-state-normal .fw-resize {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fw-host.fw-state-maximized .fw-window {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.fw-host.fw-state-fullscreen .fw-window {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.fw-host .fw-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px 8px 12px;
|
||||
background: var(--fw-bar-bg);
|
||||
border-bottom: 1px solid var(--fw-border);
|
||||
flex: 0 0 auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fw-host .fw-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--fw-accent);
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.fw-host .fw-title-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fw-host .fw-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--fw-accent);
|
||||
}
|
||||
|
||||
.fw-host .fw-winctl {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fw-host .fw-winctl button {
|
||||
background: transparent;
|
||||
color: var(--fw-dim);
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
margin-left: 2px;
|
||||
border-radius: 5px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fw-host .fw-winctl button:hover {
|
||||
color: var(--fw-fg);
|
||||
background: #1b1f25;
|
||||
}
|
||||
|
||||
.fw-host .fw-winctl button[data-win="close"]:hover {
|
||||
background: #c23b3b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fw-host .fw-body {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Container terminal body */
|
||||
.ct-body {
|
||||
background: #000000;
|
||||
}
|
||||
.ct-status {
|
||||
padding: 6px 10px;
|
||||
color: var(--fw-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ct-xterm {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 4px 2px 4px 6px;
|
||||
}
|
||||
.ct-xterm .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -20,6 +20,9 @@ import { UserAiUsage } from "./UserAiUsage.js";
|
||||
import { Tabs } from "./Tabs.js";
|
||||
import { DeviiTerminal } from "./DeviiTerminal.js";
|
||||
import { ZipDownloader } from "./ZipDownloader.js";
|
||||
import { ProjectForker } from "./ProjectForker.js";
|
||||
import WindowManager from "./components/WindowManager.js";
|
||||
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
|
||||
|
||||
class Application {
|
||||
constructor() {
|
||||
@@ -46,10 +49,14 @@ class Application {
|
||||
this.apiKey = new ApiKeyManager();
|
||||
this.userAiUsage = new UserAiUsage();
|
||||
this.tabs = new Tabs();
|
||||
this.windows = new WindowManager();
|
||||
this.containerTerminals = new ContainerTerminalManager();
|
||||
this.devii = new DeviiTerminal();
|
||||
this.zipDownloader = new ZipDownloader();
|
||||
this.projectForker = new ProjectForker();
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Application();
|
||||
window.app = app;
|
||||
app.containerTerminals.restore();
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export class ContainerInstance {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.slug = root.dataset.slug;
|
||||
this.uid = root.dataset.uid;
|
||||
this.status = root.dataset.status;
|
||||
this.base = `/projects/${this.slug}/containers`;
|
||||
this.name = root.dataset.name || this.uid;
|
||||
this.detailPoll = null;
|
||||
this.logPoll = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.renderActions(this.status);
|
||||
this.updateTerminalAvailability();
|
||||
this.bind();
|
||||
this.startDetailPoll();
|
||||
this.startLogPoll();
|
||||
}
|
||||
|
||||
bind() {
|
||||
this.q("#ci-exec-run").addEventListener("click", () => this.execOnce());
|
||||
this.q("#ci-term-toggle").addEventListener("click", () => this.openTerminal());
|
||||
const form = document.getElementById("ci-schedule-form");
|
||||
if (form) {
|
||||
form.addEventListener("submit", (e) => { e.preventDefault(); this.addSchedule(form); });
|
||||
const kind = form.elements.kind;
|
||||
kind.addEventListener("change", () => this.syncScheduleFields(kind.value));
|
||||
this.syncScheduleFields(kind.value);
|
||||
}
|
||||
this.q("#ci-schedules").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-schedule-delete]");
|
||||
if (btn) this.deleteSchedule(btn.dataset.scheduleDelete);
|
||||
});
|
||||
}
|
||||
|
||||
q(sel) { return this.root.querySelector(sel); }
|
||||
|
||||
async getJson(url) {
|
||||
const res = await fetch(url, { headers: { "Accept": "application/json" } });
|
||||
if (!res.ok) throw new Error(`request failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async post(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(body || {}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data.error && data.error.message) || `request failed: ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
toast(message, type) {
|
||||
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
|
||||
}
|
||||
|
||||
escape(value) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null ? "" : String(value);
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
// ---------------- actions ----------------
|
||||
|
||||
renderActions(status) {
|
||||
const running = status === "running";
|
||||
const paused = status === "paused";
|
||||
const spec = [
|
||||
{ action: "start", label: "Start", disabled: running },
|
||||
{ action: "stop", label: "Stop", disabled: status === "stopped" || status === "created" },
|
||||
{ action: "restart", label: "Restart", disabled: !running },
|
||||
{ action: "pause", label: "Pause", disabled: !running },
|
||||
{ action: "resume", label: "Resume", disabled: !paused },
|
||||
{ action: "sync", label: "Sync files", disabled: false },
|
||||
{ action: "delete", label: "Delete", disabled: false },
|
||||
];
|
||||
const box = this.q("#ci-actions");
|
||||
box.innerHTML = "";
|
||||
for (const item of spec) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn btn-secondary" + (item.action === "delete" ? " ci-danger" : "");
|
||||
btn.textContent = item.label;
|
||||
btn.disabled = item.disabled;
|
||||
btn.addEventListener("click", () => this.instanceAction(item.action));
|
||||
box.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async instanceAction(action) {
|
||||
if (action === "delete" && !window.confirm("Delete this instance? The container is removed.")) return;
|
||||
try {
|
||||
const url = action === "delete" ? `${this.base}/instances/${this.uid}/delete`
|
||||
: action === "sync" ? `${this.base}/instances/${this.uid}/sync`
|
||||
: `${this.base}/instances/${this.uid}/${action}`;
|
||||
const res = await this.post(url, {});
|
||||
if (action === "delete") { window.location.href = "/admin/containers"; return; }
|
||||
if (action === "sync") this.toast(`Synced ${res.data && res.data.imported} files`, "success");
|
||||
else this.toast(`${action} requested`, "success");
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
// ---------------- polling ----------------
|
||||
|
||||
startDetailPoll() {
|
||||
const tick = async () => {
|
||||
try {
|
||||
const detail = await this.getJson(`${this.base}/instances/${this.uid}`);
|
||||
this.applyDetail(detail);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
tick();
|
||||
this.detailPoll = setInterval(tick, 4000);
|
||||
}
|
||||
|
||||
applyDetail(detail) {
|
||||
const inst = detail.instance || {};
|
||||
if (inst.status && inst.status !== this.status) {
|
||||
this.status = inst.status;
|
||||
const badge = this.q("#ci-status");
|
||||
badge.textContent = inst.status;
|
||||
badge.className = `cm-badge cm-${inst.status}`;
|
||||
this.renderActions(inst.status);
|
||||
this.updateTerminalAvailability();
|
||||
}
|
||||
const stats = detail.stats || {};
|
||||
this.setMetric("samples", stats.samples);
|
||||
this.setMetric("cpu_avg", `${stats.cpu_avg ?? 0}%`);
|
||||
this.setMetric("cpu_p95", `${stats.cpu_p95 ?? 0}%`);
|
||||
this.setMetric("mem_max", stats.mem_max);
|
||||
const runtime = detail.runtime || {};
|
||||
const cid = this.q("#ci-container-id");
|
||||
if (cid) cid.textContent = runtime.container_id || "-";
|
||||
if (Array.isArray(detail.schedules)) this.renderSchedules(detail.schedules);
|
||||
}
|
||||
|
||||
setMetric(key, value) {
|
||||
const el = this.q(`[data-metric="${key}"]`);
|
||||
if (el) el.textContent = value == null ? "0" : value;
|
||||
}
|
||||
|
||||
startLogPoll() {
|
||||
const pre = this.q("#ci-log");
|
||||
const tick = async () => {
|
||||
try {
|
||||
const data = await this.getJson(`${this.base}/instances/${this.uid}/logs?tail=400`);
|
||||
const logs = data.logs || "";
|
||||
pre.classList.toggle("ci-empty", !logs);
|
||||
pre.textContent = logs || (this.status === "running"
|
||||
? "No output yet. This panel shows the container's main process (stdout and stderr); commands run in the terminal above are not included here."
|
||||
: "No logs. Start the instance to capture its main-process output.");
|
||||
if (logs) pre.scrollTop = pre.scrollHeight;
|
||||
} catch {
|
||||
pre.classList.add("ci-empty");
|
||||
pre.textContent = "Logs are currently unavailable.";
|
||||
}
|
||||
};
|
||||
tick();
|
||||
this.logPoll = setInterval(tick, 3000);
|
||||
}
|
||||
|
||||
// ---------------- exec ----------------
|
||||
|
||||
async execOnce() {
|
||||
const input = this.q("#ci-exec-input");
|
||||
const cmd = input.value.trim();
|
||||
if (!cmd) return;
|
||||
this.q("#ci-terminal").hidden = false;
|
||||
const pre = this.q("#ci-term");
|
||||
try {
|
||||
const res = await this.post(`${this.base}/instances/${this.uid}/exec`, { command: cmd });
|
||||
pre.textContent += `$ ${cmd}\n${this.cleanTerm(res.output || "")}\n`;
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
input.value = "";
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
updateTerminalAvailability() {
|
||||
const toggle = this.q("#ci-term-toggle");
|
||||
const running = this.status === "running";
|
||||
toggle.disabled = !running;
|
||||
toggle.title = running ? "" : "Start the instance to open an interactive shell";
|
||||
}
|
||||
|
||||
openTerminal() {
|
||||
if (this.status !== "running") return;
|
||||
if (window.app && window.app.containerTerminals) {
|
||||
window.app.containerTerminals.open(this.slug, this.uid, this.name);
|
||||
}
|
||||
}
|
||||
|
||||
cleanTerm(text) {
|
||||
return text
|
||||
.replace(/\x1b\][0-9];[^\x07\x1b]*(\x07|\x1b\\)/g, "")
|
||||
.replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, "")
|
||||
.replace(/\x1b[()#][0-9A-Za-z]/g, "")
|
||||
.replace(/\x1b[=>]/g, "");
|
||||
}
|
||||
|
||||
// ---------------- schedules ----------------
|
||||
|
||||
syncScheduleFields(kind) {
|
||||
const form = document.getElementById("ci-schedule-form");
|
||||
form.querySelectorAll("[data-sched-field]").forEach((field) => {
|
||||
field.hidden = field.dataset.schedField !== kind;
|
||||
});
|
||||
}
|
||||
|
||||
async addSchedule(form) {
|
||||
const kind = form.elements.kind.value;
|
||||
const body = { action: form.elements.action.value, kind };
|
||||
if (kind === "cron") body.cron = form.elements.cron.value.trim();
|
||||
if (kind === "interval") body.every_seconds = form.elements.every_seconds.value;
|
||||
if (kind === "once") body.delay_seconds = form.elements.delay_seconds.value;
|
||||
try {
|
||||
await this.post(`${this.base}/instances/${this.uid}/schedules`, body);
|
||||
const modal = document.getElementById("ci-schedule-modal");
|
||||
if (modal) modal.classList.remove("visible");
|
||||
form.reset();
|
||||
this.syncScheduleFields(form.elements.kind.value);
|
||||
this.toast("Schedule added", "success");
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
async deleteSchedule(sid) {
|
||||
const ok = await window.app.dialog.confirm({
|
||||
title: "Delete schedule",
|
||||
message: "Delete this schedule? The automated task is removed.",
|
||||
confirmLabel: "Delete",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await this.post(`${this.base}/instances/${this.uid}/schedules/${sid}/delete`, {});
|
||||
this.toast("Schedule removed", "success");
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
renderSchedules(schedules) {
|
||||
const list = this.q("#ci-schedules");
|
||||
if (!schedules.length) {
|
||||
list.innerHTML = `<li class="cm-muted ci-schedule-empty">No schedules.</li>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = schedules.map((s) => `<li class="ci-schedule" data-sid="${this.escape(s.uid)}">
|
||||
<span class="ci-schedule-action">${this.escape(s.action)}</span>
|
||||
<span class="ci-schedule-next">next ${this.escape(s.next_run_at || "-")}</span>
|
||||
<span class="cm-muted">runs ${this.escape(s.run_count || 0)}</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="${this.escape(s.uid)}">Delete</button>
|
||||
</li>`).join("");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export class ContainerList {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.endpoint = root.dataset.endpoint;
|
||||
this.body = document.getElementById("cm-admin-rows");
|
||||
this.pollMs = 4000;
|
||||
}
|
||||
|
||||
init() {
|
||||
setInterval(() => this.refresh(), this.pollMs);
|
||||
}
|
||||
|
||||
escape(value) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null ? "" : String(value);
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
let instances;
|
||||
try {
|
||||
const res = await fetch(this.endpoint, { headers: { "Accept": "application/json" } });
|
||||
if (!res.ok) return;
|
||||
instances = (await res.json()).instances || [];
|
||||
} catch { return; }
|
||||
if (!this.body) return;
|
||||
if (!instances.length) {
|
||||
this.body.innerHTML = `<tr><td colspan="6" class="admin-empty">No container instances yet. Open a project's container manager to launch one.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
this.body.innerHTML = instances.map((inst) => this.row(inst)).join("");
|
||||
}
|
||||
|
||||
row(inst) {
|
||||
const uid = this.escape(inst.uid);
|
||||
const project = inst.project_slug
|
||||
? `<a class="cm-row-project" href="/projects/${this.escape(inst.project_slug)}/containers">${this.escape(inst.project_title || inst.project_slug)}</a>`
|
||||
: `<span class="cm-muted">-</span>`;
|
||||
const ingress = inst.ingress_slug
|
||||
? `<a href="/p/${this.escape(inst.ingress_slug)}" target="_blank" rel="noopener">/p/${this.escape(inst.ingress_slug)}</a>`
|
||||
: `<span class="cm-muted">-</span>`;
|
||||
return `<tr class="cm-row" data-uid="${uid}">
|
||||
<td><a class="cm-row-name" href="/admin/containers/${uid}">${this.escape(inst.name)}</a></td>
|
||||
<td>${project}</td>
|
||||
<td><span class="cm-badge cm-${this.escape(inst.status)}">${this.escape(inst.status)}</span></td>
|
||||
<td>${ingress}</td>
|
||||
<td>${this.escape(inst.restart_policy || "never")}</td>
|
||||
<td><a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}">Manage →</a></td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
@@ -5,29 +5,19 @@ export class ContainerManager {
|
||||
this.root = root;
|
||||
this.slug = root.dataset.slug;
|
||||
this.base = `/projects/${this.slug}/containers`;
|
||||
this.dockerfiles = [];
|
||||
this.instances = [];
|
||||
this.selectedDockerfile = null;
|
||||
this.pollMs = 3000;
|
||||
this.buildPoll = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
const data = JSON.parse(this.root.querySelector("#cm-data").textContent || "{}");
|
||||
this.dockerfiles = data.dockerfiles || [];
|
||||
this.instances = data.instances || [];
|
||||
this.bind();
|
||||
this.renderDockerfiles();
|
||||
this.renderInstances();
|
||||
this.fillDockerfileSelect();
|
||||
setInterval(() => this.refresh(), this.pollMs);
|
||||
}
|
||||
|
||||
bind() {
|
||||
this.q("#cm-save-version").addEventListener("click", () => this.saveVersion());
|
||||
this.q("#cm-rebuild").addEventListener("click", () => this.rebuild());
|
||||
const dfForm = document.getElementById("cm-new-dockerfile-form");
|
||||
if (dfForm) dfForm.addEventListener("submit", (e) => { e.preventDefault(); this.createDockerfile(dfForm); });
|
||||
const instForm = document.getElementById("cm-new-instance-form");
|
||||
if (instForm) instForm.addEventListener("submit", (e) => { e.preventDefault(); this.createInstance(instForm); });
|
||||
}
|
||||
@@ -63,129 +53,11 @@ export class ContainerManager {
|
||||
async refresh() {
|
||||
try {
|
||||
const data = await this.getJson(`${this.base}/data`);
|
||||
this.dockerfiles = data.dockerfiles || [];
|
||||
this.instances = data.instances || [];
|
||||
this.renderDockerfiles();
|
||||
this.renderInstances();
|
||||
this.fillDockerfileSelect();
|
||||
} catch { /* ignore poll errors */ }
|
||||
}
|
||||
|
||||
// ---------------- dockerfiles ----------------
|
||||
|
||||
renderDockerfiles() {
|
||||
const list = this.q("#cm-dockerfile-list");
|
||||
list.innerHTML = "";
|
||||
for (const df of this.dockerfiles) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "cm-item" + (this.selectedDockerfile === df.uid ? " active" : "");
|
||||
li.textContent = `${df.name} (v${df.current_version}, build ${df.latest_build_number})`;
|
||||
li.addEventListener("click", () => this.openDockerfile(df.uid));
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
fillDockerfileSelect() {
|
||||
const select = document.getElementById("cm-inst-new-df");
|
||||
if (!select) return;
|
||||
const current = select.value;
|
||||
select.innerHTML = "";
|
||||
for (const df of this.dockerfiles) {
|
||||
const option = document.createElement("option");
|
||||
option.value = df.uid;
|
||||
option.textContent = df.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
if (current) select.value = current;
|
||||
else if (this.selectedDockerfile) select.value = this.selectedDockerfile;
|
||||
}
|
||||
|
||||
async createDockerfile(form) {
|
||||
const name = form.elements.name.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await this.post(`${this.base}/dockerfiles`, {
|
||||
name, description: form.elements.description.value.trim(), tags: "",
|
||||
});
|
||||
this.closeModal("cm-new-dockerfile-modal");
|
||||
form.reset();
|
||||
this.toast("Dockerfile created, build queued", "success");
|
||||
await this.refresh();
|
||||
if (res.data && res.data.dockerfile) this.openDockerfile(res.data.dockerfile.uid);
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
async openDockerfile(uid) {
|
||||
this.selectedDockerfile = uid;
|
||||
try {
|
||||
const detail = await this.getJson(`${this.base}/dockerfiles/${uid}`);
|
||||
this.q("#cm-dockerfile-detail").hidden = false;
|
||||
this.q("#cm-df-name").textContent = detail.dockerfile.name;
|
||||
this.q("#cm-df-editor").value = detail.content || "";
|
||||
this.renderBuilds(detail.builds || []);
|
||||
this.renderDockerfiles();
|
||||
this.fillDockerfileSelect();
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
renderBuilds(builds) {
|
||||
const list = this.q("#cm-build-list");
|
||||
list.innerHTML = "";
|
||||
for (const b of builds) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "cm-item";
|
||||
li.textContent = `${b.image_tag} [${b.status}]`;
|
||||
li.addEventListener("click", () => this.watchBuild(b.uid));
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async saveVersion() {
|
||||
if (!this.selectedDockerfile) return;
|
||||
const content = this.q("#cm-df-editor").value;
|
||||
try {
|
||||
const res = await this.post(`${this.base}/dockerfiles/${this.selectedDockerfile}/version`, { content });
|
||||
if (res.data && res.data.changed) {
|
||||
this.toast("New version saved, build queued", "success");
|
||||
if (res.data.build_uid) this.watchBuild(res.data.build_uid);
|
||||
} else {
|
||||
this.toast("No change in content", "info");
|
||||
}
|
||||
await this.refresh();
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
async rebuild() {
|
||||
if (!this.selectedDockerfile) return;
|
||||
try {
|
||||
const res = await this.post(`${this.base}/dockerfiles/${this.selectedDockerfile}/build`, {});
|
||||
this.toast("Rebuild queued", "success");
|
||||
if (res.data && res.data.build_uid) this.watchBuild(res.data.build_uid);
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
watchBuild(uid) {
|
||||
if (this.buildPoll) clearInterval(this.buildPoll);
|
||||
const pre = this.q("#cm-build-log");
|
||||
pre.hidden = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const build = await this.getJson(`${this.base}/builds/${uid}`);
|
||||
pre.textContent = `[${build.status}] ${build.image_tag}\n\n${build.logs || ""}${build.error || ""}`;
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
if (["success", "failed", "cancelled"].includes(build.status)) {
|
||||
clearInterval(this.buildPoll);
|
||||
this.buildPoll = null;
|
||||
this.refresh();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
tick();
|
||||
this.buildPoll = setInterval(tick, 1500);
|
||||
}
|
||||
|
||||
// ---------------- instances ----------------
|
||||
|
||||
renderInstances() {
|
||||
const list = this.q("#cm-instance-list");
|
||||
list.innerHTML = "";
|
||||
@@ -202,12 +74,10 @@ export class ContainerManager {
|
||||
}
|
||||
|
||||
async createInstance(form) {
|
||||
if (!this.dockerfiles.length) { this.toast("Create a Dockerfile first", "error"); return; }
|
||||
const name = form.elements.name.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await this.post(`${this.base}/instances`, {
|
||||
dockerfile_uid: form.elements.dockerfile_uid.value,
|
||||
name,
|
||||
boot_command: form.elements.boot_command.value.trim(),
|
||||
restart_policy: form.elements.restart_policy.value,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { userScope } from "./userScope.js";
|
||||
|
||||
const SESSION_NAME = "devplace";
|
||||
|
||||
function persistKey() {
|
||||
return `ct-persistent:${userScope()}`;
|
||||
}
|
||||
|
||||
export class ContainerTerminalManager {
|
||||
constructor() {
|
||||
this._terminals = new Map();
|
||||
this._persistent = null;
|
||||
}
|
||||
|
||||
open(projectSlug, instance, label) {
|
||||
if (!projectSlug || !instance) return null;
|
||||
const key = `${projectSlug}::${instance}`;
|
||||
const existing = this._terminals.get(key);
|
||||
if (existing && existing.isConnected) {
|
||||
this._makePersistent(existing, projectSlug, instance, label);
|
||||
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.focus(existing);
|
||||
return existing;
|
||||
}
|
||||
this._demoteCurrent(null);
|
||||
const element = document.createElement("container-terminal");
|
||||
element.setAttribute("project-slug", projectSlug);
|
||||
element.setAttribute("instance", instance);
|
||||
if (label) element.setAttribute("label", label);
|
||||
element.setAttribute("session", SESSION_NAME);
|
||||
element.setAttribute("persist", "");
|
||||
document.body.appendChild(element);
|
||||
this._terminals.set(key, element);
|
||||
this._persistent = element;
|
||||
this._remember(projectSlug, instance, label);
|
||||
return element;
|
||||
}
|
||||
|
||||
_makePersistent(element, projectSlug, instance, label) {
|
||||
this._demoteCurrent(element);
|
||||
element.setAttribute("session", SESSION_NAME);
|
||||
if (typeof element.setPersistent === "function") element.setPersistent(true);
|
||||
this._persistent = element;
|
||||
this._remember(projectSlug, instance, label);
|
||||
}
|
||||
|
||||
_demoteCurrent(except) {
|
||||
if (this._persistent && this._persistent !== except && this._persistent.isConnected) {
|
||||
if (typeof this._persistent.setPersistent === "function") this._persistent.setPersistent(false);
|
||||
}
|
||||
}
|
||||
|
||||
forget(element) {
|
||||
this._terminals.forEach((el, key) => {
|
||||
if (el === element) this._terminals.delete(key);
|
||||
});
|
||||
if (element === this._persistent) {
|
||||
this._persistent = null;
|
||||
this._clearRemember();
|
||||
}
|
||||
}
|
||||
|
||||
restore() {
|
||||
let record = null;
|
||||
try {
|
||||
record = JSON.parse(window.localStorage.getItem(persistKey()) || "null");
|
||||
} catch (error) {
|
||||
record = null;
|
||||
}
|
||||
if (record && record.slug && record.instance) {
|
||||
this.open(record.slug, record.instance, record.label || record.instance);
|
||||
}
|
||||
}
|
||||
|
||||
applyFontSize(size) {
|
||||
this._terminals.forEach((element) => {
|
||||
if (element.isConnected && typeof element.setFontSize === "function") {
|
||||
element.setFontSize(size);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_remember(slug, instance, label) {
|
||||
try {
|
||||
window.localStorage.setItem(persistKey(), JSON.stringify({ slug, instance, label: label || instance }));
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_clearRemember() {
|
||||
try {
|
||||
window.localStorage.removeItem(persistKey());
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,9 +62,31 @@ export class FormManager {
|
||||
|
||||
initAutoSubmitSelects() {
|
||||
document.querySelectorAll("[data-auto-submit]").forEach((select) => {
|
||||
let previous = select.value;
|
||||
select.addEventListener("change", () => {
|
||||
const form = select.closest("form");
|
||||
if (form) form.submit();
|
||||
if (!form) return;
|
||||
const message = select.dataset.confirm;
|
||||
if (!message) {
|
||||
previous = select.value;
|
||||
form.submit();
|
||||
return;
|
||||
}
|
||||
const dialog = window.app && window.app.dialog;
|
||||
if (!dialog) {
|
||||
if (confirm(message)) { previous = select.value; form.submit(); }
|
||||
else select.value = previous;
|
||||
return;
|
||||
}
|
||||
dialog.confirm({
|
||||
message,
|
||||
title: select.dataset.confirmTitle || "Please confirm",
|
||||
confirmLabel: select.dataset.confirmLabel,
|
||||
danger: select.dataset.confirmDanger !== undefined,
|
||||
}).then((ok) => {
|
||||
if (ok) { previous = select.value; form.submit(); }
|
||||
else select.value = previous;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
export class ProjectForker {
|
||||
constructor() {
|
||||
this.pollMs = 1500;
|
||||
this.maxPolls = 200;
|
||||
this.active = new Set();
|
||||
document.addEventListener("click", (event) => {
|
||||
const trigger = event.target.closest("[data-fork-project]");
|
||||
if (!trigger) return;
|
||||
event.preventDefault();
|
||||
this.start(trigger.dataset.forkProject, trigger.dataset.forkName || "");
|
||||
});
|
||||
}
|
||||
|
||||
async start(enqueueUrl, sourceName) {
|
||||
if (this.active.has(enqueueUrl)) return;
|
||||
const title = await window.app.dialog.prompt({
|
||||
title: "Fork project",
|
||||
label: "New project name",
|
||||
value: sourceName ? `${sourceName} (fork)` : "",
|
||||
confirmLabel: "Fork",
|
||||
});
|
||||
if (title === null) return;
|
||||
const name = title.trim();
|
||||
if (!name) {
|
||||
window.app.toast.show("A project name is required", { type: "error" });
|
||||
return;
|
||||
}
|
||||
this.active.add(enqueueUrl);
|
||||
window.app.toast.show("Forking project...", { type: "info", ms: 4000 });
|
||||
try {
|
||||
const job = await Http.sendForm(enqueueUrl, { title: name });
|
||||
await this.poll(job.status_url);
|
||||
} catch (err) {
|
||||
window.app.toast.show("Could not start the fork", { type: "error" });
|
||||
} finally {
|
||||
this.active.delete(enqueueUrl);
|
||||
}
|
||||
}
|
||||
|
||||
poll(statusUrl) {
|
||||
return new Promise((resolve) => {
|
||||
let attempts = 0;
|
||||
const timer = setInterval(async () => {
|
||||
attempts += 1;
|
||||
if (attempts > this.maxPolls) {
|
||||
clearInterval(timer);
|
||||
window.app.toast.show("Fork timed out", { type: "error" });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let status;
|
||||
try {
|
||||
status = await Http.getJson(statusUrl);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (status.status === "done") {
|
||||
clearInterval(timer);
|
||||
window.app.toast.show("Fork ready", { type: "success" });
|
||||
window.location.href = status.project_url;
|
||||
resolve();
|
||||
} else if (status.status === "failed") {
|
||||
clearInterval(timer);
|
||||
window.app.toast.show("Fork failed: " + (status.error || "unknown error"), { type: "error" });
|
||||
resolve();
|
||||
}
|
||||
}, this.pollMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export class AppDialog extends Component {
|
||||
this.messageEl.hidden = !opts.message;
|
||||
this.confirmBtn.textContent = opts.confirmLabel || (mode === "alert" ? "OK" : mode === "prompt" ? "Save" : "Confirm");
|
||||
this.cancelBtn.textContent = opts.cancelLabel || "Cancel";
|
||||
this.cancelBtn.hidden = mode === "alert";
|
||||
this.cancelBtn.style.display = mode === "alert" ? "none" : "";
|
||||
this.confirmBtn.classList.toggle("dialog-danger", !!opts.danger);
|
||||
|
||||
if (mode === "prompt") {
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import FloatingWindow from "./FloatingWindow.js";
|
||||
import { userScope } from "../userScope.js";
|
||||
|
||||
const XTERM_JS = "/static/vendor/xterm/xterm.js";
|
||||
const XTERM_CSS = "/static/vendor/xterm/xterm.css";
|
||||
const XTERM_FIT = "/static/vendor/xterm/addon-fit.js";
|
||||
|
||||
const FONT_MIN = 8;
|
||||
const FONT_MAX = 30;
|
||||
const FONT_DEFAULT = 13;
|
||||
|
||||
let xtermLoading = null;
|
||||
let cascade = 0;
|
||||
|
||||
function fontKey() {
|
||||
return `ct-fontsize:${userScope()}`;
|
||||
}
|
||||
|
||||
function readFontSize() {
|
||||
try {
|
||||
const value = parseInt(window.localStorage.getItem(fontKey()), 10);
|
||||
return Number.isFinite(value) ? Math.max(FONT_MIN, Math.min(FONT_MAX, value)) : FONT_DEFAULT;
|
||||
} catch (error) {
|
||||
return FONT_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFontSize(size) {
|
||||
try {
|
||||
window.localStorage.setItem(fontKey(), String(size));
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector(`script[src="${src}"]`)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`failed to load ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function loadCss(href, id) {
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function ensureXterm() {
|
||||
if (window.Terminal && window.FitAddon) return Promise.resolve();
|
||||
if (!xtermLoading) {
|
||||
loadCss(XTERM_CSS, "xterm-css");
|
||||
xtermLoading = loadScript(XTERM_JS).then(() => loadScript(XTERM_FIT));
|
||||
}
|
||||
return xtermLoading;
|
||||
}
|
||||
|
||||
export default class ContainerTerminalElement extends FloatingWindow {
|
||||
get windowKey() {
|
||||
return "container-terminal";
|
||||
}
|
||||
|
||||
get titleText() {
|
||||
return this.getAttribute("label") || this.getAttribute("instance") || "terminal";
|
||||
}
|
||||
|
||||
get fontControls() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get isPersistent() {
|
||||
return this.hasAttribute("persist");
|
||||
}
|
||||
|
||||
setPersistent(on) {
|
||||
if (on) {
|
||||
this.setAttribute("persist", "");
|
||||
} else {
|
||||
this.removeAttribute("persist");
|
||||
this._clearReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
_clearReconnect() {
|
||||
if (this._reconnectTimer) {
|
||||
window.clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
setFontSize(size) {
|
||||
if (!this.term) return;
|
||||
this.term.options.fontSize = size;
|
||||
this._onResize();
|
||||
}
|
||||
|
||||
_changeFont(delta) {
|
||||
const next = Math.max(FONT_MIN, Math.min(FONT_MAX, readFontSize() + delta));
|
||||
writeFontSize(next);
|
||||
if (globalThis.app && globalThis.app.containerTerminals) {
|
||||
globalThis.app.containerTerminals.applyFontSize(next);
|
||||
} else {
|
||||
this.setFontSize(next);
|
||||
}
|
||||
}
|
||||
|
||||
_defaultGeometry() {
|
||||
const geometry = super._defaultGeometry();
|
||||
const offset = (cascade++ % 6) * 26;
|
||||
geometry.left = Math.max(12, geometry.left + offset);
|
||||
geometry.top = Math.max(12, geometry.top + offset);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
async _buildBody(body) {
|
||||
body.classList.add("ct-body");
|
||||
this._status = document.createElement("div");
|
||||
this._status.className = "ct-status";
|
||||
this._status.textContent = "connecting...";
|
||||
body.appendChild(this._status);
|
||||
this._mount = document.createElement("div");
|
||||
this._mount.className = "ct-xterm";
|
||||
body.appendChild(this._mount);
|
||||
try {
|
||||
await ensureXterm();
|
||||
} catch (error) {
|
||||
this._status.textContent = "failed to load terminal assets";
|
||||
return;
|
||||
}
|
||||
if (!this.isConnected) return;
|
||||
this._initTerm();
|
||||
}
|
||||
|
||||
_initTerm() {
|
||||
const Terminal = window.Terminal;
|
||||
const FitAddon = window.FitAddon.FitAddon;
|
||||
this.term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: '"SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace',
|
||||
fontSize: readFontSize(),
|
||||
theme: { background: "#000000", foreground: "#d0d0d0" },
|
||||
scrollback: 5000,
|
||||
});
|
||||
this.fit = new FitAddon();
|
||||
this.term.loadAddon(this.fit);
|
||||
this.term.open(this._mount);
|
||||
this.term.onData((data) => {
|
||||
if (this._ws && this._ws.readyState === 1) this._ws.send(data);
|
||||
});
|
||||
this._fitSoon();
|
||||
this._connect();
|
||||
}
|
||||
|
||||
_fitSoon() {
|
||||
[0, 60, 180, 400].forEach((delay) => window.setTimeout(() => this._onResize(), delay));
|
||||
}
|
||||
|
||||
_connect() {
|
||||
const slug = this.getAttribute("project-slug");
|
||||
const instance = this.getAttribute("instance");
|
||||
const session = this.getAttribute("session") || "";
|
||||
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
||||
const query = session ? `?session=${encodeURIComponent(session)}` : "";
|
||||
const url = `${scheme}://${location.host}/projects/${encodeURIComponent(slug)}`
|
||||
+ `/containers/instances/${encodeURIComponent(instance)}/exec/ws${query}`;
|
||||
const ws = new WebSocket(url);
|
||||
this._ws = ws;
|
||||
ws.addEventListener("open", () => {
|
||||
this._reconnects = 0;
|
||||
if (this._status) this._status.style.display = "none";
|
||||
this._fitSoon();
|
||||
if (this.term) this.term.focus();
|
||||
});
|
||||
ws.addEventListener("message", (event) => {
|
||||
if (this.term) this.term.write(event.data);
|
||||
});
|
||||
ws.addEventListener("close", (event) => {
|
||||
this._ws = null;
|
||||
if (this._closing) return;
|
||||
if (event.code === 1008) {
|
||||
if (this.term) this.term.write("\r\n\x1b[31m[forbidden]\x1b[0m\r\n");
|
||||
return;
|
||||
}
|
||||
if (this.isPersistent) {
|
||||
this._scheduleReconnect();
|
||||
} else if (this.term) {
|
||||
this.term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
|
||||
}
|
||||
});
|
||||
ws.addEventListener("error", () => {});
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (this._closing || this._reconnectTimer) return;
|
||||
this._reconnects = (this._reconnects || 0) + 1;
|
||||
const delay = Math.min(5000, 400 * this._reconnects);
|
||||
if (this._status) {
|
||||
this._status.style.display = "";
|
||||
this._status.textContent = "reconnecting...";
|
||||
}
|
||||
this._reconnectTimer = window.setTimeout(() => {
|
||||
this._reconnectTimer = null;
|
||||
if (!this._closing && this.isConnected) this._connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
_sendResize() {
|
||||
if (!this.term || !this._ws || this._ws.readyState !== 1) return;
|
||||
this._ws.send(JSON.stringify({ type: "resize", cols: this.term.cols, rows: this.term.rows }));
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
if (!this.fit || !this.term || !this.isConnected) return;
|
||||
try {
|
||||
this.fit.fit();
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
this._sendResize();
|
||||
}
|
||||
|
||||
_onShow() {
|
||||
if (this.term) this.term.focus();
|
||||
}
|
||||
|
||||
_contextItems() {
|
||||
const slug = this.getAttribute("project-slug");
|
||||
return [
|
||||
{ label: "Sync files", onSelect: () => this._lifecycle("sync", "Files synced") },
|
||||
{ label: "Restart", onSelect: () => this._lifecycle("restart", "Restart requested") },
|
||||
{ label: "Terminate", danger: true, onSelect: () => this._terminate() },
|
||||
{ separator: true },
|
||||
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
|
||||
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
|
||||
{ separator: true },
|
||||
{ label: "Project", onSelect: () => this._navigate(`/projects/${slug}`) },
|
||||
{ label: "Files", onSelect: () => this._navigate(`/projects/${slug}/files`) },
|
||||
{ separator: true },
|
||||
{ label: "Close", danger: true, onSelect: () => this.close() },
|
||||
];
|
||||
}
|
||||
|
||||
_post(action) {
|
||||
const slug = this.getAttribute("project-slug");
|
||||
const instance = this.getAttribute("instance");
|
||||
return fetch(
|
||||
`/projects/${encodeURIComponent(slug)}/containers/instances/${encodeURIComponent(instance)}/${action}`,
|
||||
{ method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" } },
|
||||
);
|
||||
}
|
||||
|
||||
async _lifecycle(action, okMessage) {
|
||||
try {
|
||||
const res = await this._post(action);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) this._toast(okMessage, "success");
|
||||
else this._toast((data.error && data.error.message) || `${action} failed`, "error");
|
||||
} catch (error) {
|
||||
this._toast(`${action} failed`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async _terminate() {
|
||||
if (globalThis.app && globalThis.app.dialog) {
|
||||
const ok = await globalThis.app.dialog.confirm({
|
||||
title: "Terminate container",
|
||||
message: `Stop and remove "${this.titleText}"? Synced project files are kept.`,
|
||||
confirmLabel: "Terminate",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
await this._lifecycle("delete", "Container terminated");
|
||||
this.close();
|
||||
}
|
||||
|
||||
_navigate(url) {
|
||||
window.location.assign(url);
|
||||
}
|
||||
|
||||
_toast(message, type) {
|
||||
if (globalThis.app && globalThis.app.toast) globalThis.app.toast.show(message, { type: type || "info" });
|
||||
}
|
||||
|
||||
_onClose() {
|
||||
this._closing = true;
|
||||
this._clearReconnect();
|
||||
if (globalThis.app && globalThis.app.containerTerminals) {
|
||||
globalThis.app.containerTerminals.forget(this);
|
||||
}
|
||||
try {
|
||||
if (this._ws) this._ws.close();
|
||||
} catch (error) {
|
||||
return this._dispose();
|
||||
}
|
||||
this._dispose();
|
||||
}
|
||||
|
||||
_dispose() {
|
||||
try {
|
||||
if (this.term) this.term.dispose();
|
||||
} catch (error) {
|
||||
// already disposed
|
||||
}
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("container-terminal", ContainerTerminalElement);
|
||||
@@ -0,0 +1,356 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const FW_CSS_ID = "floating-window-css";
|
||||
const MOBILE_QUERY = "(max-width: 640px)";
|
||||
|
||||
export default class FloatingWindow extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = "normal";
|
||||
this.geometry = null;
|
||||
this._drag = null;
|
||||
this._rafResize = null;
|
||||
this._mobile = window.matchMedia(MOBILE_QUERY);
|
||||
}
|
||||
|
||||
get windowKey() {
|
||||
return "floating-window";
|
||||
}
|
||||
|
||||
get titleText() {
|
||||
return this.getAttribute("label") || "Window";
|
||||
}
|
||||
|
||||
get fontControls() {
|
||||
return false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._ensureBaseCss();
|
||||
this.classList.add("fw-host");
|
||||
this._buildChrome();
|
||||
this._registerWindow();
|
||||
this._viewportHandler = () => this._onViewportChange();
|
||||
this._mobile.addEventListener("change", this._viewportHandler);
|
||||
this._winResize = () => {
|
||||
this._clampWindow();
|
||||
this._scheduleResize();
|
||||
};
|
||||
window.addEventListener("resize", this._winResize);
|
||||
const saved = this._loadGeometry();
|
||||
if (saved) {
|
||||
this.geometry = saved;
|
||||
this._clampGeometry();
|
||||
}
|
||||
this._setState(this._mobile.matches ? "fullscreen" : "normal");
|
||||
this._buildBody(this.body);
|
||||
this._observeResize();
|
||||
this._onShow();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("resize", this._winResize);
|
||||
this._mobile.removeEventListener("change", this._viewportHandler);
|
||||
if (this._ro) this._ro.disconnect();
|
||||
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.unregister(this);
|
||||
this._onHide();
|
||||
}
|
||||
|
||||
_ensureBaseCss() {
|
||||
if (document.getElementById(FW_CSS_ID)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = FW_CSS_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/floating-window.css";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
_registerWindow() {
|
||||
if (globalThis.app && globalThis.app.windows) {
|
||||
globalThis.app.windows.register(this);
|
||||
}
|
||||
this.addEventListener("pointerdown", () => {
|
||||
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.focus(this);
|
||||
});
|
||||
if (globalThis.app && globalThis.app.contextMenu) {
|
||||
globalThis.app.contextMenu.attach(this.win, () => this._contextItems());
|
||||
}
|
||||
}
|
||||
|
||||
_buildChrome() {
|
||||
this.innerHTML = "";
|
||||
this.win = document.createElement("div");
|
||||
this.win.className = "fw-window";
|
||||
const fontButtons = this.fontControls ? [
|
||||
' <button type="button" data-win="font-dec" title="Smaller font">−</button>',
|
||||
' <button type="button" data-win="font-inc" title="Larger font">+</button>',
|
||||
] : [];
|
||||
this.win.innerHTML = [
|
||||
'<div class="fw-titlebar">',
|
||||
' <span class="fw-title"><span class="fw-dot"></span><span class="fw-title-text"></span></span>',
|
||||
' <span class="fw-winctl">',
|
||||
...fontButtons,
|
||||
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">▫</button>',
|
||||
' <button type="button" data-win="normalize" title="Normalize (comfortable size)">▭</button>',
|
||||
' <button type="button" data-win="fullscreen" title="Fullscreen">⛶</button>',
|
||||
' <button type="button" data-win="maximize" title="Maximize">□</button>',
|
||||
' <button type="button" data-win="close" title="Close">✕</button>',
|
||||
" </span>",
|
||||
"</div>",
|
||||
'<div class="fw-body"></div>',
|
||||
'<div class="fw-resize"></div>',
|
||||
].join("");
|
||||
this.appendChild(this.win);
|
||||
this.titlebar = this.win.querySelector(".fw-titlebar");
|
||||
this.body = this.win.querySelector(".fw-body");
|
||||
this.win.querySelector(".fw-title-text").textContent = this.titleText;
|
||||
this.win.querySelectorAll(".fw-winctl button").forEach((button) => {
|
||||
button.addEventListener("click", () => this._window(button.dataset.win));
|
||||
});
|
||||
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
|
||||
this.titlebar.addEventListener("touchstart", (event) => this._startDrag(event), { passive: false });
|
||||
this.titlebar.addEventListener("dblclick", () => this._window("maximize"));
|
||||
const grip = this.win.querySelector(".fw-resize");
|
||||
grip.addEventListener("mousedown", (event) => this._startResize(event));
|
||||
grip.addEventListener("touchstart", (event) => this._startResize(event), { passive: false });
|
||||
}
|
||||
|
||||
setTitle(text) {
|
||||
const el = this.win && this.win.querySelector(".fw-title-text");
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
_buildBody() {}
|
||||
_onShow() {}
|
||||
_onHide() {}
|
||||
_onResize() {}
|
||||
_onClose() { this.remove(); }
|
||||
_changeFont() {}
|
||||
|
||||
close() {
|
||||
this._onClose();
|
||||
}
|
||||
|
||||
_window(action) {
|
||||
if (action === "close") {
|
||||
this.close();
|
||||
} else if (action === "maximize") {
|
||||
this._setState(this.state === "maximized" ? "normal" : "maximized");
|
||||
} else if (action === "fullscreen") {
|
||||
this._setState(this.state === "fullscreen" ? "normal" : "fullscreen");
|
||||
} else if (action === "font-inc") {
|
||||
this._changeFont(1);
|
||||
} else if (action === "font-dec") {
|
||||
this._changeFont(-1);
|
||||
} else if (action === "minimize") {
|
||||
this.minimizeWindow();
|
||||
} else if (action === "normalize") {
|
||||
this.normalizeWindow();
|
||||
}
|
||||
}
|
||||
|
||||
minimizeWindow() {
|
||||
this._presetGeometry(400, 260);
|
||||
}
|
||||
|
||||
normalizeWindow() {
|
||||
this._presetGeometry(820, 560);
|
||||
}
|
||||
|
||||
_presetGeometry(width, height) {
|
||||
const w = Math.max(320, Math.min(width, window.innerWidth - 16));
|
||||
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
|
||||
this.geometry = {
|
||||
width: w,
|
||||
height: h,
|
||||
left: Math.max(8, Math.round((window.innerWidth - w) / 2)),
|
||||
top: Math.max(8, Math.round((window.innerHeight - h) / 2)),
|
||||
};
|
||||
this._setState("normal");
|
||||
this._persistGeometry();
|
||||
}
|
||||
|
||||
_contextItems() {
|
||||
return [
|
||||
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
|
||||
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
|
||||
{ separator: true },
|
||||
{ label: "Close", danger: true, onSelect: () => this.close() },
|
||||
];
|
||||
}
|
||||
|
||||
_setState(state) {
|
||||
let resolved = state;
|
||||
if (this._mobile.matches) resolved = "fullscreen";
|
||||
this.state = resolved;
|
||||
this.classList.toggle("fw-mobile", this._mobile.matches);
|
||||
["normal", "maximized", "fullscreen"].forEach((name) => {
|
||||
this.classList.toggle(`fw-state-${name}`, name === resolved);
|
||||
});
|
||||
if (resolved === "normal") {
|
||||
this._applyGeometry();
|
||||
} else {
|
||||
this.win.style.left = "";
|
||||
this.win.style.top = "";
|
||||
this.win.style.width = "";
|
||||
this.win.style.height = "";
|
||||
}
|
||||
this._scheduleResize();
|
||||
}
|
||||
|
||||
_defaultGeometry() {
|
||||
const width = Math.min(820, Math.round(window.innerWidth * 0.94));
|
||||
const height = Math.min(560, Math.round(window.innerHeight * 0.82));
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: Math.max(12, Math.round((window.innerWidth - width) / 2)),
|
||||
top: Math.max(12, Math.round((window.innerHeight - height) / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
_applyGeometry() {
|
||||
if (!this.geometry) this.geometry = this._defaultGeometry();
|
||||
const g = this.geometry;
|
||||
this.win.style.width = `${g.width}px`;
|
||||
this.win.style.height = `${g.height}px`;
|
||||
this.win.style.left = `${g.left}px`;
|
||||
this.win.style.top = `${g.top}px`;
|
||||
}
|
||||
|
||||
_clampGeometry() {
|
||||
if (!this.geometry) return;
|
||||
const g = this.geometry;
|
||||
g.width = Math.min(g.width, window.innerWidth - 16);
|
||||
g.height = Math.min(g.height, window.innerHeight - 16);
|
||||
g.left = Math.max(8, Math.min(g.left, window.innerWidth - g.width - 8));
|
||||
g.top = Math.max(8, Math.min(g.top, window.innerHeight - g.height - 8));
|
||||
}
|
||||
|
||||
_clampWindow() {
|
||||
if (this.state !== "normal" || !this.geometry) return;
|
||||
this._clampGeometry();
|
||||
this._applyGeometry();
|
||||
this._persistGeometry();
|
||||
this._scheduleResize();
|
||||
}
|
||||
|
||||
_onViewportChange() {
|
||||
this._setState("normal");
|
||||
}
|
||||
|
||||
_startDrag(event) {
|
||||
if (this.state !== "normal") return;
|
||||
if (event.target.tagName === "BUTTON") return;
|
||||
event.preventDefault();
|
||||
const point = event.touches ? event.touches[0] : event;
|
||||
const rect = this.win.getBoundingClientRect();
|
||||
this._drag = { dx: point.clientX - rect.left, dy: point.clientY - rect.top };
|
||||
this.classList.add("fw-dragging");
|
||||
this._moveHandler = (move) => this._onDrag(move);
|
||||
this._upHandler = () => this._endDrag();
|
||||
window.addEventListener("mousemove", this._moveHandler);
|
||||
window.addEventListener("mouseup", this._upHandler);
|
||||
window.addEventListener("touchmove", this._moveHandler, { passive: false });
|
||||
window.addEventListener("touchend", this._upHandler);
|
||||
}
|
||||
|
||||
_onDrag(event) {
|
||||
if (!this._drag) return;
|
||||
if (event.cancelable) event.preventDefault();
|
||||
const point = event.touches ? event.touches[0] : event;
|
||||
const g = this.geometry;
|
||||
g.left = Math.max(8, Math.min(point.clientX - this._drag.dx, window.innerWidth - g.width - 8));
|
||||
g.top = Math.max(8, Math.min(point.clientY - this._drag.dy, window.innerHeight - g.height - 8));
|
||||
this.win.style.left = `${g.left}px`;
|
||||
this.win.style.top = `${g.top}px`;
|
||||
}
|
||||
|
||||
_endDrag() {
|
||||
this._drag = null;
|
||||
this.classList.remove("fw-dragging");
|
||||
window.removeEventListener("mousemove", this._moveHandler);
|
||||
window.removeEventListener("mouseup", this._upHandler);
|
||||
window.removeEventListener("touchmove", this._moveHandler);
|
||||
window.removeEventListener("touchend", this._upHandler);
|
||||
this._persistGeometry();
|
||||
}
|
||||
|
||||
_startResize(event) {
|
||||
if (this.state !== "normal") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!this.geometry) this.geometry = this._defaultGeometry();
|
||||
const point = event.touches ? event.touches[0] : event;
|
||||
this._resize = { x: point.clientX, y: point.clientY, w: this.geometry.width, h: this.geometry.height };
|
||||
this.classList.add("fw-dragging");
|
||||
this._resizeMove = (move) => this._onResizeDrag(move);
|
||||
this._resizeUp = () => this._endResize();
|
||||
window.addEventListener("mousemove", this._resizeMove);
|
||||
window.addEventListener("mouseup", this._resizeUp);
|
||||
window.addEventListener("touchmove", this._resizeMove, { passive: false });
|
||||
window.addEventListener("touchend", this._resizeUp);
|
||||
}
|
||||
|
||||
_onResizeDrag(event) {
|
||||
if (!this._resize) return;
|
||||
if (event.cancelable) event.preventDefault();
|
||||
const point = event.touches ? event.touches[0] : event;
|
||||
const g = this.geometry;
|
||||
g.width = Math.max(320, Math.min(this._resize.w + (point.clientX - this._resize.x), window.innerWidth - g.left - 8));
|
||||
g.height = Math.max(220, Math.min(this._resize.h + (point.clientY - this._resize.y), window.innerHeight - g.top - 8));
|
||||
this.win.style.width = `${g.width}px`;
|
||||
this.win.style.height = `${g.height}px`;
|
||||
this._scheduleResize();
|
||||
}
|
||||
|
||||
_endResize() {
|
||||
this._resize = null;
|
||||
this.classList.remove("fw-dragging");
|
||||
window.removeEventListener("mousemove", this._resizeMove);
|
||||
window.removeEventListener("mouseup", this._resizeUp);
|
||||
window.removeEventListener("touchmove", this._resizeMove);
|
||||
window.removeEventListener("touchend", this._resizeUp);
|
||||
this._persistGeometry();
|
||||
}
|
||||
|
||||
_observeResize() {
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
this._ro = new ResizeObserver(() => {
|
||||
if (this.state === "normal" && this.geometry && !this._drag) {
|
||||
const rect = this.win.getBoundingClientRect();
|
||||
this.geometry.width = Math.round(rect.width);
|
||||
this.geometry.height = Math.round(rect.height);
|
||||
this._persistGeometry();
|
||||
}
|
||||
this._scheduleResize();
|
||||
});
|
||||
this._ro.observe(this.win);
|
||||
if (this.body) this._ro.observe(this.body);
|
||||
}
|
||||
|
||||
_scheduleResize() {
|
||||
if (this._rafResize) return;
|
||||
this._rafResize = window.requestAnimationFrame(() => {
|
||||
this._rafResize = null;
|
||||
this._onResize();
|
||||
});
|
||||
}
|
||||
|
||||
_loadGeometry() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`fw-geo:${this.windowKey}`);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_persistGeometry() {
|
||||
try {
|
||||
window.localStorage.setItem(`fw-geo:${this.windowKey}`, JSON.stringify(this.geometry));
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const BASE_Z = 2147480000;
|
||||
const CEILING_Z = 2147483400;
|
||||
|
||||
export default class WindowManager {
|
||||
constructor() {
|
||||
this._z = BASE_Z;
|
||||
this._windows = new Set();
|
||||
}
|
||||
|
||||
register(win) {
|
||||
this._windows.add(win);
|
||||
this.focus(win);
|
||||
}
|
||||
|
||||
unregister(win) {
|
||||
this._windows.delete(win);
|
||||
}
|
||||
|
||||
focus(win) {
|
||||
if (!win) return;
|
||||
if (this._z >= CEILING_Z) this._renormalize();
|
||||
this._z += 1;
|
||||
try {
|
||||
win.style.zIndex = String(this._z);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_renormalize() {
|
||||
const ordered = Array.from(this._windows).sort(
|
||||
(a, b) => (parseInt(a.style.zIndex, 10) || 0) - (parseInt(b.style.zIndex, 10) || 0),
|
||||
);
|
||||
this._z = BASE_Z;
|
||||
ordered.forEach((win) => {
|
||||
this._z += 1;
|
||||
try {
|
||||
win.style.zIndex = String(this._z);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,4 @@ import "./AppUpload.js";
|
||||
import "./AppToast.js";
|
||||
import "./AppDialog.js";
|
||||
import "./AppContextMenu.js";
|
||||
import "./ContainerTerminal.js";
|
||||
|
||||
@@ -28,6 +28,8 @@ export default class DeviiClient {
|
||||
return this._navigate(String(args.url || ""));
|
||||
case "reload_page":
|
||||
return this._reload();
|
||||
case "open_terminal":
|
||||
return this._openTerminal(args);
|
||||
default:
|
||||
return { error: `unknown client action '${action}'` };
|
||||
}
|
||||
@@ -52,9 +54,12 @@ export default class DeviiClient {
|
||||
loggedIn = true;
|
||||
}
|
||||
}
|
||||
const pageTypeEl = document.querySelector("[data-page-type]");
|
||||
const pageType = pageTypeEl ? pageTypeEl.getAttribute("data-page-type") : location.pathname;
|
||||
return {
|
||||
url: location.href,
|
||||
path: location.pathname + location.search,
|
||||
pageType,
|
||||
title: document.title,
|
||||
referrer: document.referrer || null,
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
@@ -203,4 +208,16 @@ export default class DeviiClient {
|
||||
window.setTimeout(() => window.location.reload(), NAV_DELAY_MS);
|
||||
return "reloading";
|
||||
}
|
||||
|
||||
_openTerminal(args) {
|
||||
const projectSlug = String(args.project_slug || "").trim();
|
||||
const instance = String(args.instance || "").trim();
|
||||
const label = String(args.label || instance || "").trim();
|
||||
if (!projectSlug || !instance) return { error: "project_slug and instance are required" };
|
||||
if (!globalThis.app || !globalThis.app.containerTerminals) {
|
||||
return { error: "terminal manager unavailable" };
|
||||
}
|
||||
globalThis.app.containerTerminals.open(projectSlug, instance, label);
|
||||
return { opened: true, instance };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const RECONNECT_DELAY_MS = 1500;
|
||||
const WRONG_WORKER_RETRY_MS = 200;
|
||||
const WRONG_WORKER_CODE = 4013;
|
||||
|
||||
export default class DeviiSocket {
|
||||
constructor(handlers) {
|
||||
@@ -8,6 +10,7 @@ export default class DeviiSocket {
|
||||
this.ws = null;
|
||||
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/devii/ws";
|
||||
this._shouldRun = true;
|
||||
this._settled = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
@@ -16,12 +19,15 @@ export default class DeviiSocket {
|
||||
}
|
||||
|
||||
_open() {
|
||||
this._settled = false;
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws.addEventListener("open", () => this._emit("onOpen"));
|
||||
this.ws.addEventListener("close", () => {
|
||||
this._emit("onClose");
|
||||
this.ws.addEventListener("close", (event) => {
|
||||
const transient = !this._settled && event.code === WRONG_WORKER_CODE;
|
||||
if (!transient) this._emit("onClose");
|
||||
if (this._shouldRun) {
|
||||
window.setTimeout(() => this._open(), RECONNECT_DELAY_MS);
|
||||
const delay = transient ? WRONG_WORKER_RETRY_MS : RECONNECT_DELAY_MS;
|
||||
window.setTimeout(() => this._open(), delay);
|
||||
}
|
||||
});
|
||||
this.ws.addEventListener("error", () => this._emit("onError"));
|
||||
@@ -32,6 +38,10 @@ export default class DeviiSocket {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!this._settled) {
|
||||
this._settled = true;
|
||||
this._emit("onReady");
|
||||
}
|
||||
this._emit("onMessage", payload);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
import Markdown from "./markdown.js";
|
||||
import DeviiSocket from "./DeviiSocket.js";
|
||||
import DeviiClient from "./DeviiClient.js";
|
||||
import { userScope } from "../userScope.js";
|
||||
|
||||
const CSS_ID = "devii-terminal-css";
|
||||
const STORAGE_KEY = "devii-terminal-state";
|
||||
const HISTORY_KEY = "devii-terminal-history";
|
||||
const FONT_KEY = "devii-fontsize";
|
||||
const FONT_MIN = 10;
|
||||
const FONT_MAX = 26;
|
||||
const FONT_DEFAULT = 14;
|
||||
const HISTORY_LIMIT = 500;
|
||||
const MOBILE_QUERY = "(max-width: 640px)";
|
||||
const TRACE_GLYPHS = {
|
||||
@@ -55,9 +60,16 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this._ensureCss();
|
||||
this._build();
|
||||
this._applyFont(this._readFont());
|
||||
if (globalThis.app && typeof globalThis.app.register === "function") {
|
||||
globalThis.app.register(this);
|
||||
}
|
||||
if (globalThis.app && globalThis.app.windows) {
|
||||
globalThis.app.windows.register(this);
|
||||
this.addEventListener("pointerdown", () => {
|
||||
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.focus(this);
|
||||
});
|
||||
}
|
||||
this._mobile.addEventListener("change", () => this._onViewportChange());
|
||||
window.addEventListener("resize", () => this._clampWindow());
|
||||
window.addEventListener("pagehide", () => this._captureFocusForUnload());
|
||||
@@ -77,7 +89,8 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
this._reportVisibility();
|
||||
});
|
||||
this.socket = new DeviiSocket({
|
||||
onOpen: () => {
|
||||
onOpen: () => this._reportVisibility(),
|
||||
onReady: () => {
|
||||
this._notice("connected.");
|
||||
this._reportVisibility();
|
||||
},
|
||||
@@ -220,6 +233,10 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
'<div class="devii-titlebar">',
|
||||
' <span class="devii-title"><span class="devii-dot"></span>Devii</span>',
|
||||
' <span class="devii-winctl">',
|
||||
' <button type="button" data-win="font-dec" title="Smaller font">−</button>',
|
||||
' <button type="button" data-win="font-inc" title="Larger font">+</button>',
|
||||
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">▫</button>',
|
||||
' <button type="button" data-win="normalize" title="Normalize (comfortable size)">▭</button>',
|
||||
' <button type="button" data-win="fullscreen" title="Fullscreen">⛶</button>',
|
||||
' <button type="button" data-win="maximize" title="Maximize">□</button>',
|
||||
' <button type="button" data-win="close" title="Close">✕</button>',
|
||||
@@ -261,6 +278,9 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
|
||||
this.titlebar.addEventListener("touchstart", (event) => this._startDrag(event), { passive: false });
|
||||
this.titlebar.addEventListener("dblclick", () => this._window("maximize"));
|
||||
if (globalThis.app && globalThis.app.contextMenu) {
|
||||
globalThis.app.contextMenu.attach(this.win, () => this._contextItems());
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
@@ -285,9 +305,75 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
this._setState(this.state === "maximized" ? "normal" : "maximized");
|
||||
} else if (action === "fullscreen") {
|
||||
this._setState(this.state === "fullscreen" ? "normal" : "fullscreen");
|
||||
} else if (action === "font-inc") {
|
||||
this._changeFont(1);
|
||||
} else if (action === "font-dec") {
|
||||
this._changeFont(-1);
|
||||
} else if (action === "minimize") {
|
||||
this.minimizeWindow();
|
||||
} else if (action === "normalize") {
|
||||
this.normalizeWindow();
|
||||
}
|
||||
}
|
||||
|
||||
minimizeWindow() {
|
||||
this._presetGeometry(400, 260);
|
||||
}
|
||||
|
||||
normalizeWindow() {
|
||||
this._presetGeometry(760, 600);
|
||||
}
|
||||
|
||||
_presetGeometry(width, height) {
|
||||
const w = Math.max(300, Math.min(width, window.innerWidth - 16));
|
||||
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
|
||||
this.geometry = {
|
||||
width: w,
|
||||
height: h,
|
||||
left: Math.max(8, Math.round((window.innerWidth - w) / 2)),
|
||||
top: Math.max(8, Math.round((window.innerHeight - h) / 2)),
|
||||
};
|
||||
this._clampGeometry();
|
||||
this._setState("normal");
|
||||
this._persist();
|
||||
}
|
||||
|
||||
_contextItems() {
|
||||
return [
|
||||
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
|
||||
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
|
||||
{ separator: true },
|
||||
{ label: "Close", danger: true, onSelect: () => this.close() },
|
||||
];
|
||||
}
|
||||
|
||||
_fontKey() {
|
||||
return `${FONT_KEY}:${userScope()}`;
|
||||
}
|
||||
|
||||
_readFont() {
|
||||
try {
|
||||
const value = parseInt(window.localStorage.getItem(this._fontKey()), 10);
|
||||
return Number.isFinite(value) ? Math.max(FONT_MIN, Math.min(FONT_MAX, value)) : FONT_DEFAULT;
|
||||
} catch (error) {
|
||||
return FONT_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
_applyFont(size) {
|
||||
this.style.setProperty("--devii-font-size", `${size}px`);
|
||||
}
|
||||
|
||||
_changeFont(delta) {
|
||||
const next = Math.max(FONT_MIN, Math.min(FONT_MAX, this._readFont() + delta));
|
||||
try {
|
||||
window.localStorage.setItem(this._fontKey(), String(next));
|
||||
} catch (error) {
|
||||
return this._applyFont(next);
|
||||
}
|
||||
this._applyFont(next);
|
||||
}
|
||||
|
||||
_setState(state) {
|
||||
let resolved = state;
|
||||
if (state === "open") {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export function userScope() {
|
||||
const docs = window.DEVPLACE_DOCS || {};
|
||||
let username = docs.username || "";
|
||||
if (!username) {
|
||||
const el = document.querySelector(".topnav-user-name");
|
||||
if (el) username = el.textContent.trim();
|
||||
}
|
||||
return username ? `user:${username}` : "guest";
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
+2
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@
|
||||
<td>
|
||||
{% if u['uid'] != user['uid'] %}
|
||||
<form method="POST" action="/admin/users/{{ u['uid'] }}/role" class="admin-inline-form">
|
||||
<select name="role" data-auto-submit class="admin-select">
|
||||
<select name="role" data-auto-submit data-confirm-danger data-confirm="Change this user's role?" class="admin-select">
|
||||
<option value="member" {% if u.get('role') == 'Member' %}selected{% endif %}>Member</option>
|
||||
<option value="admin" {% if u.get('role') == 'Admin' %}selected{% endif %}>Admin</option>
|
||||
</select>
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="admin-actions">
|
||||
{% if u['uid'] != user['uid'] %}
|
||||
<form method="POST" action="/admin/users/{{ u['uid'] }}/toggle" class="admin-inline-form">
|
||||
<button type="submit" class="admin-btn admin-btn-sm">
|
||||
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="{% if u.get('is_active', True) %}Disable this user's account?{% else %}Enable this user's account?{% endif %}">
|
||||
<span class="icon">{% if u.get('is_active', True) %}⚡{% else %}🔒{% endif %}</span> {% if u.get('is_active', True) %}Disable{% else %}Enable{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
@@ -61,7 +61,7 @@
|
||||
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">💾</span> Set</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/users/{{ u['uid'] }}/reset-ai-quota" class="admin-inline-form">
|
||||
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">🔄</span> Reset quota</button>
|
||||
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Reset this user's AI quota?"><span class="icon">🔄</span> Reset quota</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="admin-text-muted">You</span>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
<label><input type="checkbox" id="ai-usage-divided"> Show breakdowns</label>
|
||||
<span class="ai-usage-generated" data-meta="generated">-</span>
|
||||
<form method="POST" action="/admin/ai-quota/reset-guests" class="admin-inline-form">
|
||||
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">🔄</span> Reset guest quotas</button>
|
||||
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Reset guest AI quotas for all guests?"><span class="icon">🔄</span> Reset guest quotas</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/ai-quota/reset-all" class="admin-inline-form">
|
||||
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">🔄</span> Reset all quotas</button>
|
||||
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Reset AI quotas for every user and guest?"><span class="icon">🔄</span> Reset all quotas</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<link rel="stylesheet" href="/static/css/attachments.css">
|
||||
<link rel="stylesheet" href="/static/css/engagement.css">
|
||||
{% block extra_head %}{% endblock %}
|
||||
{{ custom_css_tag(request) }}
|
||||
|
||||
{% if page_schema %}
|
||||
<script type="application/ld+json">
|
||||
@@ -158,7 +159,7 @@
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<main class="page">
|
||||
<main class="page" data-page-type="{{ page_type_for(request) }}">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -182,5 +183,6 @@
|
||||
<script type="module" src="/static/vendor/emoji-picker-element/index.js"></script>
|
||||
<script type="module" src="/static/js/Application.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
{{ custom_js_tag(request) }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,66 +14,25 @@
|
||||
</div>
|
||||
|
||||
<div class="cm-body">
|
||||
<section class="card cm-col cm-dockerfiles">
|
||||
<div class="cm-col-head">
|
||||
<h2>Dockerfiles</h2>
|
||||
<button type="button" class="btn btn-primary" data-modal="cm-new-dockerfile-modal">New</button>
|
||||
</div>
|
||||
<ul class="cm-list" id="cm-dockerfile-list"></ul>
|
||||
<div class="cm-detail" id="cm-dockerfile-detail" hidden>
|
||||
<div class="cm-detail-head">
|
||||
<span class="cm-detail-name" id="cm-df-name"></span>
|
||||
<button type="button" class="btn btn-secondary" id="cm-save-version">Save & build</button>
|
||||
<button type="button" class="btn btn-secondary" id="cm-rebuild">Rebuild</button>
|
||||
</div>
|
||||
<textarea class="cm-editor" id="cm-df-editor" spellcheck="false"></textarea>
|
||||
<div class="cm-builds">
|
||||
<h3>Builds</h3>
|
||||
<ul class="cm-list" id="cm-build-list"></ul>
|
||||
</div>
|
||||
<pre class="cm-log" id="cm-build-log" hidden></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card cm-col cm-instances">
|
||||
<div class="cm-col-head">
|
||||
<h2>Instances</h2>
|
||||
<button type="button" class="btn btn-primary" data-modal="cm-new-instance-modal">New</button>
|
||||
</div>
|
||||
<p class="cm-hint">Every instance runs the shared <code>ppy</code> image with this project's files mounted at <code>/app</code>.</p>
|
||||
<ul class="cm-list" id="cm-instance-list"></ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="application/json" id="cm-data">{{ {"dockerfiles": dockerfiles, "instances": instances} | tojson }}</script>
|
||||
<script type="application/json" id="cm-data">{{ {"instances": instances} | tojson }}</script>
|
||||
</div>
|
||||
|
||||
{% call modal('cm-new-dockerfile-modal', 'New Dockerfile') %}
|
||||
<form id="cm-new-dockerfile-form" class="cm-modal-form">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="cm-df-new-name">Name</label>
|
||||
<input type="text" id="cm-df-new-name" name="name" required maxlength="64" placeholder="lowercase-name">
|
||||
</div>
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="cm-df-new-desc">Description</label>
|
||||
<input type="text" id="cm-df-new-desc" name="description" maxlength="200" placeholder="Optional">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create & build</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
{% call modal('cm-new-instance-modal', 'New instance') %}
|
||||
<form id="cm-new-instance-form" class="cm-modal-form">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="cm-inst-new-name">Name</label>
|
||||
<input type="text" id="cm-inst-new-name" name="name" required maxlength="64" placeholder="my-service">
|
||||
</div>
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="cm-inst-new-df">Dockerfile</label>
|
||||
<select id="cm-inst-new-df" name="dockerfile_uid"></select>
|
||||
</div>
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="cm-inst-new-boot">Boot command</label>
|
||||
<input type="text" id="cm-inst-new-boot" name="boot_command" placeholder="Optional, e.g. python app.py">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/containers.css">
|
||||
@@ -8,9 +7,6 @@
|
||||
<div class="admin-toolbar">
|
||||
<h2>Containers</h2>
|
||||
<span class="admin-count">{{ instances|length }} instances</span>
|
||||
{% if projects %}
|
||||
<button type="button" class="btn btn-primary admin-toolbar-action" data-modal="new-container-modal">New container</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
@@ -44,31 +40,11 @@
|
||||
<td><a class="admin-btn admin-btn-sm" href="/admin/containers/{{ inst['uid'] }}">Manage →</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="admin-empty">No container instances yet. Open a project's container manager to build an image and launch one.</td></tr>
|
||||
<tr><td colspan="6" class="admin-empty">No container instances yet. Open a project's container manager to launch one.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if projects %}
|
||||
{% call modal('new-container-modal', 'New container') %}
|
||||
<form id="cm-new-container-form" class="cm-modal-form">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="cm-project-select">Project</label>
|
||||
<select id="cm-project-select" name="project">
|
||||
{% for p in projects %}
|
||||
<option value="{{ p['slug'] }}">{{ p['title'] or p['slug'] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="hint-text">Container images are built and launched from the project's container manager.</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Open manager</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<div class="ci-page" id="container-instance"
|
||||
data-slug="{{ project_slug }}"
|
||||
data-uid="{{ instance['uid'] }}"
|
||||
data-name="{{ instance['name'] }}"
|
||||
data-status="{{ instance['status'] }}">
|
||||
<div class="ci-header">
|
||||
<a href="/admin/containers" class="back-link">← Containers</a>
|
||||
@@ -28,6 +29,7 @@
|
||||
<div class="ci-kv"><span>Restart policy</span><code>{{ instance['restart_policy'] or 'never' }}</code></div>
|
||||
<div class="ci-kv"><span>Restarts</span><code>{{ runtime['restart_count'] }}</code></div>
|
||||
<div class="ci-kv"><span>Container</span><code id="ci-container-id">{{ runtime['container_id'] or '-' }}</code></div>
|
||||
<div class="ci-kv"><span>Image</span><code>ppy:latest</code></div>
|
||||
</div>
|
||||
{% if instance['ingress_slug'] %}
|
||||
<div class="ci-ingress" id="ci-ingress">
|
||||
@@ -73,9 +75,11 @@
|
||||
<div class="ci-exec">
|
||||
<input type="text" id="ci-exec-input" placeholder="one-shot command, e.g. ls -la /app">
|
||||
<button type="button" class="btn btn-secondary" id="ci-exec-run">Run</button>
|
||||
<button type="button" class="btn btn-secondary" id="ci-term-toggle">Terminal</button>
|
||||
<button type="button" class="btn btn-secondary" id="ci-term-toggle">Open terminal</button>
|
||||
</div>
|
||||
<div class="ci-terminal" id="ci-terminal" hidden>
|
||||
<pre class="ci-log ci-term-output" id="ci-term"></pre>
|
||||
</div>
|
||||
<pre class="ci-log" id="ci-term" hidden></pre>
|
||||
</section>
|
||||
|
||||
<section class="card ci-card">
|
||||
|
||||
@@ -43,7 +43,6 @@ Run these before considering a change done:
|
||||
- **Per-language validation:** check the syntax and structure of each touched language (Python compiles and imports, JavaScript parses, CSS braces and HTML tags balance).
|
||||
- **Clean import:** `python -c "from devplacepy.main import app"` must import without error.
|
||||
- **No em-dash:** grep the touched files for the long dash and its HTML entity; both are forbidden.
|
||||
- **UI check:** if the UI changed, capture and review a screenshot of the affected page.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Container Manager
|
||||
|
||||
The Container Manager builds versioned Docker images for projects and runs supervised container
|
||||
instances with full lifecycle control, from the web UI, the HTTP API, and Devii. It is built on the
|
||||
generic service framework: a [JobService](/docs/architecture-jobs.html) drives async builds and a
|
||||
reconciling `BaseService` supervises instances.
|
||||
The Container Manager runs supervised container instances with full lifecycle control, from the web UI,
|
||||
the HTTP API, and Devii. Every instance runs ONE shared, prebuilt image; there is no per-project image
|
||||
building inside the app. A reconciling `BaseService` supervises the instances.
|
||||
|
||||
> Audience: administrators. All build, run, exec, lifecycle, and schedule operations require an
|
||||
> administrator, because running arbitrary Dockerfiles and bind mounts with access to the docker
|
||||
> socket is root-equivalent on the host.
|
||||
> Audience: administrators. All run, exec, lifecycle, and schedule operations require an administrator,
|
||||
> because running containers with bind mounts and access to the docker socket is root-equivalent on the
|
||||
> host.
|
||||
|
||||
## Pieces
|
||||
|
||||
@@ -16,59 +15,57 @@ reconciling `BaseService` supervises instances.
|
||||
that drives the `docker` CLI via `asyncio.create_subprocess_exec`, streaming stdout line by line. A
|
||||
`FakeBackend` implements the same interface for tests with no daemon. The active backend is resolved
|
||||
by `runtime.get_backend()` and is the pluggable seam for a future Kubernetes or remote backend.
|
||||
- **Data model** (all in `database.py` indexes): `dockerfiles` (mutable head), `dockerfile_versions`
|
||||
(immutable, append-only), `builds` (permanent audit), `instances`, `instance_events` (audit),
|
||||
`instance_metrics` (ring-buffered), `instance_schedules`. Build and version numbers are minted under
|
||||
a process lock with a compare-and-set on the head row.
|
||||
- **ContainerBuildService** (`build_service.py`): a `JobService` subclass (`kind="container_build"`).
|
||||
It materializes the project's files as the build context, runs `docker build` streaming logs into the
|
||||
permanent `builds` row, and tags `<name>:<build-number>` plus optional `:latest`.
|
||||
- **Data model** (indexed in `database.py`): `instances`, `instance_events` (audit), `instance_metrics`
|
||||
(ring-buffered), `instance_schedules`. There are no dockerfile or build tables.
|
||||
- **ContainerService** (`service.py`): a reconciler. Each tick it snapshots `docker ps` (filtered by
|
||||
the `devplace.instance` label), converges every instance to its `desired_state`, applies restart
|
||||
policies, reaps orphan containers whose DB row is gone, fires due schedules, and samples metrics. The
|
||||
`devplace.instance=<uid>` label is the join key that guarantees no orphans and no lost state. Only the
|
||||
service lock owner reconciles; HTTP handlers only edit `desired_state`.
|
||||
|
||||
## Dockerfiles and builds
|
||||
## The shared `ppy` image
|
||||
|
||||
Each Dockerfile belongs to a project and has a unique name, a monotonic version, and a build counter.
|
||||
Saving new content creates a new immutable version and automatically queues a build, incrementing the
|
||||
tag (`myapp:12` to `myapp:13`). A high-quality default Dockerfile (Debian python-slim with numpy,
|
||||
pandas, fastapi, sqlalchemy, redis, Playwright + Chromium, git/curl, a non-root user and `/app`
|
||||
workdir) is used when none is supplied. Build status moves through queued, building, success, failed,
|
||||
or cancelled, with full logs stored and tailed live.
|
||||
Every instance runs `ppy:latest` (override with the `DEVPLACE_CONTAINER_IMAGE` env var), built **once**
|
||||
with `make ppy` from `ppy.Dockerfile`. The image is a Python + Playwright base with a broad set of
|
||||
common Python libraries preinstalled, and it bakes in the security model: the `pravda` user at uid/gid
|
||||
`1000:1000`, a `sudo` superclone that never escalates (it runs commands as `pravda`, so a container can
|
||||
never write a root-owned file into the bind-mounted workspace), and the stdlib `pagent` agent at
|
||||
`/usr/bin/pagent.py`. Creating an instance is then an instant `docker run` (no build wait); it fails
|
||||
fast with a clear error if the `ppy` image has not been built yet.
|
||||
|
||||
Projects that need a library not in the image install it at runtime with `pip install` (pravda owns the
|
||||
site-packages, so no `sudo` is needed), or add it to `ppy.Dockerfile` and rerun `make ppy`.
|
||||
|
||||
## Instances and lifecycle
|
||||
|
||||
An instance is created from a Dockerfile's successful build with a name, optional boot command, env
|
||||
vars, CPU/memory limits, port maps, and restart policy. The project's files are materialized to a
|
||||
persistent host workspace and bind-mounted read-write as `/app`; the sync action imports container-side
|
||||
changes back into the project filesystem. Lifecycle operations (start, stop, restart, pause, resume,
|
||||
delete) flip the instance's `desired_state` and the reconciler executes them. Schedules use the shared
|
||||
cron/interval/one-time scheduler to start or stop instances. One-shot exec runs over HTTP; an
|
||||
interactive shell runs over a PTY-backed WebSocket on the lock owner.
|
||||
An instance is created with a name, optional boot command, env vars, CPU/memory limits, port maps, and
|
||||
restart policy. The project's files are materialized to a persistent host workspace and bind-mounted
|
||||
read-write as `/app`; the sync action imports container-side changes back into the project filesystem.
|
||||
Lifecycle operations (start, stop, restart, pause, resume, delete) flip the instance's `desired_state`
|
||||
and the reconciler executes them. Schedules use the shared cron/interval/one-time scheduler to start or
|
||||
stop instances. One-shot exec runs over HTTP; an interactive shell runs over a PTY-backed WebSocket on
|
||||
the lock owner.
|
||||
|
||||
Each launch also injects per-instance `PRAVDA_*` env vars (`PRAVDA_BASE_URL`, `PRAVDA_OPENAI_URL`,
|
||||
`PRAVDA_API_KEY`, `PRAVDA_USER_UID`, `PRAVDA_CONTAINER_NAME`, `PRAVDA_CONTAINER_UID`) so code inside the
|
||||
container - and `pagent` - can call back into the platform and the AI gateway authenticated as the user.
|
||||
|
||||
## Runtime data and operations
|
||||
|
||||
All generated data lives OUTSIDE the `devplacepy/` package and is never served via `/static`:
|
||||
persistent workspaces under `config.DATA_DIR/container_workspaces` (default `var/`, configurable with
|
||||
the `DEVPLACE_DATA_DIR` env var - point it at a volume in production), and ephemeral build contexts in
|
||||
the OS temp dir. The docker daemon must be able to bind-mount `DATA_DIR` for the `/app` mount. In
|
||||
development `make dev` runs `uvicorn --reload --reload-dir devplacepy` so that materializing build
|
||||
contexts and workspaces does not trigger a reload that would kill a build mid-flight. Both services are
|
||||
disabled by default (they need the docker socket); an administrator enables them on `/admin/services`.
|
||||
|
||||
Builds run with `docker build --network=host` by default (the `container_build_network` field on the
|
||||
build service) so pip can reach PyPI; set it empty to use the docker default.
|
||||
the `DEVPLACE_DATA_DIR` env var - point it at a volume in production). The docker daemon must be able to
|
||||
bind-mount `DATA_DIR` for the `/app` mount. The service is disabled by default (it needs the docker
|
||||
socket); an administrator enables **Containers** on `/admin/services`, and the `ppy` image must be built
|
||||
once with `make ppy`.
|
||||
|
||||
## Publishing a service (ingress)
|
||||
|
||||
A running instance can be exposed by setting an `ingress_slug` plus an `ingress_port` (one of its
|
||||
mapped container ports). The `/p/<slug>` route (`routers/proxy.py`) then reverse-proxies both HTTP and
|
||||
WebSocket traffic to that container's published host port, stripping the `/p/<slug>` prefix. It is
|
||||
public (the unguessable nature is up to your slug) but SSRF-safe: the target host and port are derived
|
||||
from the instance row, never from the request. So a container WebSocket server on port 8899 becomes
|
||||
reachable at `wss://<your-host>/p/<slug>/ws`.
|
||||
public but SSRF-safe: the target host and port are derived from the instance row, never from the
|
||||
request. So a container WebSocket server on port 8899 becomes reachable at `wss://<your-host>/p/<slug>/ws`.
|
||||
|
||||
Set the **Public URL** in admin settings (`/admin/settings`) to your production origin; the container
|
||||
tools then return an absolute `ingress_url` (for example `https://your-host/p/<slug>`) so Devii and API
|
||||
@@ -78,21 +75,23 @@ clients use the real public address rather than localhost.
|
||||
|
||||
The manager drives the host docker daemon, so production needs the opt-in overlay
|
||||
`docker-compose.containers.yml`. The `make docker-build` and `make docker-up` targets apply it
|
||||
automatically (`make docker-build && make docker-up`) and self-derive its inputs, so it works with no
|
||||
`sudo`, no `/srv` dir, and no manual `.env` editing; a plain `docker compose up -d` drops the overlay
|
||||
and re-breaks it. The overlay mounts the docker socket (root on the host - trusted admins only),
|
||||
installs the docker CLI via the `INSTALL_DOCKER_CLI` build arg, and adds the host docker group (gid read
|
||||
from the socket via `stat -c '%g' /var/run/docker.sock`). Two docker-in-docker details matter: the data
|
||||
dir must be bind-mounted at the **same absolute path** on host and in the app container (make sets
|
||||
`DEVPLACE_DATA_DIR` to the project's own `./var`) because `docker run -v` resolves the source against the
|
||||
host; and the ingress proxy reaches published ports on the host via
|
||||
automatically and self-derive its inputs, so it works with no `sudo`, no `/srv` dir, and no manual
|
||||
`.env` editing; a plain `docker compose up -d` drops the overlay and re-breaks it. The overlay mounts
|
||||
the docker socket (root on the host - trusted admins only), installs the docker CLI via the
|
||||
`INSTALL_DOCKER_CLI` build arg, and adds the host docker group (gid read from the socket via
|
||||
`stat -c '%g' /var/run/docker.sock`). Two docker-in-docker details matter: the data dir must be
|
||||
bind-mounted at the **same absolute path** on host and in the app container (make sets
|
||||
`DEVPLACE_DATA_DIR` to the project's own `./var`) because `docker run -v` resolves the source against
|
||||
the host; and the ingress proxy reaches published ports on the host via
|
||||
`DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (a bare-metal `make prod` deploy uses the default
|
||||
`127.0.0.1`). Full steps are in the README "Container Manager wiring".
|
||||
`127.0.0.1`). Remember to run `make ppy` on the production host too. Full steps are in the README
|
||||
"Container Manager wiring".
|
||||
|
||||
## Observability, CLI, and Devii
|
||||
|
||||
The service tile on `/admin/services` shows instance counts and build success rate; per-instance and
|
||||
per-Dockerfile aggregates (CPU/memory, p95 runtime, build times) are computed on read over the ring
|
||||
buffer. The CLI exposes `devplace containers list | reconcile | prune | gc-workspaces`. Devii's admin
|
||||
`container_*` tools cover the same operations in natural language.
|
||||
The service tile on `/admin/services` shows instance counts; per-instance aggregates (CPU/memory, p95
|
||||
runtime) are computed on read over the ring buffer. The CLI exposes
|
||||
`devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a
|
||||
one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables). Devii's
|
||||
admin `container_*` tools cover the same instance operations in natural language.
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if forked_from %}
|
||||
<div class="project-detail-meta">
|
||||
<span>⑂ Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ forked_from['title'] }}</a></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="project-detail-author">
|
||||
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||
<div>
|
||||
@@ -65,6 +71,9 @@
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}/containers" class="project-star-btn">🖥️ Containers</a>
|
||||
{% endif %}
|
||||
<button type="button" class="project-star-btn" data-zip-download="/projects/{{ project['slug'] or project['uid'] }}/zip">📦 Download zip</button>
|
||||
{% if user %}
|
||||
<button type="button" class="project-star-btn" data-fork-project="/projects/{{ project['slug'] or project['uid'] }}/fork" data-fork-name="{{ project['title'] }}">⑂ Fork</button>
|
||||
{% endif %}
|
||||
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}">🔗 Share</button>
|
||||
{% if user %}
|
||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
|
||||
@@ -73,11 +82,11 @@
|
||||
{% if is_owner %}
|
||||
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/private" style="display:inline;">
|
||||
<input type="hidden" name="value" value="{{ 0 if is_private else 1 }}">
|
||||
<button type="submit" class="project-star-btn">{% if is_private %}🔓 Make public{% else %}🔒 Make private{% endif %}</button>
|
||||
<button type="submit" class="project-star-btn" data-confirm-danger data-confirm="{% if is_private %}Make this project public? Everyone will be able to see the project and all its files.{% else %}Make this project private? Only you and administrators will be able to see it.{% endif %}">{% if is_private %}🔓 Make public{% else %}🔒 Make private{% endif %}</button>
|
||||
</form>
|
||||
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/readonly" style="display:inline;">
|
||||
<input type="hidden" name="value" value="{{ 0 if read_only else 1 }}">
|
||||
<button type="submit" class="project-star-btn" data-confirm="{% if read_only %}Allow file changes again for this project?{% else %}Make this project read-only? Files become immutable until you turn this off.{% endif %}">{% if read_only %}📝 Allow edits{% else %}🚫 Make read-only{% endif %}</button>
|
||||
<button type="submit" class="project-star-btn" data-confirm-danger data-confirm="{% if read_only %}Allow file changes again for this project?{% else %}Make this project read-only? Files become immutable until you turn this off.{% endif %}">{% if read_only %}📝 Allow edits{% else %}🚫 Make read-only{% endif %}</button>
|
||||
</form>
|
||||
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}" style="display:inline;">
|
||||
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">🗑️</span> Delete</button>
|
||||
|
||||
@@ -109,3 +109,8 @@ templates.env.globals["language_name"] = jinja_language_name
|
||||
from devplacepy.attachments import format_file_size, file_icon_emoji
|
||||
templates.env.globals["format_file_size"] = format_file_size
|
||||
templates.env.globals["file_icon_emoji"] = file_icon_emoji
|
||||
|
||||
from devplacepy.customization import custom_css_tag, custom_js_tag, page_type_for
|
||||
templates.env.globals["custom_css_tag"] = custom_css_tag
|
||||
templates.env.globals["custom_js_tag"] = custom_js_tag
|
||||
templates.env.globals["page_type_for"] = page_type_for
|
||||
|
||||
Reference in New Issue
Block a user