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:
2026-06-09 14:06:02 +00:00
parent c596a5db6e
commit a58702968a
90 changed files with 6889 additions and 1146 deletions
+60 -2
View File
@@ -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()
+2 -3
View File
@@ -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")
+2
View File
@@ -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"
+2
View File
@@ -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"])
+77
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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:
+52 -113
View File
@@ -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
-12
View File
@@ -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",
})
+3 -2
View File
@@ -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():
+39
View File
@@ -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"))
+28 -2
View File
@@ -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)
+45 -5
View File
@@ -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
View File
@@ -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):
+34 -69
View File
@@ -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)}
+245
View File
@@ -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
File diff suppressed because it is too large Load Diff
+66
View File
@@ -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 -7
View File
@@ -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 -117
View File
@@ -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"]
"""
+31 -1
View File
@@ -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(
+47 -14
View File
@@ -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),
),
),
)
+60 -23
View File
@@ -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,
)
+3 -1
View File
@@ -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)
+4
View File
@@ -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
)
+14
View File
@@ -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)]
+91
View File
@@ -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)
+12
View File
@@ -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;
+2 -2
View File
@@ -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);
+124 -21
View File
@@ -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; }
}
+8
View File
@@ -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;
+1 -1
View File
@@ -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;
}
+182
View File
@@ -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%;
}
+7
View File
@@ -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();
+257
View File
@@ -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("");
}
}
+53
View File
@@ -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 &rarr;</a></td>
</tr>`;
}
}
-130
View File
@@ -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;
}
}
}
+23 -1
View File
@@ -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;
});
});
});
}
+74
View File
@@ -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);
});
}
}
+1 -1
View File
@@ -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">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</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)">&#9643;</button>',
' <button type="button" data-win="normalize" title="Normalize (comfortable size)">&#9645;</button>',
' <button type="button" data-win="fullscreen" title="Fullscreen">&#9974;</button>',
' <button type="button" data-win="maximize" title="Maximize">&#9633;</button>',
' <button type="button" data-win="close" title="Close">&#10005;</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;
}
});
}
}
+1
View File
@@ -7,3 +7,4 @@ import "./AppUpload.js";
import "./AppToast.js";
import "./AppDialog.js";
import "./AppContextMenu.js";
import "./ContainerTerminal.js";
+17
View File
@@ -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 };
}
}
+13 -3
View File
@@ -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);
});
}
+87 -1
View File
@@ -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">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</button>',
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">&#9643;</button>',
' <button type="button" data-win="normalize" title="Normalize (comfortable size)">&#9645;</button>',
' <button type="button" data-win="fullscreen" title="Fullscreen">&#9974;</button>',
' <button type="button" data-win="maximize" title="Maximize">&#9633;</button>',
' <button type="button" data-win="close" title="Close">&#10005;</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") {
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -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) %}&#x26A1;{% else %}&#x1F512;{% 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">&#x1F4BE;</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">&#x1F504;</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">&#x1F504;</span> Reset quota</button>
</form>
{% else %}
<span class="admin-text-muted">You</span>
+2 -2
View File
@@ -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">&#x1F504;</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">&#x1F504;</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">&#x1F504;</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">&#x1F504;</span> Reset all quotas</button>
</form>
</div>
</div>
+3 -1
View File
@@ -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>
+2 -43
View File
@@ -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 &amp; 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 &amp; 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 -25
View File
@@ -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 &rarr;</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">&larr; 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>
+11 -2
View File
@@ -32,6 +32,12 @@
{% endif %}
</div>
{% if forked_from %}
<div class="project-detail-meta">
<span>&#x2442; 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">&#x1F5A5;&#xFE0F; Containers</a>
{% endif %}
<button type="button" class="project-star-btn" data-zip-download="/projects/{{ project['slug'] or project['uid'] }}/zip">&#x1F4E6; 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'] }}">&#x2442; Fork</button>
{% endif %}
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}">&#x1F517; 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 %}&#x1F513; Make public{% else %}&#x1F512; 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 %}&#x1F513; Make public{% else %}&#x1F512; 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 %}&#x1F4DD; Allow edits{% else %}&#x1F6AB; 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 %}&#x1F4DD; Allow edits{% else %}&#x1F6AB; 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">&#x1F5D1;&#xFE0F;</span> Delete</button>
+5
View File
@@ -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