feat: add TTLCache for get_cache_version and TEMPLATE_AUTO_RELOAD config with Makefile worker count variables

This commit is contained in:
2026-06-14 14:46:36 +00:00
parent c151325916
commit d75d5dc6a8
149 changed files with 9811 additions and 262 deletions
+5
View File
@@ -58,6 +58,11 @@ XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550"))
# DEVPLACE_STATIC_VERSION at launch so multiple prod workers share one value.
STATIC_VERSION = environ.get("DEVPLACE_STATIC_VERSION") or str(int(time.time()))
# Jinja template auto-reload stat-checks every template in the inheritance chain on
# every render. Keep it on for development hot-reload; turn it off in production
# (DEVPLACE_TEMPLATE_AUTO_RELOAD=0) so compiled templates stay cached in memory.
TEMPLATE_AUTO_RELOAD = environ.get("DEVPLACE_TEMPLATE_AUTO_RELOAD", "1") != "0"
INTERNAL_BASE_URL = environ.get(
"DEVPLACE_INTERNAL_BASE_URL", f"http://localhost:{PORT}"
).rstrip("/")
+38 -1
View File
@@ -43,6 +43,7 @@ def refresh_snapshot() -> None:
_local_cache_versions: dict = {}
_cache_version_cache = TTLCache(ttl=1)
_cache_state_ready = False
@@ -58,6 +59,9 @@ def _ensure_cache_state() -> None:
def get_cache_version(name: str) -> int:
cached = _cache_version_cache.get(name)
if cached is not None:
return cached
try:
_ensure_cache_state()
row = next(
@@ -68,10 +72,12 @@ def get_cache_version(name: str) -> int:
),
None,
)
return int(row["version"]) if row else 0
version = int(row["version"]) if row else 0
except Exception as e:
logger.warning(f"Could not read cache version {name}: {e}")
return 0
_cache_version_cache.set(name, version)
return version
def bump_cache_version(name: str) -> None:
@@ -87,6 +93,7 @@ def bump_cache_version(name: str) -> None:
connection = db.executable
if connection.in_transaction():
connection.commit()
_cache_version_cache.pop(name)
except Exception as e:
logger.warning(f"Could not bump cache version {name}: {e}")
@@ -442,6 +449,17 @@ def init_db():
_index(db, "jobs", "idx_jobs_expires", ["expires_at"])
_index(db, "project_forks", "idx_project_forks_source", ["source_project_uid"])
_index(db, "project_forks", "idx_project_forks_forked", ["forked_project_uid"])
if "instances" in db.tables:
instances = get_table("instances")
for column, example in (
("run_as_uid", ""),
("boot_language", "none"),
("boot_script", ""),
("start_on_boot", 0),
):
if not instances.has_column(column):
instances.create_column_by_example(column, example)
_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"])
@@ -930,6 +948,25 @@ def get_users_by_uids(uids):
return {u["uid"]: u for u in users.find(users.table.columns.uid.in_(unique))}
def search_users_by_username(q, *, exclude_uid=None, limit=10):
if not q or "users" not in db.tables:
return []
if exclude_uid is not None:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q AND uid != :me LIMIT :limit",
q=f"%{q}%",
me=exclude_uid,
limit=limit,
)
else:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT :limit",
q=f"%{q}%",
limit=limit,
)
return [{"uid": r["uid"], "username": r["username"]} for r in rows]
def get_comment_counts_by_post_uids(post_uids):
if not post_uids or "comments" not in db.tables:
return {}
+194 -2
View File
@@ -94,7 +94,12 @@ def endpoint(
}
NON_BODY_ENDPOINTS = {"avatar", "gateway-passthrough", "notifications-open"}
NON_BODY_ENDPOINTS = {
"avatar",
"gateway-passthrough",
"notifications-open",
"admin-bots-frame",
}
def _classify(ep):
@@ -2860,6 +2865,20 @@ Mutations flip desired state; a single reconciler converges containers to it.
)
],
),
endpoint(
id="containers-admin-edit-page",
method="GET",
path="/admin/containers/{uid}/edit",
title="Edit instance page",
summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
auth="admin",
interactive=True,
params=[
field(
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
)
],
),
endpoint(
id="containers-create-instance",
method="POST",
@@ -2887,6 +2906,9 @@ Mutations flip desired state; a single reconciler converges containers to it.
"python app.py",
"Optional boot command.",
),
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field(
"env",
"form",
@@ -2916,6 +2938,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
"Restart policy.",
["never", "always", "on-failure", "unless-stopped"],
),
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
field(
"ingress_slug",
"form",
@@ -3012,7 +3035,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
method="POST",
path="/projects/{project_slug}/containers/instances/{uid}/sync",
title="Sync workspace",
summary="Import the container /app workspace back into the project files.",
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
auth="admin",
destructive=True,
params=[
@@ -3028,6 +3051,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
),
],
sample_response={"exported": 3, "imported": 1},
),
endpoint(
id="containers-instance-delete",
@@ -3230,6 +3254,115 @@ Mutations flip desired state; a single reconciler converges containers to it.
),
],
),
endpoint(
id="containers-admin-create",
method="POST",
path="/admin/containers/create",
title="Admin create instance",
summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
field("name", "form", "string", True, "staging", "Instance name."),
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
],
),
endpoint(
id="containers-admin-edit",
method="POST",
path="/admin/containers/{uid}/edit",
title="Admin edit instance",
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
],
),
endpoint(
id="containers-admin-action",
method="POST",
path="/admin/containers/{uid}/{action}",
title="Admin instance lifecycle",
summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
],
),
endpoint(
id="containers-admin-sync",
method="POST",
path="/admin/containers/{uid}/sync",
title="Admin bidirectional sync",
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
sample_response={"exported": 3, "imported": 1},
),
endpoint(
id="containers-admin-delete",
method="POST",
path="/admin/containers/{uid}/delete",
title="Admin delete instance",
summary="Soft-delete an instance and mark its container for removal.",
auth="admin",
destructive=True,
params=[
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
],
),
endpoint(
id="containers-admin-project-search",
method="GET",
path="/admin/containers/projects/search",
title="Admin project search",
summary="Search projects by title for the admin create form (returns uid, slug, title).",
auth="admin",
params=[
field("q", "query", "string", False, "api", "Title fragment."),
],
sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
),
endpoint(
id="containers-admin-user-search",
method="GET",
path="/admin/containers/users/search",
title="Admin run-as user search",
summary="Search users by username for the run-as-user select (returns uid, username).",
auth="admin",
params=[
field("q", "query", "string", False, "alice", "Username fragment."),
],
sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
),
],
},
{
@@ -4292,6 +4425,63 @@ four ways to sign requests.
auth="admin",
destructive=True,
),
endpoint(
id="admin-bots-monitor",
method="GET",
path="/admin/bots",
title="Bot monitor page",
summary="Live low-quality screenshot grid of every running bot persona, one frame per bot.",
auth="admin",
interactive=True,
),
endpoint(
id="admin-bots-data",
method="GET",
path="/admin/bots/data",
title="Bot monitor data",
summary="JSON of every running bot slot with its label, persona, current action/status, and latest frame URL for polling.",
auth="admin",
sample_response={
"enabled": True,
"service_status": "running",
"frames": [
{
"slot": 0,
"username": "bytewren",
"persona": "grumpy_senior",
"action": "POST: rant [3]",
"status": "page load",
"url": "https://example.com/feed",
"label": "bytewren",
"captured_at": 1718366400,
"age_seconds": 2,
"has_image": True,
"active": True,
"frame_url": "/admin/bots/0/frame.jpg?t=1718366400",
}
],
},
),
endpoint(
id="admin-bots-frame",
method="GET",
path="/admin/bots/{slot}/frame.jpg",
title="Bot frame image",
summary="The latest low-quality JPEG screenshot for one bot slot (no-store).",
auth="admin",
encoding="none",
interactive=False,
params=[
field(
"slot",
"path",
"integer",
True,
"0",
"Bot fleet slot index.",
)
],
),
],
},
]
@@ -4322,6 +4512,8 @@ _PAGE_RESPONSES = {
"admin-settings-get": schemas.AdminSettingsOut,
"admin-audit-log": schemas.AuditLogOut,
"admin-audit-event": schemas.AuditEventOut,
"admin-bots-monitor": schemas.AdminBotsOut,
"containers-admin-edit-page": schemas.AdminContainerEditOut,
}
_ACTION_RESPONSES = {
+26
View File
@@ -278,6 +278,10 @@ class ProjectFileAppendForm(BaseModel):
class ContainerInstanceForm(BaseModel):
name: str = Field(min_length=1, max_length=64)
boot_command: str = Field(default="", max_length=500)
boot_language: str = Field(default="none", max_length=10)
boot_script: str = Field(default="", max_length=100000)
run_as_uid: str = Field(default="", max_length=36)
start_on_boot: bool = False
env: str = Field(default="", max_length=10000)
cpu_limit: str = Field(default="", max_length=16)
mem_limit: str = Field(default="", max_length=16)
@@ -288,6 +292,28 @@ class ContainerInstanceForm(BaseModel):
ingress_slug: str = Field(default="", max_length=64)
ingress_port: Optional[int] = Field(default=None, ge=1, le=65535)
@field_validator("ingress_port", mode="before")
@classmethod
def _blank_ingress_port(cls, value):
if value is None or (isinstance(value, str) and not value.strip()):
return None
return value
class ContainerAdminCreateForm(ContainerInstanceForm):
project_slug: str = Field(min_length=1, max_length=128)
class ContainerEditForm(BaseModel):
run_as_uid: str = Field(default="", max_length=36)
boot_language: str = Field(default="none", max_length=10)
boot_script: str = Field(default="", max_length=100000)
boot_command: str = Field(default="", max_length=500)
restart_policy: str = Field(default="never", max_length=20)
start_on_boot: bool = False
cpu_limit: str = Field(default="", max_length=16)
mem_limit: str = Field(default="", max_length=16)
class ContainerExecForm(BaseModel):
command: str = Field(min_length=1, max_length=2000)
+130 -21
View File
@@ -481,6 +481,122 @@ def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
)
_SYNC_CLOCK_SKEW_SECONDS = 1.0
def _epoch_of(iso_timestamp) -> float:
if not iso_timestamp:
return 0.0
try:
return datetime.fromisoformat(str(iso_timestamp)).timestamp()
except (TypeError, ValueError):
return 0.0
def _file_records(project_uid: str) -> dict:
records: dict = {}
for row in _table().find(project_uid=project_uid, deleted_at=None):
if row["type"] != "file":
continue
records[row["path"]] = row
return records
def _walk_workspace_files(src, skip):
for root, dirs, files in src.walk():
dirs[:] = [d for d in sorted(dirs) if d not in skip]
for name in sorted(files):
if name in skip:
continue
full = Path(root) / name
if full.is_symlink() or not full.is_file():
continue
try:
if full.stat().st_size > IMPORT_MAX_FILE_BYTES:
continue
relative = full.relative_to(src).as_posix()
path = normalize_path(relative)
except (OSError, ProjectFileError):
continue
yield path, full
def _workspace_records(workspace) -> dict:
src = Path(workspace).resolve()
records: dict = {}
if not src.is_dir():
return records
for path, full in _walk_workspace_files(src, SYNC_SKIP_NAMES):
records[path] = full
return records
def sync_dir_bidirectional(project_uid: str, workspace, user: dict) -> dict:
if "project_files" not in db.tables:
return {"exported": 0, "imported": 0}
readonly = is_readonly(project_uid)
dest = Path(workspace).resolve()
dest.mkdir(parents=True, exist_ok=True)
db_files = _file_records(project_uid)
fs_files = _workspace_records(dest)
exported = 0
imported = 0
for path, row in db_files.items():
fs_full = fs_files.get(path)
if fs_full is None:
_export_node(row, dest)
exported += 1
continue
try:
fs_mtime = fs_full.stat().st_mtime
except OSError:
continue
db_mtime = _epoch_of(row.get("updated_at"))
if db_mtime >= fs_mtime - _SYNC_CLOCK_SKEW_SECONDS:
_export_node(row, dest)
exported += 1
elif not readonly:
if _import_file(project_uid, user, path, fs_full):
imported += 1
if not readonly:
for path, fs_full in fs_files.items():
if path in db_files:
continue
if _import_file(project_uid, user, path, fs_full):
imported += 1
return {"exported": exported, "imported": imported}
def _export_node(row: dict, dest: Path) -> None:
target = (dest / row["path"]).resolve()
if target != dest and not target.is_relative_to(dest):
return
target.parent.mkdir(parents=True, exist_ok=True)
if target.is_symlink():
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:
target.write_text(row.get("content") or "", encoding="utf-8")
def _import_file(project_uid: str, user: dict, path: str, fs_full: Path) -> bool:
try:
data = fs_full.read_bytes()
except OSError:
return False
try:
store_upload(project_uid, user, _parent_of(path), _name_of(path), data)
return True
except ProjectFileError:
return False
def node_to_dict(row: dict) -> dict:
is_binary = bool(row.get("is_binary"))
url = None
@@ -535,6 +651,10 @@ IMPORT_SKIP_NAMES = {
".idea",
".cache",
}
SYNC_SKIP_NAMES = IMPORT_SKIP_NAMES | {
".devplace_boot.py",
".devplace_boot.sh",
}
IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024
@@ -545,27 +665,16 @@ def import_from_dir(project_uid: str, src_dir, user: dict, *, skip_names=None) -
raise ProjectFileError("source directory does not exist")
skip = set(skip_names) if skip_names is not None else set(IMPORT_SKIP_NAMES)
imported = 0
for root, dirs, files in src.walk():
dirs[:] = [d for d in sorted(dirs) if d not in skip]
for name in sorted(files):
if name in skip:
continue
full = Path(root) / name
if full.is_symlink() or not full.is_file():
continue
try:
if full.stat().st_size > IMPORT_MAX_FILE_BYTES:
continue
relative = full.relative_to(src).as_posix()
path = normalize_path(relative)
data = full.read_bytes()
except (OSError, ProjectFileError):
continue
try:
store_upload(project_uid, user, _parent_of(path), _name_of(path), data)
imported += 1
except ProjectFileError:
continue
for path, full in _walk_workspace_files(src, skip):
try:
data = full.read_bytes()
except OSError:
continue
try:
store_upload(project_uid, user, _parent_of(path), _name_of(path), data)
imported += 1
except ProjectFileError:
continue
return imported
+2
View File
@@ -4,6 +4,7 @@ from devplacepy.routers.admin import (
aiquota,
aiusage,
auditlog,
bots,
containers,
media,
news,
@@ -24,5 +25,6 @@ router.include_router(settings.router)
router.include_router(notifications.router)
router.include_router(news.router)
router.include_router(auditlog.router)
router.include_router(bots.router)
router.include_router(services.router, prefix="/services")
router.include_router(containers.router, prefix="/containers")
+99
View File
@@ -0,0 +1,99 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
from devplacepy.responses import respond
from devplacepy.schemas import AdminBotsOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.bot.monitor import monitor
from devplacepy.services.manager import service_manager
from devplacepy.utils import not_found, require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
def _service_describe() -> dict:
service = service_manager.get_service("bots")
if service is None:
return {}
try:
return service.describe()
except Exception as e:
logger.warning("bots describe failed: %s", e)
return {}
def _frames_payload() -> dict:
described = _service_describe()
metrics = described.get("metrics") or {}
frames = []
for frame in metrics.get("frames", []):
slot = frame.get("slot", 0)
captured_at = frame.get("captured_at", 0)
frames.append(
{
**frame,
"frame_url": f"/admin/bots/{slot}/frame.jpg?t={captured_at}",
}
)
return {
"frames": frames,
"enabled": bool(described.get("enabled")),
"service_status": described.get("status", ""),
}
@router.get("/bots", response_class=HTMLResponse)
async def bots_monitor(request: Request):
admin = require_admin(request)
payload = _frames_payload()
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Bot Monitor - Admin",
description="Live low-quality screenshots of every running bot persona.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Bot Monitor", "url": "/admin/bots"},
],
schemas=[website_schema(base)],
)
return respond(
request,
"admin_bots.html",
{
**seo_ctx,
"request": request,
"user": admin,
**payload,
"admin_section": "bots",
},
model=AdminBotsOut,
)
@router.get("/bots/data")
async def bots_monitor_data(request: Request):
require_admin(request)
return JSONResponse(_frames_payload())
@router.get("/bots/{slot}/frame.jpg")
async def bots_monitor_frame(request: Request, slot: int):
require_admin(request)
image = monitor.read_image(slot)
if image is None:
raise not_found("Frame not available")
return Response(
content=image,
media_type="image/jpeg",
headers={"Cache-Control": "no-store"},
)
+270 -10
View File
@@ -1,19 +1,35 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Request
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import db, get_table
from devplacepy.database import (
db,
get_table,
get_users_by_uids,
resolve_by_slug,
search_users_by_username,
)
from devplacepy.models import ContainerAdminCreateForm, ContainerEditForm
from devplacepy.responses import action_result, json_error, respond
from devplacepy.schemas import (
AdminContainerEditOut,
AdminContainerInstanceOut,
AdminContainersOut,
)
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.containers import api, store
from devplacepy.responses import respond
from devplacepy.schemas import AdminContainersOut, AdminContainerInstanceOut
from devplacepy.services.containers.api import ContainerError
from devplacepy.services.audit import record as audit
from devplacepy.utils import not_found, require_admin
router = APIRouter()
BOOT_LANGUAGES = list(api.BOOT_LANGUAGES)
RESTART_POLICIES = list(store.RESTART_POLICIES)
def _project_index(project_uids: set) -> dict:
if not project_uids or "projects" not in db.tables:
@@ -36,6 +52,31 @@ def _decorate(instances: list) -> list:
return decorated
def _instance_or_404(uid: str) -> dict:
inst = store.get_instance(uid)
if not inst:
raise not_found("Instance not found")
return inst
def _project_of(inst: dict) -> dict:
return get_table("projects").find_one(uid=inst["project_uid"]) or {}
def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summary: str, metadata=None) -> None:
audit.record(
request,
event_key,
user=admin,
target_type="instance",
target_uid=inst["uid"],
target_label=inst.get("name"),
metadata=metadata,
summary=summary,
links=[audit.instance(inst["uid"], inst.get("name"))],
)
@router.get("", response_class=HTMLResponse)
async def containers_index(request: Request):
admin = require_admin(request)
@@ -62,6 +103,8 @@ async def containers_index(request: Request):
"request": request,
"user": admin,
"instances": instances,
"boot_languages": BOOT_LANGUAGES,
"restart_policies": RESTART_POLICIES,
"admin_section": "containers",
},
model=AdminContainersOut,
@@ -74,13 +117,230 @@ async def containers_index_json(request: Request):
return JSONResponse({"instances": _decorate(store.all_instances())})
@router.get("/projects/search")
async def project_search(request: Request, q: str = ""):
require_admin(request)
if not q or "projects" not in db.tables:
return JSONResponse({"results": []})
rows = db.query(
"SELECT uid, slug, title FROM projects "
"WHERE deleted_at IS NULL AND title LIKE :q LIMIT 10",
q=f"%{q}%",
)
results = [
{"uid": r["uid"], "slug": r["slug"] or r["uid"], "title": r["title"]}
for r in rows
]
return JSONResponse({"results": results})
@router.get("/users/search")
async def user_search(request: Request, q: str = ""):
require_admin(request)
if not q:
return JSONResponse({"results": []})
return JSONResponse({"results": search_users_by_username(q)})
@router.post("/create")
async def container_create(
request: Request, data: Annotated[ContainerAdminCreateForm, Form()]
):
admin = require_admin(request)
project = resolve_by_slug(get_table("projects"), data.project_slug)
if not project:
return json_error(404, "project not found")
try:
inst = await api.create_instance(
project,
name=data.name,
boot_command=data.boot_command,
boot_language=data.boot_language,
boot_script=data.boot_script,
run_as_uid=data.run_as_uid,
start_on_boot=data.start_on_boot,
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", admin["uid"]),
)
except ContainerError as exc:
return json_error(400, str(exc))
_audit_admin(
request,
admin,
"container.instance.create",
inst,
f"admin {admin['username']} created container instance {inst.get('name')}",
metadata={
"project": project.get("title"),
"boot_language": data.boot_language,
"run_as_uid": data.run_as_uid or None,
"start_on_boot": data.start_on_boot,
},
)
return action_result(request, "/admin/containers", data={"instance": inst})
@router.get("/{uid}/edit", response_class=HTMLResponse)
async def container_edit_page(request: Request, uid: str):
admin = require_admin(request)
inst = _instance_or_404(uid)
project = _project_of(inst)
run_as_user = None
if inst.get("run_as_uid"):
run_as_user = get_users_by_uids([inst["run_as_uid"]]).get(inst["run_as_uid"])
base = site_url(request)
seo_ctx = base_seo_context(
request,
title=f"Edit {inst['name']} - Containers",
description=f"Edit container instance {inst['name']}.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Containers", "url": "/admin/containers"},
{"name": inst["name"], "url": f"/admin/containers/{inst['uid']}"},
{"name": "Edit", "url": f"/admin/containers/{inst['uid']}/edit"},
],
schemas=[website_schema(base)],
)
return respond(
request,
"containers_edit.html",
{
**seo_ctx,
"request": request,
"user": admin,
"instance": inst,
"project": project,
"run_as_user": run_as_user,
"boot_languages": BOOT_LANGUAGES,
"restart_policies": RESTART_POLICIES,
"admin_section": "containers",
},
model=AdminContainerEditOut,
)
@router.post("/{uid}/edit")
async def container_edit(
request: Request, uid: str, data: Annotated[ContainerEditForm, Form()]
):
admin = require_admin(request)
inst = _instance_or_404(uid)
try:
updated = api.update_instance_config(
inst,
run_as_uid=data.run_as_uid,
boot_language=data.boot_language,
boot_script=data.boot_script,
boot_command=data.boot_command,
restart_policy=data.restart_policy,
start_on_boot=data.start_on_boot,
cpu_limit=data.cpu_limit,
mem_limit=data.mem_limit,
actor=("user", admin["uid"]),
)
except ContainerError as exc:
return json_error(400, str(exc))
_audit_admin(
request,
admin,
"container.instance.configure",
updated,
f"admin {admin['username']} edited container instance {updated.get('name')}",
metadata={
"boot_language": data.boot_language,
"run_as_uid": data.run_as_uid or None,
"start_on_boot": data.start_on_boot,
"restart_policy": data.restart_policy,
},
)
return action_result(
request, f"/admin/containers/{uid}", data={"instance": updated}
)
_ACTIONS = {
"start": store.DESIRED_RUNNING,
"stop": store.DESIRED_STOPPED,
"pause": store.DESIRED_PAUSED,
"resume": store.DESIRED_RUNNING,
}
@router.post("/{uid}/start")
@router.post("/{uid}/stop")
@router.post("/{uid}/pause")
@router.post("/{uid}/resume")
@router.post("/{uid}/restart")
async def container_action(request: Request, uid: str):
admin = require_admin(request)
inst = _instance_or_404(uid)
actor = ("user", admin["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)
_audit_admin(
request,
admin,
f"container.instance.{action}",
inst,
f"admin {admin['username']} {action} container instance {inst.get('name')}",
)
return action_result(
request, "/admin/containers", data={"uid": uid, "action": action}
)
@router.post("/{uid}/sync")
async def container_sync(request: Request, uid: str):
admin = require_admin(request)
inst = _instance_or_404(uid)
try:
counts = await api.sync_workspace(inst, admin)
except ContainerError as exc:
return json_error(400, str(exc))
_audit_admin(
request,
admin,
"container.instance.sync",
inst,
f"admin {admin['username']} synced the workspace of instance {inst.get('name')}",
metadata=counts,
)
return action_result(request, f"/admin/containers/{uid}", data=counts)
@router.post("/{uid}/delete")
async def container_delete(request: Request, uid: str):
admin = require_admin(request)
inst = _instance_or_404(uid)
api.mark_for_removal(inst, actor=("user", admin["uid"]))
_audit_admin(
request,
admin,
"container.instance.delete",
inst,
f"admin {admin['username']} removed container instance {inst.get('name')}",
)
return action_result(request, "/admin/containers", data={"uid": uid})
@router.get("/{uid}", response_class=HTMLResponse)
async def container_instance_page(request: Request, uid: str):
admin = require_admin(request)
inst = store.get_instance(uid)
if not inst:
raise not_found("Instance not found")
project = get_table("projects").find_one(uid=inst["project_uid"]) or {}
inst = _instance_or_404(uid)
project = _project_of(inst)
project_slug = project.get("slug") or project.get("uid") or ""
base = site_url(request)
seo_ctx = base_seo_context(
@@ -114,4 +374,4 @@ async def container_instance_page(request: Request, uid: str):
"admin_section": "containers",
},
model=AdminContainerInstanceOut,
)
)
+2 -2
View File
@@ -12,7 +12,7 @@ from devplacepy.database import (
)
from devplacepy.utils import (
require_admin,
hash_password,
hash_password_async,
clear_user_cache,
get_current_user,
is_admin,
@@ -135,7 +135,7 @@ async def admin_user_password(
admin = require_admin(request)
users = get_table("users")
target_user = users.find_one(uid=uid)
users.update({"uid": uid, "password_hash": hash_password(data.password)}, ["uid"])
users.update({"uid": uid, "password_hash": await hash_password_async(data.password)}, ["uid"])
logger.info(f"Admin {admin['username']} changed password for user {uid}")
audit.record(
request,
+2 -2
View File
@@ -7,7 +7,7 @@ from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, get_int_setting
from devplacepy.templating import templates
from devplacepy.utils import (
verify_password,
verify_password_async,
create_session,
get_current_user,
safe_next,
@@ -57,7 +57,7 @@ async def login(request: Request, data: Annotated[LoginForm, Form()]):
users = get_table("users")
user = users.find_one(email=email)
if not user or not verify_password(password, user["password_hash"]):
if not user or not await verify_password_async(password, user["password_hash"]):
errors.append("Invalid email or password")
if errors:
+3 -2
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import hash_password
from devplacepy.utils import hash_password_async
from devplacepy.seo import base_seo_context
from devplacepy.models import ResetPasswordForm
from devplacepy.responses import respond, action_result, wants_json, json_error
@@ -77,7 +77,8 @@ async def reset_password(
)
users.update(
{"uid": matched["user_uid"], "password_hash": hash_password(password)}, ["uid"]
{"uid": matched["user_uid"], "password_hash": await hash_password_async(password)},
["uid"],
)
resets.update({"id": matched["id"], "used": True}, ["id"])
logger.info(f"Password reset completed for user {matched['user_uid']}")
+2 -2
View File
@@ -9,7 +9,7 @@ from devplacepy.templating import templates
from devplacepy.utils import (
create_session,
get_current_user,
register_account,
register_account_async,
)
from devplacepy.seo import base_seo_context
from devplacepy.models import SignupForm
@@ -90,7 +90,7 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
},
)
uid, role, is_first = register_account(username, email, password)
uid, role, is_first = await register_account_async(username, email, password)
max_age = max(1, get_int_setting("session_max_age_days", 7)) * SECONDS_PER_DAY
token = create_session(uid, max_age)
+4 -4
View File
@@ -10,7 +10,7 @@ from fastapi.responses import Response
from devplacepy.cache import TTLCache
from devplacepy.config import SECONDS_PER_DAY
from devplacepy.database import get_table, get_setting
from devplacepy.utils import verify_password, register_account
from devplacepy.utils import verify_password_async, register_account_async
from devplacepy.services.audit import record as audit
from devplacepy.services.devrant.params import merge_params
from devplacepy.services.devrant.tokens import issue_token, resolve_user, revoke_all
@@ -43,7 +43,7 @@ async def auth_token(request: Request):
if (
not user
or not user.get("is_active", True)
or not verify_password(password, user["password_hash"])
or not await verify_password_async(password, user["password_hash"])
):
audit.record(
request,
@@ -96,7 +96,7 @@ async def register(request: Request):
return dr_error("That username is already taken.", error_field="username")
if users.find_one(email=email):
return dr_error("That email is already registered.", error_field="email")
uid, role, is_first = register_account(username, email, password)
uid, role, is_first = await register_account_async(username, email, password)
logger.info("devrant registration for %s", username)
audit.record(
request,
@@ -155,7 +155,7 @@ async def edit_profile(request: Request):
get_table("users").update(updates, ["uid"])
audit.record(
request,
"user.profile.edit",
"profile.update",
user=user,
target_type="user",
target_uid=user["uid"],
+171 -76
View File
@@ -1,29 +1,48 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from typing import Annotated, Optional
from fastapi import APIRouter, Request, Form, WebSocket, WebSocketDisconnect
from devplacepy.models import MessageForm
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import get_table, db
from devplacepy.database import get_table, db, get_users_by_uids, search_users_by_username
from devplacepy.attachments import get_attachments_batch
from devplacepy.templating import clear_messages_cache
from devplacepy.utils import (
generate_uid,
require_user,
time_ago,
create_mention_notifications,
create_notification,
is_admin,
_user_from_session,
_user_from_api_key,
)
from devplacepy.seo import base_seo_context
from devplacepy.responses import respond, action_result
from devplacepy.schemas import MessagesOut
from devplacepy.services.audit import record as audit
from devplacepy.services.messaging import (
message_frame,
message_hub,
message_relay,
persist_message,
)
logger = logging.getLogger(__name__)
router = APIRouter()
MAX_WS_ATTACHMENTS = 5
def mark_conversation_read(user_uid: str, other_uid: str) -> None:
if "messages" not in db.tables:
return
with db:
db.query(
"UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0",
me=user_uid,
other=other_uid,
)
clear_messages_cache(user_uid)
def get_conversations(user_uid: str):
messages_table = get_table("messages")
@@ -56,8 +75,6 @@ def get_conversations(user_uid: str):
}
if other_uids:
from devplacepy.database import get_users_by_uids
users_map = get_users_by_uids(list(other_uids))
for uid, conv in conversation_map.items():
conv["other_user"] = users_map.get(uid)
@@ -83,16 +100,7 @@ def get_conversation_messages(user_uid: str, other_uid: str):
msgs.append(m)
msgs.sort(key=lambda m: m["created_at"])
if "messages" in db.tables:
with db:
db.query(
"UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0",
me=user_uid,
other=other_uid,
)
clear_messages_cache(user_uid)
from devplacepy.database import get_users_by_uids
mark_conversation_read(user_uid, other_uid)
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
users_map = get_users_by_uids(user_ids)
@@ -128,9 +136,13 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
current_conversation = None
messages = []
other_user = None
other_online = False
other_last_seen = None
if with_uid:
messages, other_user = get_conversation_messages(user["uid"], with_uid)
current_conversation = with_uid
other_online = message_hub.is_online(with_uid)
other_last_seen = message_hub.last_seen(with_uid)
audit.record(
request,
"message.read_on_view",
@@ -164,6 +176,8 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
"other_user": other_user,
"current_conversation": current_conversation,
"search": search,
"other_online": other_online,
"other_last_seen": other_last_seen,
},
model=MessagesOut,
)
@@ -174,72 +188,153 @@ async def search_users(request: Request, q: str = ""):
user = require_user(request)
if not q or len(q) < 1:
return JSONResponse({"results": []})
if "users" in db.tables:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q AND uid != :me LIMIT 10",
q=f"%{q}%",
me=user["uid"],
)
results = [{"uid": r["uid"], "username": r["username"]} for r in rows]
else:
results = []
results = search_users_by_username(q, exclude_uid=user["uid"])
return JSONResponse({"results": results})
@router.post("/send")
async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
user = require_user(request)
content = data.content.strip()
receiver_uid = data.receiver_uid
if not get_table("users").find_one(uid=receiver_uid):
message = persist_message(
user,
receiver_uid,
data.content,
data.attachment_uids,
request=request,
origin="web",
)
if message is None:
return action_result(request, "/messages")
messages_table = get_table("messages")
msg_uid = generate_uid()
messages_table.insert(
{
"uid": msg_uid,
"sender_uid": user["uid"],
"receiver_uid": receiver_uid,
"content": content,
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
from devplacepy.attachments import link_attachments
link_attachments(data.attachment_uids, "message", msg_uid)
if user["uid"] != receiver_uid:
create_notification(
receiver_uid,
"message",
f"{user['username']} sent you a message",
user["uid"],
f"/messages?with_uid={user['uid']}",
)
clear_messages_cache(receiver_uid)
create_mention_notifications(
content, user["uid"], f"/messages?with_uid={receiver_uid}"
)
logger.info(f"Message {msg_uid} sent from {user['username']} to {receiver_uid}")
receiver = get_table("users").find_one(uid=receiver_uid)
audit.record(
request,
"message.send",
user=user,
target_type="message",
target_uid=msg_uid,
summary=f"{user['username']} sent a message to {receiver.get('username') if receiver else receiver_uid}: {content}",
links=[
audit.target("message", msg_uid),
audit.recipient(receiver_uid, receiver.get("username") if receiver else None),
],
)
await broadcast_message(user, message)
return action_result(
request, f"/messages?with_uid={receiver_uid}", data={"uid": msg_uid}
request, f"/messages?with_uid={receiver_uid}", data={"uid": message["uid"]}
)
async def broadcast_message(
sender: dict, message: dict, client_id: Optional[str] = None
) -> None:
frame = message_frame(message, sender.get("username", ""), client_id)
message_hub.mark_delivered(message["uid"])
targets = [message["sender_uid"], message["receiver_uid"]]
await message_hub.send_to_users(targets, frame)
def _resolve_ws_user(websocket: WebSocket):
user = _user_from_session(websocket)
if user:
return user
key = websocket.headers.get("x-api-key", "").strip()
if not key:
scheme, _, credentials = websocket.headers.get("authorization", "").partition(
" "
)
if scheme.lower() == "bearer":
key = credentials.strip()
if key:
return _user_from_api_key(key)
return None
async def _announce_presence(user_uid: str, online: bool) -> None:
other_uids = {c["other_user"]["uid"] for c in get_conversations(user_uid) if c["other_user"]}
if not other_uids:
return
frame = {
"type": "presence",
"user_uid": user_uid,
"online": online,
"last_seen": None if online else message_hub.last_seen(user_uid),
}
await message_hub.send_to_users(list(other_uids), frame)
@router.websocket("/ws")
async def messages_ws(websocket: WebSocket):
await websocket.accept()
user = _resolve_ws_user(websocket)
if not user:
await websocket.close(code=1008)
return
user_uid = user["uid"]
was_offline = message_hub.register(user_uid, websocket)
message_relay.start()
try:
await websocket.send_json({"type": "ready", "user_uid": user_uid})
if was_offline:
await _announce_presence(user_uid, True)
while True:
data = await websocket.receive_json()
kind = data.get("type")
if kind == "send":
receiver_uid = str(data.get("receiver_uid", "")).strip()
content = str(data.get("content", ""))
client_id = data.get("client_id")
raw_attachments = data.get("attachment_uids") or []
attachment_uids = [str(a) for a in raw_attachments][:MAX_WS_ATTACHMENTS]
if not receiver_uid:
continue
message = persist_message(
user,
receiver_uid,
content,
attachment_uids,
origin="websocket",
)
if message is None:
await websocket.send_json(
{"type": "error", "client_id": client_id, "text": "Message not sent."}
)
continue
await broadcast_message(user, message, client_id)
elif kind == "typing":
receiver_uid = str(data.get("receiver_uid", "")).strip()
if receiver_uid:
await message_hub.send_to_user(
receiver_uid, {"type": "typing", "from_uid": user_uid}
)
elif kind == "read":
with_uid = str(data.get("with_uid", "")).strip()
if with_uid:
mark_conversation_read(user_uid, with_uid)
other_user = get_users_by_uids([with_uid]).get(with_uid)
other_username = other_user.get("username") if other_user else None
audit.record(
websocket,
"message.read_on_view",
user=user,
target_type="user",
target_uid=with_uid,
target_label=other_username,
summary=f"{user['username']} read messages from {other_username or with_uid}",
links=[audit.target("user", with_uid, other_username)],
)
await message_hub.send_to_user(
with_uid, {"type": "read", "by_uid": user_uid}
)
elif kind == "presence":
with_uid = str(data.get("with_uid", "")).strip()
if with_uid:
await websocket.send_json(
{
"type": "presence",
"user_uid": with_uid,
"online": message_hub.is_online(with_uid),
"last_seen": message_hub.last_seen(with_uid),
}
)
except WebSocketDisconnect:
pass
except Exception: # noqa: BLE001
logger.exception("messages websocket loop failed for %s", user_uid)
finally:
went_offline = message_hub.unregister(user_uid, websocket)
if went_offline:
try:
await _announce_presence(user_uid, False)
except Exception: # noqa: BLE001
logger.debug("presence offline announce failed for %s", user_uid)
+2 -8
View File
@@ -23,6 +23,7 @@ from devplacepy.database import (
get_follow_list,
get_following_among,
get_user_media,
search_users_by_username,
)
from devplacepy.content import can_view_project, enrich_items
from devplacepy.utils import (
@@ -56,14 +57,7 @@ async def search_users(request: Request, q: str = ""):
require_user_api(request)
if not q or len(q) < 1:
return JSONResponse({"results": []})
if "users" in db.tables:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT 10",
q=f"%{q}%",
)
results = [{"uid": r["uid"], "username": r["username"]} for r in rows]
else:
results = []
results = search_users_by_username(q)
return JSONResponse({"results": results})
@@ -97,6 +97,10 @@ async def create_instance(
project,
name=data.name,
boot_command=data.boot_command,
boot_language=data.boot_language,
boot_script=data.boot_script,
run_as_uid=data.run_as_uid,
start_on_boot=data.start_on_boot,
env=data.env,
cpu_limit=data.cpu_limit,
mem_limit=data.mem_limit,
@@ -221,16 +225,16 @@ async def instance_sync(request: Request, project_slug: str, uid: str):
project = project_for(project_slug)
inst = instance_for(project, uid)
try:
count = await api.sync_workspace(inst, user)
counts = await api.sync_workspace(inst, user)
except ContainerError as exc:
return fail(exc)
audit_instance(
request, user, "container.instance.sync", inst, project,
summary=f"admin {user['username']} synced the workspace of instance {inst.get('name')}",
metadata={"files_imported": count},
metadata=counts,
)
return action_result(
request, f"/projects/{slug_of(project)}/containers", data={"imported": count}
request, f"/projects/{slug_of(project)}/containers", data=counts
)
+42
View File
@@ -43,6 +43,10 @@ class InstanceOut(_Out):
ingress_slug: Optional[str] = None
ingress_port: int = 0
boot_command: Optional[str] = None
boot_language: Optional[str] = None
boot_script: Optional[str] = None
run_as_uid: Optional[str] = None
start_on_boot: int = 0
cpu_limit: Optional[str] = None
mem_limit: Optional[str] = None
created_at: Optional[str] = None
@@ -653,6 +657,8 @@ class MessagesOut(_Out):
other_user: Optional[UserOut] = None
current_conversation: Optional[str] = None
search: Optional[str] = None
other_online: bool = False
other_last_seen: Optional[str] = None
class NotificationsOut(_Out):
@@ -719,6 +725,19 @@ class AdminMediaOut(_Out):
class AdminContainersOut(_Out):
instances: list = []
projects: list = []
boot_languages: list = []
restart_policies: list = []
admin_section: Optional[str] = None
user: Optional[Any] = None
class AdminContainerEditOut(_Out):
instance: Optional[Any] = None
project: Optional[dict] = None
run_as_user: Optional[dict] = None
boot_languages: list = []
restart_policies: list = []
admin_section: Optional[str] = None
user: Optional[Any] = None
@@ -735,6 +754,29 @@ class AdminContainerInstanceOut(_Out):
user: Optional[Any] = None
class BotFrameOut(_Out):
slot: int = 0
username: str = ""
persona: str = ""
action: str = ""
status: str = ""
url: str = ""
label: str = ""
captured_at: int = 0
age_seconds: int = 0
has_image: bool = False
active: bool = False
frame_url: str = ""
class AdminBotsOut(_Out):
frames: list[BotFrameOut] = []
enabled: bool = False
service_status: str = ""
admin_section: Optional[str] = None
user: Optional[Any] = None
class TrashItemOut(_Out):
uid: str
table: str
+56 -33
View File
@@ -2,18 +2,30 @@
import asyncio
import logging
import queue
import threading
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
QUEUE_MAXSIZE = 10000
DRAIN_BATCH = 64
JOIN_TIMEOUT_SECONDS = 10
GET_TIMEOUT_SECONDS = 1
class _Stop:
pass
_STOP = _Stop()
class BackgroundQueue:
def __init__(self, maxsize: int = QUEUE_MAXSIZE) -> None:
self._queue: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
self._task: Optional[asyncio.Task] = None
self._queue: "queue.Queue" = queue.Queue(maxsize=maxsize)
self._thread: Optional[threading.Thread] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._counters_lock = threading.Lock()
self._submitted = 0
self._processed = 0
self._failed = 0
@@ -21,44 +33,55 @@ class BackgroundQueue:
@property
def running(self) -> bool:
return self._task is not None and not self._task.done()
return self._thread is not None and self._thread.is_alive()
@property
def loop(self) -> Optional[asyncio.AbstractEventLoop]:
return self._loop
def submit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
self._submitted += 1
with self._counters_lock:
self._submitted += 1
if not self.running:
self._execute(fn, args, kwargs, inline=True)
return
try:
self._queue.put_nowait((fn, args, kwargs))
except asyncio.QueueFull:
except queue.Full:
logger.warning("background queue full; running task inline")
self._execute(fn, args, kwargs, inline=True)
def _execute(self, fn: Callable[..., Any], args: tuple, kwargs: dict, inline: bool = False) -> None:
if inline:
self._inline += 1
try:
fn(*args, **kwargs)
self._processed += 1
with self._counters_lock:
self._processed += 1
if inline:
self._inline += 1
except Exception as exc:
self._failed += 1
with self._counters_lock:
self._failed += 1
if inline:
self._inline += 1
logger.warning("background task %s failed: %s", getattr(fn, "__name__", fn), exc)
async def start(self) -> None:
if self.running:
return
self._task = asyncio.create_task(self._drain())
logger.info("background task queue started")
self._loop = asyncio.get_running_loop()
self._thread = threading.Thread(
target=self._worker, name="background-queue", daemon=True
)
self._thread.start()
logger.info("background task queue worker thread started")
async def stop(self) -> None:
task = self._task
self._task = None
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
thread = self._thread
self._thread = None
self._loop = None
if thread is not None:
self._queue.put(_STOP)
await asyncio.to_thread(thread.join, JOIN_TIMEOUT_SECONDS)
self._flush_remaining()
logger.info(
"background task queue stopped processed=%s failed=%s inline=%s",
@@ -70,24 +93,24 @@ class BackgroundQueue:
def _flush_remaining(self) -> None:
while True:
try:
fn, args, kwargs = self._queue.get_nowait()
except asyncio.QueueEmpty:
item = self._queue.get_nowait()
except queue.Empty:
break
if item is _STOP:
continue
fn, args, kwargs = item
self._execute(fn, args, kwargs)
self._queue.task_done()
async def _drain(self) -> None:
def _worker(self) -> None:
while True:
fn, args, kwargs = await self._queue.get()
try:
item = self._queue.get(timeout=GET_TIMEOUT_SECONDS)
except queue.Empty:
continue
if item is _STOP:
return
fn, args, kwargs = item
self._execute(fn, args, kwargs)
self._queue.task_done()
for _ in range(DRAIN_BATCH - 1):
try:
fn, args, kwargs = self._queue.get_nowait()
except asyncio.QueueEmpty:
break
self._execute(fn, args, kwargs)
self._queue.task_done()
def stats(self) -> dict:
return {
+13 -1
View File
@@ -46,11 +46,12 @@ logger = logging.getLogger(__name__)
class DevPlaceBot:
def __init__(self, cfg: BotRuntimeConfig, on_event=None):
def __init__(self, cfg: BotRuntimeConfig, on_event=None, slot: int = 0):
self.cfg = cfg
self.base_url = cfg.base_url
self.state_path = cfg.state_path
self.on_event = on_event
self._slot = slot
self.state = BotState.load(cfg.state_path)
if not self.state.persona:
self.state.persona = random.choice(PERSONAS)
@@ -139,6 +140,15 @@ class DevPlaceBot:
self.state.log = self.state.log[-250:]
logger.info("[%s] %s (%s)", self._identity(), line, self._cost_tag())
self._notify(f"[{self._identity()}] {line}")
self.b.note(action=line)
asyncio.create_task(self.b.capture(tag.lower(), force=True))
def _bind_monitor(self) -> None:
self.b.bind_monitor(
self._slot,
username=self.state.username,
persona=self.state.persona,
)
async def _generate(self, fn: Any, *args: Any) -> Any:
try:
@@ -2773,6 +2783,8 @@ class DevPlaceBot:
await asyncio.sleep(60)
continue
self._bind_monitor()
if self._ai_decisions:
await self._ensure_identity()
use_ai = self._ai_decisions and bool(self.state.identity)
+52
View File
@@ -3,10 +3,18 @@
import asyncio
import logging
import random
import time
from typing import Optional
from playwright.async_api import Page, Playwright, async_playwright
from devplacepy.services.bot.config import (
MONITOR_JPEG_QUALITY,
MONITOR_MIN_INTERVAL_SECONDS,
MONITOR_SCALE,
)
from devplacepy.services.bot.monitor import monitor
logger = logging.getLogger(__name__)
DEFAULT_USER_AGENT = (
@@ -30,6 +38,8 @@ class BotBrowser:
self._browser = None
self._context = None
self._page: Optional[Page] = None
self._monitor_slot: Optional[int] = None
self._last_capture: float = 0.0
async def launch(self) -> None:
self._playwright = await async_playwright().start()
@@ -70,6 +80,44 @@ class BotBrowser:
def page(self) -> Page:
return self._page
def bind_monitor(
self, slot: int, *, username: str = "", persona: str = ""
) -> None:
self._monitor_slot = slot
monitor.update_meta(slot, username=username, persona=persona)
def note(self, *, action: str = "", status: str = "") -> None:
if self._monitor_slot is None:
return
monitor.update_meta(self._monitor_slot, action=action, status=status)
async def capture(self, reason: str = "", *, force: bool = False) -> None:
if self._monitor_slot is None or self._page is None:
return
now = time.time()
if not force and now - self._last_capture < MONITOR_MIN_INTERVAL_SECONDS:
return
self._last_capture = now
try:
current_url = ""
try:
current_url = self._page.url
except Exception:
current_url = ""
monitor.update_meta(
self._monitor_slot, status=reason, url=current_url
)
image = await self._page.screenshot(
type="jpeg",
quality=MONITOR_JPEG_QUALITY,
scale=MONITOR_SCALE,
full_page=False,
timeout=4000,
)
monitor.store_image(self._monitor_slot, image)
except Exception as e:
logger.debug("monitor capture (%s) failed: %s", reason, e)
async def goto(self, url: str) -> None:
try:
await self._page.goto(url, wait_until="domcontentloaded", timeout=15000)
@@ -80,6 +128,7 @@ class BotBrowser:
except Exception as e2:
logger.warning("goto failed for %s: %s", url[:80], e2)
await self._idle(0.5, 1.5)
await self.capture("page load", force=True)
async def _idle(self, lo: float = 0.5, hi: float = 2.0) -> None:
await asyncio.sleep(random.uniform(lo, hi))
@@ -92,6 +141,7 @@ class BotBrowser:
except Exception as e:
logger.debug("scroll failed: %s", e)
await self._idle(0.2, 0.6)
await self.capture("scroll")
async def scroll_to_bottom(self) -> None:
try:
@@ -112,6 +162,7 @@ class BotBrowser:
await self._page.keyboard.type(ch, delay=random.randint(20, 60))
if ch == " ":
await asyncio.sleep(random.uniform(0.02, 0.08))
await self.capture("field input")
return True
except Exception as e:
logger.debug("fill failed for '%s': %s", sel, e)
@@ -175,5 +226,6 @@ class BotBrowser:
try:
await self._page.reload(timeout=15000)
await self._idle()
await self.capture("reload", force=True)
except Exception as e:
logger.debug("reload failed: %s", e)
+4
View File
@@ -14,6 +14,10 @@ OUTPUT_COST_PER_1M_DEFAULT = 1.10
COST_WINDOW_SECONDS = 600
COST_WARMUP_SECONDS = 120
MONITOR_JPEG_QUALITY = 35
MONITOR_SCALE = "css"
MONITOR_MIN_INTERVAL_SECONDS = 1.0
STATE_DIR = BOT_DIR
ARTICLE_REGISTRY_PATH = BOT_DIR / "article_registry.json"
+122
View File
@@ -0,0 +1,122 @@
# retoor <retoor@molodetz.nl>
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from devplacepy.config import BOT_DIR
logger = logging.getLogger(__name__)
MONITOR_DIR: Path = BOT_DIR / "monitor"
FRAME_SUFFIX = ".jpg"
ACTIVE_WINDOW_SECONDS = 90
@dataclass
class BotFrame:
slot: int
username: str = ""
persona: str = ""
action: str = ""
status: str = ""
url: str = ""
captured_at: float = 0.0
has_image: bool = False
def label(self) -> str:
return self.username or f"bot{self.slot}"
def age_seconds(self) -> float:
return max(0.0, time.time() - self.captured_at) if self.captured_at else 0.0
def is_active(self) -> bool:
return self.has_image and self.age_seconds() <= ACTIVE_WINDOW_SECONDS
def as_dict(self) -> dict:
return {
"slot": self.slot,
"username": self.username,
"persona": self.persona,
"action": self.action,
"status": self.status,
"url": self.url,
"label": self.label(),
"captured_at": int(self.captured_at) if self.captured_at else 0,
"age_seconds": int(self.age_seconds()),
"has_image": self.has_image,
"active": self.is_active(),
}
@dataclass
class BotMonitor:
frames: dict[int, BotFrame] = field(default_factory=dict)
def frame_path(self, slot: int) -> Path:
return MONITOR_DIR / f"slot{slot}{FRAME_SUFFIX}"
def update_meta(
self,
slot: int,
*,
username: str = "",
persona: str = "",
action: str = "",
status: str = "",
url: str = "",
) -> None:
frame = self.frames.get(slot) or BotFrame(slot=slot)
if username:
frame.username = username
if persona:
frame.persona = persona
if action:
frame.action = action
if status:
frame.status = status
if url:
frame.url = url
self.frames[slot] = frame
def store_image(self, slot: int, image: bytes) -> None:
if not image:
return
frame = self.frames.get(slot) or BotFrame(slot=slot)
try:
MONITOR_DIR.mkdir(parents=True, exist_ok=True)
path = self.frame_path(slot)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_bytes(image)
tmp.replace(path)
except OSError as e:
logger.debug("monitor store_image slot %s failed: %s", slot, e)
return
frame.has_image = True
frame.captured_at = time.time()
self.frames[slot] = frame
def drop(self, slot: int) -> None:
self.frames.pop(slot, None)
try:
self.frame_path(slot).unlink(missing_ok=True)
except OSError as e:
logger.debug("monitor drop slot %s failed: %s", slot, e)
def read_image(self, slot: int) -> Optional[bytes]:
path = self.frame_path(slot)
if not path.exists():
return None
try:
return path.read_bytes()
except OSError as e:
logger.debug("monitor read_image slot %s failed: %s", slot, e)
return None
def snapshot(self) -> list[dict]:
return [self.frames[slot].as_dict() for slot in sorted(self.frames)]
monitor = BotMonitor()
+4 -2
View File
@@ -8,6 +8,7 @@ from datetime import datetime, timedelta, timezone
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.bot import config
from devplacepy.services.bot.monitor import monitor
logger = logging.getLogger(__name__)
@@ -283,7 +284,7 @@ class BotsService(BaseService):
ai_decisions=cfg["bot_ai_decisions"],
decision_temperature=cfg["bot_decision_temperature"],
)
bot = DevPlaceBot(rc, on_event=self.log)
bot = DevPlaceBot(rc, on_event=self.log, slot=slot)
task = asyncio.create_task(self._run_slot(slot, bot, cfg["bot_max_actions"]))
self._fleet[slot] = {"bot": bot, "task": task}
self.log(f"Launched bot{slot}")
@@ -319,6 +320,7 @@ class BotsService(BaseService):
except Exception as e:
self.log(f"bot{slot} stop error: {e}")
await self._close_bot(slot, entry.get("bot"))
monitor.drop(slot)
self.log(f"Stopped bot{slot}")
async def _close_bot(self, slot: int, bot) -> None:
@@ -401,7 +403,7 @@ class BotsService(BaseService):
],
"rows": rows,
}
return {"stats": stats, "table": table}
return {"stats": stats, "table": table, "frames": monitor.snapshot()}
def _sample_cost(
self, now: datetime, total_cost: float
+176 -8
View File
@@ -30,6 +30,10 @@ INSTANCE_LABEL = "devplace.instance"
PROJECT_LABEL = "devplace.project"
HOST_PORT_MIN = 20001
HOST_PORT_MAX = 65535
BOOT_LANGUAGES = ("none", "python", "bash")
BOOT_SCRIPT_FILES = {"python": ".devplace_boot.py", "bash": ".devplace_boot.sh"}
BOOT_SCRIPT_RUNNERS = {"python": "python", "bash": "bash"}
MAX_BOOT_SCRIPT_CHARS = 100_000
class ContainerError(ValueError):
@@ -43,6 +47,36 @@ def _validate_limits(cpu_limit: str, mem_limit: str) -> None:
raise ContainerError("memory limit must look like 512m, 1g, or a byte count")
def validate_run_as(run_as_uid) -> str:
uid = str(run_as_uid or "").strip()
if not uid:
return ""
from devplacepy import database
user = database.get_users_by_uids([uid]).get(uid)
if not user:
raise ContainerError(f"run-as user not found: {uid}")
return uid
def validate_boot(boot_language, boot_script) -> tuple:
language = str(boot_language or "none").strip().lower() or "none"
if language not in BOOT_LANGUAGES:
raise ContainerError(
f"boot language must be one of {', '.join(BOOT_LANGUAGES)}"
)
script = str(boot_script or "")
if language == "none":
script = ""
if len(script) > MAX_BOOT_SCRIPT_CHARS:
raise ContainerError(
f"boot script exceeds the {MAX_BOOT_SCRIPT_CHARS}-character limit"
)
if language != "none" and not script.strip():
raise ContainerError("boot script is required when a boot language is set")
return language, script
def parse_ports(value) -> list:
ports = []
if not value:
@@ -164,6 +198,10 @@ async def create_instance(
*,
name: str,
boot_command: str = "",
boot_language: str = "none",
boot_script: str = "",
run_as_uid: str = "",
start_on_boot: bool = False,
env="",
cpu_limit: str = "",
mem_limit: str = "",
@@ -184,6 +222,8 @@ async def create_instance(
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
)
_validate_limits(cpu_limit, mem_limit)
run_as_uid = validate_run_as(run_as_uid)
boot_language, boot_script = validate_boot(boot_language, boot_script)
port_list = assign_host_ports(parse_ports(ports))
env_map = parse_env(env)
name = (name or "").strip()
@@ -200,8 +240,12 @@ async def create_instance(
"project_uid": project["uid"],
"created_by": actor[1] if actor and actor[0] == "user" else "",
"owner_uid": project.get("user_uid", ""),
"run_as_uid": run_as_uid,
"name": name,
"boot_command": boot_command or "",
"boot_language": boot_language,
"boot_script": boot_script,
"start_on_boot": 1 if start_on_boot else 0,
"env_json": json.dumps(env_map),
"cpu_limit": str(cpu_limit or ""),
"mem_limit": str(mem_limit or ""),
@@ -259,12 +303,105 @@ def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
store.record_event(instance, "remove", actor[0], actor[1])
def update_instance_config(
instance: dict,
*,
run_as_uid=None,
boot_language=None,
boot_script=None,
boot_command=None,
restart_policy=None,
start_on_boot=None,
cpu_limit=None,
mem_limit=None,
actor=("system", "system"),
) -> dict:
changes: dict = {}
if run_as_uid is not None:
changes["run_as_uid"] = validate_run_as(run_as_uid)
if boot_language is not None or boot_script is not None:
language = (
boot_language
if boot_language is not None
else instance.get("boot_language", "none")
)
script = (
boot_script if boot_script is not None else instance.get("boot_script", "")
)
language, script = validate_boot(language, script)
changes["boot_language"] = language
changes["boot_script"] = script
if boot_command is not None:
changes["boot_command"] = str(boot_command or "")[:500]
if restart_policy is not None:
if restart_policy not in store.RESTART_POLICIES:
raise ContainerError(
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
)
changes["restart_policy"] = restart_policy
if start_on_boot is not None:
changes["start_on_boot"] = 1 if start_on_boot else 0
if cpu_limit is not None or mem_limit is not None:
cpu = cpu_limit if cpu_limit is not None else instance.get("cpu_limit", "")
mem = mem_limit if mem_limit is not None else instance.get("mem_limit", "")
_validate_limits(cpu, mem)
changes["cpu_limit"] = str(cpu or "")
changes["mem_limit"] = str(mem or "")
if not changes:
return store.get_instance(instance["uid"])
store.update_instance(instance["uid"], changes)
store.record_event(
instance, "configure", actor[0], actor[1], {"fields": sorted(changes)}
)
return store.get_instance(instance["uid"])
def set_start_on_boot(
instance: dict, enabled: bool, *, actor=("system", "system")
) -> dict:
store.update_instance(instance["uid"], {"start_on_boot": 1 if enabled else 0})
store.record_event(
instance, "start_on_boot", actor[0], actor[1], {"enabled": bool(enabled)}
)
return store.get_instance(instance["uid"])
def materialize_boot_script(instance: dict) -> None:
language = (instance.get("boot_language") or "none").strip().lower()
workspace = instance.get("workspace_dir")
if not workspace:
return
for filename in BOOT_SCRIPT_FILES.values():
stale = Path(workspace) / filename
if stale.is_file():
try:
stale.unlink()
except OSError:
pass
if language not in BOOT_SCRIPT_FILES:
return
script = instance.get("boot_script") or ""
if not script.strip():
return
target = Path(workspace) / BOOT_SCRIPT_FILES[language]
try:
Path(workspace).mkdir(parents=True, exist_ok=True)
target.write_text(script, encoding="utf-8")
except OSError:
pass
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")):
user_uid = instance.get("owner_uid") or ""
for uid in (
instance.get("run_as_uid"),
instance.get("created_by"),
instance.get("owner_uid"),
):
if not uid:
continue
user = database.get_users_by_uids([uid]).get(uid)
@@ -277,7 +414,7 @@ def pravda_env(instance: dict) -> dict:
"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_USER_UID": instance.get("run_as_uid") or user_uid,
"PRAVDA_CONTAINER_NAME": instance.get("name") or "",
"PRAVDA_CONTAINER_UID": instance.get("uid") or "",
"PRAVDA_INGRESS_URL": ingress_url,
@@ -296,8 +433,15 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
mounts.append(
Mount(extra["host"], extra["container"], extra.get("mode", "rw"))
)
language = (instance.get("boot_language") or "none").strip().lower()
boot = (instance.get("boot_command") or "").strip()
command = ["/bin/sh", "-c", boot] if boot else ["sleep", "infinity"]
if language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
elif boot:
command = ["/bin/sh", "-c", boot]
else:
command = ["sleep", "infinity"]
return RunSpec(
image=image_tag,
name=instance["slug"],
@@ -315,15 +459,39 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
)
async def sync_workspace(instance: dict, user: dict) -> int:
async def sync_workspace(instance: dict, user: dict) -> dict:
workspace = instance.get("workspace_dir")
if not workspace:
raise ContainerError("instance has no workspace")
count = await asyncio.to_thread(
project_files.import_from_dir, instance["project_uid"], workspace, user
counts = await asyncio.to_thread(
project_files.sync_dir_bidirectional, instance["project_uid"], workspace, user
)
store.record_event(instance, "sync", "user", user["uid"], {"imported": count})
return count
store.record_event(
instance,
"sync",
"user",
user["uid"],
{"exported": counts["exported"], "imported": counts["imported"]},
)
return counts
def sync_bidirectional_sync(instance: dict, user: dict) -> dict:
workspace = instance.get("workspace_dir")
if not workspace:
return {"exported": 0, "imported": 0}
counts = project_files.sync_dir_bidirectional(
instance["project_uid"], workspace, user
)
if counts["exported"] or counts["imported"]:
store.record_event(
instance,
"sync",
"service",
"system",
{"exported": counts["exported"], "imported": counts["imported"]},
)
return counts
def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+105 -12
View File
@@ -1,6 +1,8 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import time
from devplacepy import config
from devplacepy.services.base import BaseService, ConfigField
@@ -10,6 +12,34 @@ from devplacepy.services.devii.tasks.schedule import next_run, now_utc, to_iso
from devplacepy.services.audit import record as audit
AUTO_RESTART = ("always", "on-failure", "unless-stopped")
SYNC_EVERY_SECONDS = 60
def _set_status(inst, changes: dict, *, actor_kind: str = "service", reason: str = "") -> dict:
uid = inst["uid"]
old_status = inst.get("status")
new_status = changes.get("status", old_status)
store.update_instance(uid, changes)
inst.update(changes)
if new_status != old_status:
detail = {"from": old_status, "to": new_status}
if reason:
detail["reason"] = reason
store.record_event(inst, "status_change", actor_kind, "", detail)
audit.record_system(
"container.instance.status",
actor_kind="service",
origin="scheduler",
target_type="instance",
target_uid=uid,
target_label=inst.get("name"),
old_value=old_status,
new_value=new_status,
metadata={"reason": reason} if reason else None,
summary=f"instance {inst.get('name')} status {old_status} -> {new_status}",
links=[audit.instance(uid, inst.get("name"))],
)
return inst
def _audit_reconcile(inst, action, summary, metadata=None):
@@ -51,6 +81,8 @@ class ContainerService(BaseService):
def __init__(self):
super().__init__(name="containers", interval_seconds=5)
self._metric_tick = 0
self._booted = False
self._last_sync_at = 0.0
async def run_once(self) -> None:
backend = get_backend()
@@ -69,6 +101,12 @@ class ContainerService(BaseService):
instances = store.all_instances()
known = {inst["uid"] for inst in instances}
if not self._booted:
self._boot_pass(instances)
self._booted = True
await self._periodic_sync(instances, by_uid)
for uid, row in by_uid.items():
if uid not in known:
try:
@@ -125,13 +163,13 @@ class ContainerService(BaseService):
if gateway and gateway != (inst.get("container_gateway") or ""):
changes["container_gateway"] = gateway
if changes:
store.update_instance(uid, changes)
_set_status(inst, changes)
elif ps.state == "paused":
await backend.unpause(ps.container_id)
store.update_instance(uid, {"status": store.ST_RUNNING})
_set_status(inst, {"status": store.ST_RUNNING}, reason="unpause")
elif ps.state == "created":
await backend.start(ps.container_id)
store.update_instance(uid, {"status": store.ST_RUNNING})
_set_status(inst, {"status": store.ST_RUNNING}, reason="start")
else:
await self._handle_exit(backend, inst, ps)
@@ -139,21 +177,29 @@ class ContainerService(BaseService):
if ps is not None and ps.state in ("running", "restarting", "paused"):
await backend.stop(ps.container_id)
if status != store.ST_STOPPED:
store.update_instance(
uid, {"status": store.ST_STOPPED, "stopped_at": store.now()}
_set_status(
inst,
{"status": store.ST_STOPPED, "stopped_at": store.now()},
reason="stopped",
)
elif desired == store.DESIRED_PAUSED:
if ps is not None and ps.state == "running":
await backend.pause(ps.container_id)
store.update_instance(uid, {"status": store.ST_PAUSED})
if status != store.ST_PAUSED:
_set_status(inst, {"status": store.ST_PAUSED}, reason="paused")
async def _launch(self, backend, inst) -> None:
await self._sync_one(inst)
try:
await asyncio.to_thread(api.materialize_boot_script, inst)
except Exception as exc:
self.log(f"boot script materialize {inst['name']} failed: {exc}")
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
try:
cid = await backend.run(spec)
except Exception as exc:
store.update_instance(inst["uid"], {"status": store.ST_CRASHED})
_set_status(inst, {"status": store.ST_CRASHED}, reason="launch_failed")
store.record_event(
inst, "launch_failed", "reconciler", "", {"reason": str(exc)}
)
@@ -169,11 +215,56 @@ class ContainerService(BaseService):
changes["container_ip"] = ip
if gateway:
changes["container_gateway"] = gateway
store.update_instance(inst["uid"], changes)
_set_status(inst, changes, reason="launch")
store.record_event(inst, "start", "reconciler", "")
self.log(f"launched instance {inst['name']}")
_audit_reconcile(inst, "start", f"reconciler started instance {inst['name']}")
def _boot_pass(self, instances) -> None:
for inst in instances:
if int(inst.get("start_on_boot") or 0) != 1:
continue
if inst.get("desired_state") == store.DESIRED_RUNNING:
continue
store.update_instance(inst["uid"], {"desired_state": store.DESIRED_RUNNING})
inst["desired_state"] = store.DESIRED_RUNNING
store.record_event(inst, "start_on_boot", "reconciler", "")
self.log(f"start_on_boot forced running for {inst['name']}")
def _sync_user(self, inst) -> dict:
from devplacepy import database
for uid in (
inst.get("run_as_uid"),
inst.get("created_by"),
inst.get("owner_uid"),
):
if not uid:
continue
user = database.get_users_by_uids([uid]).get(uid)
if user:
return user
return {"uid": "system", "username": "system"}
async def _sync_one(self, inst) -> None:
try:
await asyncio.to_thread(
api.sync_bidirectional_sync, inst, self._sync_user(inst)
)
except Exception as exc:
self.log(f"sync {inst.get('name')} failed: {exc}")
async def _periodic_sync(self, instances, by_uid) -> None:
now = time.monotonic()
if now - self._last_sync_at < SYNC_EVERY_SECONDS:
return
self._last_sync_at = now
for inst in instances:
ps = by_uid.get(inst["uid"])
if ps is None or ps.state != "running":
continue
await self._sync_one(inst)
async def _capture_net(self, backend, cid: str) -> tuple:
try:
data = await backend.inspect(cid)
@@ -204,12 +295,13 @@ class ContainerService(BaseService):
if policy in AUTO_RESTART and not (policy == "on-failure" and exit_code == 0):
try:
await backend.start(ps.container_id)
store.update_instance(
uid,
_set_status(
inst,
{
"status": store.ST_RUNNING,
"restart_count": int(inst.get("restart_count") or 0) + 1,
},
reason="policy_restart",
)
store.record_event(
inst,
@@ -228,14 +320,15 @@ class ContainerService(BaseService):
except Exception as exc:
self.log(f"policy restart {inst['name']} failed: {exc}")
terminal = store.ST_CRASHED if exit_code != 0 else store.ST_STOPPED
store.update_instance(
uid,
_set_status(
inst,
{
"status": terminal,
"desired_state": store.DESIRED_STOPPED,
"exit_code": exit_code,
"stopped_at": store.now(),
},
reason="exit",
)
if terminal == store.ST_CRASHED:
store.record_event(
+4
View File
@@ -48,6 +48,10 @@ def create_instance(row: dict) -> dict:
"restart_count": 0,
"ingress_slug": "",
"ingress_port": 0,
"run_as_uid": "",
"boot_language": "none",
"boot_script": "",
"start_on_boot": 0,
"started_at": "",
"stopped_at": "",
"created_at": now(),
@@ -1160,6 +1160,19 @@ ACTIONS: tuple[Action, ...] = (
params=(path("uid", "Audit event uid."),),
requires_admin=True,
),
Action(
name="bot_monitor",
method="GET",
path="/admin/bots/data",
summary="Live screenshot monitor of every running bot persona (admin only)",
description=(
"Returns JSON: one entry per running bot slot with its username, persona, current "
"action and status text, last page url, frame age in seconds, whether it is active, "
"and a frame_url to the latest low-quality screenshot. Use this to see what the bot "
"fleet is doing right now. Frames live only in the worker running the Bots service."
),
requires_admin=True,
),
Action(
name="admin_list_users",
method="GET",
@@ -45,6 +45,22 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
arg(
"boot_command", "Optional command to run on boot, e.g. 'python app.py'."
),
arg(
"boot_language",
"Optional boot source language: 'none', 'python', or 'bash'. When set with boot_script, the script is materialized into /app and run on launch (takes precedence over boot_command).",
),
arg(
"boot_script",
"Optional boot source code (the body of the python or bash script) run on launch when boot_language is python or bash.",
),
arg(
"run_as_uid",
"Optional DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user, which is always pravda (uid 1000).",
),
arg(
"start_on_boot",
"Force this instance to running whenever the container service starts ('true' or 'false', default false).",
),
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
arg("env", "Optional env vars as KEY=VALUE lines."),
arg(
@@ -90,6 +106,32 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
),
),
),
Action(
name="container_configure_instance",
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg(
"run_as_uid",
"DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID); pass empty to clear. Does NOT change the container OS user (always pravda, uid 1000).",
),
arg("boot_language", "Boot source language: 'none', 'python', or 'bash'."),
arg("boot_script", "Boot source code body run on launch."),
arg("boot_command", "Fallback boot command used when no boot_script is set."),
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
arg(
"start_on_boot",
"Force running on container-service start ('true' or 'false').",
),
arg("cpu_limit", "CPU limit, e.g. 1 or 1.5."),
arg("mem_limit", "Memory limit, e.g. 512m or 1g."),
),
),
Action(
name="container_logs",
method="LOCAL",
@@ -103,6 +103,7 @@ _DEVII_MECHANIC_EVENTS = {
_DEVII_CONTAINER_EVENTS = {
"container_create_instance": "container.instance.create",
"container_configure_instance": "container.instance.configure",
"container_exec": "container.instance.exec",
"container_schedule": "container.schedule.create",
}
@@ -42,6 +42,10 @@ class Action:
"chunks",
"rsearch",
"container",
"customization",
"notification",
"behavior",
"virtual_tool",
] = "http"
freeform_body: bool = False
ajax: bool = False
@@ -79,10 +79,20 @@ class ContainerController:
"no",
"off",
)
start_on_boot = str(arguments.get("start_on_boot", "false")).lower() in (
"true",
"1",
"yes",
"on",
)
inst = await api.create_instance(
project,
name=str(arguments.get("name", "")),
boot_command=str(arguments.get("boot_command", "")),
boot_language=str(arguments.get("boot_language", "none")),
boot_script=str(arguments.get("boot_script", "")),
run_as_uid=str(arguments.get("run_as_uid", "")),
start_on_boot=start_on_boot,
env=arguments.get("env", ""),
cpu_limit=str(arguments.get("cpu_limit", "")),
mem_limit=str(arguments.get("mem_limit", "")),
@@ -105,8 +115,8 @@ class ContainerController:
if action == "delete":
api.mark_for_removal(inst, actor=actor)
elif action == "sync":
count = await api.sync_workspace(inst, self._actor_user())
return json.dumps({"status": "synced", "imported": count})
counts = await api.sync_workspace(inst, self._actor_user())
return json.dumps({"status": "synced", **counts})
elif action == "restart":
api.request_restart(inst, actor=actor)
elif action in ("start", "resume"):
@@ -119,6 +129,32 @@ class ContainerController:
raise ToolInputError(f"unknown action: {action}")
return json.dumps({"status": "ok", "action": action, "instance": inst["uid"]})
async def _configure_instance(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
actor = ("user", self._actor_user()["uid"])
kwargs: dict = {}
for key in (
"run_as_uid",
"boot_language",
"boot_script",
"boot_command",
"restart_policy",
"cpu_limit",
"mem_limit",
):
if key in arguments and arguments.get(key) is not None:
kwargs[key] = str(arguments.get(key))
if "start_on_boot" in arguments and arguments.get("start_on_boot") is not None:
kwargs["start_on_boot"] = str(arguments.get("start_on_boot")).lower() in (
"true",
"1",
"yes",
"on",
)
updated = api.update_instance_config(inst, actor=actor, **kwargs)
return json.dumps({"status": "configured", "instance": updated}, default=str)
async def _logs(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
@@ -0,0 +1,7 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.messaging.hub import message_hub
from devplacepy.services.messaging.persist import message_frame, persist_message
from devplacepy.services.messaging.relay import message_relay
__all__ = ["message_frame", "message_hub", "message_relay", "persist_message"]
+84
View File
@@ -0,0 +1,84 @@
# retoor <retoor@molodetz.nl>
import logging
from collections import OrderedDict
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import WebSocket
logger = logging.getLogger("messaging.hub")
DELIVERED_CAP = 4000
class ConnectionManager:
def __init__(self) -> None:
self._connections: dict[str, set[WebSocket]] = {}
self._last_seen: dict[str, str] = {}
self._delivered: "OrderedDict[str, bool]" = OrderedDict()
def register(self, user_uid: str, websocket: WebSocket) -> bool:
sockets = self._connections.setdefault(user_uid, set())
was_offline = len(sockets) == 0
sockets.add(websocket)
self._last_seen.pop(user_uid, None)
logger.info(
"messaging socket registered for %s (sockets=%d)", user_uid, len(sockets)
)
return was_offline
def unregister(self, user_uid: str, websocket: WebSocket) -> bool:
sockets = self._connections.get(user_uid)
if not sockets:
return False
sockets.discard(websocket)
if not sockets:
self._connections.pop(user_uid, None)
self._last_seen[user_uid] = datetime.now(timezone.utc).isoformat()
logger.info("messaging socket removed for %s (now offline)", user_uid)
return True
logger.debug(
"messaging socket removed for %s (sockets=%d)", user_uid, len(sockets)
)
return False
def is_online(self, user_uid: str) -> bool:
return bool(self._connections.get(user_uid))
def has_connections(self) -> bool:
return bool(self._connections)
def connected_user_uids(self) -> set[str]:
return set(self._connections.keys())
def mark_delivered(self, message_uid: str) -> None:
self._delivered[message_uid] = True
self._delivered.move_to_end(message_uid)
while len(self._delivered) > DELIVERED_CAP:
self._delivered.popitem(last=False)
def was_delivered(self, message_uid: str) -> bool:
return message_uid in self._delivered
def last_seen(self, user_uid: str) -> Optional[str]:
return self._last_seen.get(user_uid)
def sockets_for(self, user_uid: str) -> list[WebSocket]:
return list(self._connections.get(user_uid, ()))
async def send_to_user(self, user_uid: str, frame: dict[str, Any]) -> None:
for websocket in self.sockets_for(user_uid):
try:
await websocket.send_json(frame)
except Exception: # noqa: BLE001
logger.debug("dropping frame to stale socket for %s", user_uid)
async def send_to_users(
self, user_uids: list[str], frame: dict[str, Any]
) -> None:
for user_uid in dict.fromkeys(user_uids):
await self.send_to_user(user_uid, frame)
message_hub = ConnectionManager()
+154
View File
@@ -0,0 +1,154 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime, timezone
from typing import Any, Optional
from devplacepy.attachments import get_attachments, link_attachments
from devplacepy.database import get_table
from devplacepy.templating import clear_messages_cache
from devplacepy.utils import (
create_mention_notifications,
create_notification,
generate_uid,
time_ago,
)
from devplacepy.services.audit import record as audit
logger = logging.getLogger("messaging.persist")
MAX_CONTENT_LENGTH = 2000
def _slim_attachment(attachment: dict[str, Any]) -> dict[str, Any]:
return {
"uid": attachment["uid"],
"url": attachment["url"],
"thumbnail_url": attachment.get("thumbnail_url"),
"is_image": bool(attachment.get("is_image")),
"is_video": bool(attachment.get("is_video")),
"original_filename": attachment.get("original_filename", ""),
"file_size": attachment.get("file_size", 0),
"mime_type": attachment.get("mime_type", ""),
}
def message_frame(
message: dict[str, Any], sender_username: str, client_id: Optional[str] = None
) -> dict[str, Any]:
attachments = get_attachments("message", message["uid"])
return {
"type": "message",
"uid": message["uid"],
"sender_uid": message["sender_uid"],
"sender_username": sender_username,
"receiver_uid": message["receiver_uid"],
"content": message["content"],
"created_at": message["created_at"],
"time_ago": time_ago(message["created_at"]),
"client_id": client_id,
"attachments": [_slim_attachment(a) for a in attachments],
}
def persist_message(
sender: dict[str, Any],
receiver_uid: str,
content: str,
attachment_uids: Optional[list[str]] = None,
*,
request: Any = None,
origin: str = "web",
) -> Optional[dict[str, Any]]:
content = (content or "").strip()[:MAX_CONTENT_LENGTH]
attachment_uids = attachment_uids or []
if not content and not attachment_uids:
return None
receiver = get_table("users").find_one(uid=receiver_uid)
if not receiver:
return None
sender_uid = sender["uid"]
sender_username = sender.get("username", "")
messages_table = get_table("messages")
msg_uid = generate_uid()
created_at = datetime.now(timezone.utc).isoformat()
messages_table.insert(
{
"uid": msg_uid,
"sender_uid": sender_uid,
"receiver_uid": receiver_uid,
"content": content,
"read": False,
"created_at": created_at,
}
)
link_attachments(attachment_uids, "message", msg_uid)
if sender_uid != receiver_uid:
create_notification(
receiver_uid,
"message",
f"{sender_username} sent you a message",
sender_uid,
f"/messages?with_uid={sender_uid}",
)
clear_messages_cache(receiver_uid)
create_mention_notifications(
content, sender_uid, f"/messages?with_uid={receiver_uid}"
)
logger.info(
"Message %s sent from %s to %s via %s",
msg_uid,
sender_username,
receiver_uid,
origin,
)
logger.debug("message %s content length=%d", msg_uid, len(content))
summary = (
f"{sender_username} sent a message to "
f"{receiver.get('username') or receiver_uid}: {content}"
)
links = [
audit.target("message", msg_uid),
audit.recipient(receiver_uid, receiver.get("username")),
]
if request is not None:
audit.record(
request,
"message.send",
user=sender,
target_type="message",
target_uid=msg_uid,
summary=summary,
metadata={"origin": origin},
links=links,
)
else:
audit.record_system(
"message.send",
actor_kind="user",
actor_uid=sender_uid,
actor_username=sender_username,
actor_role="admin" if sender.get("role") == "Admin" else "member",
origin=origin,
target_type="message",
target_uid=msg_uid,
summary=summary,
metadata={"origin": origin},
links=links,
)
return {
"uid": msg_uid,
"sender_uid": sender_uid,
"receiver_uid": receiver_uid,
"content": content,
"read": False,
"created_at": created_at,
}
+89
View File
@@ -0,0 +1,89 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
from typing import Optional
from devplacepy.database import db, get_users_by_uids
from devplacepy.services.messaging.hub import message_hub
from devplacepy.services.messaging.persist import message_frame
logger = logging.getLogger("messaging.relay")
POLL_INTERVAL_SECONDS = 1.0
BATCH_LIMIT = 500
class MessageRelay:
def __init__(self) -> None:
self._task: Optional[asyncio.Task] = None
self._watermark: int = 0
self._primed: bool = False
def start(self) -> None:
if self._task is not None and not self._task.done():
return
self._task = asyncio.create_task(self._run())
logger.info("message relay started")
def _max_id(self) -> int:
if "messages" not in db.tables:
return 0
rows = list(db.query("SELECT MAX(id) AS max_id FROM messages"))
value = rows[0]["max_id"] if rows else None
return int(value or 0)
async def _run(self) -> None:
try:
if not self._primed:
self._watermark = self._max_id()
self._primed = True
logger.debug("message relay primed at watermark %d", self._watermark)
while message_hub.has_connections():
try:
await self._tick()
except Exception: # noqa: BLE001
logger.exception("message relay tick failed")
await asyncio.sleep(POLL_INTERVAL_SECONDS)
finally:
self._task = None
logger.info("message relay stopped (no active connections)")
async def _tick(self) -> None:
if "messages" not in db.tables:
return
rows = list(
db.query(
"SELECT * FROM messages WHERE id > :wm ORDER BY id ASC LIMIT :lim",
wm=self._watermark,
lim=BATCH_LIMIT,
)
)
if not rows:
return
connected = message_hub.connected_user_uids()
pending = [
row
for row in rows
if (row["sender_uid"] in connected or row["receiver_uid"] in connected)
and not message_hub.was_delivered(row["uid"])
]
sender_uids = {row["sender_uid"] for row in pending}
senders = get_users_by_uids(list(sender_uids)) if sender_uids else {}
for row in pending:
sender = senders.get(row["sender_uid"]) or {}
frame = message_frame(dict(row), sender.get("username", ""))
message_hub.mark_delivered(row["uid"])
await message_hub.send_to_users(
[row["sender_uid"], row["receiver_uid"]], frame
)
self._watermark = max(self._watermark, max(row["id"] for row in rows))
logger.debug(
"message relay delivered %d of %d new rows, watermark %d",
len(pending),
len(rows),
self._watermark,
)
message_relay = MessageRelay()
+85
View File
@@ -0,0 +1,85 @@
/* retoor <retoor@molodetz.nl> */
.bots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-top: 12px;
}
.bot-card {
margin: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card-hover);
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.2s ease, opacity 0.2s ease;
}
.bot-card.bot-active {
border-color: var(--success);
}
.bot-card.bot-idle {
opacity: 0.6;
}
.bot-shot {
position: relative;
aspect-ratio: 16 / 10;
background: var(--bg-input);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.bot-shot img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
}
.bot-shot-empty {
color: var(--text-muted);
font-size: 0.85rem;
font-family: var(--font-mono);
}
.bot-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
}
.bot-name {
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
}
.bot-persona {
font-size: 0.78rem;
color: var(--text-secondary);
}
.bot-action {
font-size: 0.78rem;
color: var(--text-muted);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 600px) {
.bots-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
}
+137
View File
@@ -132,3 +132,140 @@
.cm-body { grid-template-columns: 1fr; }
.ci-grid { grid-template-columns: 1fr; }
}
.admin-btn-primary {
background: var(--accent, var(--info));
color: var(--white);
border-color: transparent;
}
.admin-btn-danger {
background: var(--danger);
color: var(--white);
border-color: transparent;
}
.cm-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cm-form.cm-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
max-width: 720px;
}
.cm-form label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
color: var(--text-secondary);
position: relative;
}
.cm-form input[type="text"],
.cm-form input[type="number"],
.cm-form select,
.cm-form textarea {
width: 100%;
padding: 0.5rem 0.65rem;
background: var(--bg-input, var(--bg-card));
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
font: inherit;
}
.cm-form textarea {
font-family: var(--font-mono, monospace);
resize: vertical;
}
.cm-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.cm-check {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.cm-suggest {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
max-height: 200px;
overflow-y: auto;
}
.cm-suggest:empty {
display: none;
}
.cm-suggest li {
padding: 0.4rem 0.65rem;
cursor: pointer;
color: var(--text-primary);
}
.cm-suggest li:hover,
.cm-suggest li.cm-suggest-active {
background: var(--bg-hover, var(--border));
}
.cm-suggest li.cm-suggest-active {
outline: 2px solid var(--accent, var(--border));
outline-offset: -2px;
}
.cm-actions {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
@media (max-width: 768px) {
.cm-form-row { grid-template-columns: 1fr; }
}
.ci-events {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 320px;
overflow-y: auto;
}
.ci-event {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.ci-event-name {
font-weight: 600;
color: var(--text-primary);
}
.ci-event-time {
margin-left: auto;
color: var(--text-muted);
font-family: var(--font-mono, monospace);
}
+136
View File
@@ -141,6 +141,109 @@
font-weight: 600;
}
.messages-header-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.messages-presence {
font-size: 0.6875rem;
color: var(--text-muted);
}
.messages-presence.online {
color: var(--success, var(--accent));
}
.messages-presence.online::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success, var(--accent));
margin-right: 0.3125rem;
vertical-align: middle;
}
.conversation-unread-dot {
flex-shrink: 0;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
align-self: center;
}
.conversation-unread-dot[hidden] {
display: none;
}
.message-receipt {
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.85);
margin-left: 0.375rem;
letter-spacing: -2px;
}
.message-receipt[hidden] {
display: none;
}
.message-bubble.pending {
opacity: 0.6;
}
.attachment-pending {
margin-top: 0.35rem;
font-size: 0.8rem;
font-style: italic;
color: var(--text-secondary);
}
.typing-indicator {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: var(--bg-card-hover);
border-radius: var(--radius-lg);
border-bottom-left-radius: 4px;
}
.typing-indicator[hidden] {
display: none;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: typing-bounce 1.2s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing-bounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
.messages-thread {
flex: 1;
overflow-y: auto;
@@ -233,6 +336,39 @@
background: var(--accent-hover);
}
.messages-send-btn .send-spinner {
display: none;
width: 16px;
height: 16px;
border: 2px solid var(--white);
border-right-color: transparent;
border-radius: 50%;
animation: messages-send-spin 0.6s linear infinite;
}
.messages-send-btn.is-sending {
cursor: default;
opacity: 0.75;
}
.messages-send-btn.is-sending .send-icon {
display: none;
}
.messages-send-btn.is-sending .send-spinner {
display: block;
}
.messages-send-btn:disabled {
cursor: default;
}
@keyframes messages-send-spin {
to {
transform: rotate(360deg);
}
}
.messages-empty {
display: flex;
flex-direction: column;
+106
View File
@@ -0,0 +1,106 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class BotMonitor {
constructor(root) {
this.root = root;
this.endpoint = root.dataset.endpoint;
this.count = document.getElementById("bots-count");
this.pollMs = 2000;
}
init() {
this.poller = new Poller(() => this.refresh(), this.pollMs, {
immediate: false,
pauseHidden: true,
});
}
escape(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
async refresh() {
const payload = await Http.getJson(this.endpoint);
const frames = payload.frames || [];
if (this.count) {
this.count.textContent = `${frames.length} bots`;
}
if (!frames.length) {
this.root.innerHTML = `<p class="admin-empty">No bots running. Launch the fleet on <a href="/admin/services">Services</a>.</p>`;
return;
}
this.reconcile(frames);
}
reconcile(frames) {
const seen = new Set();
let previous = null;
for (const frame of frames) {
const slot = String(frame.slot);
seen.add(slot);
let card = this.root.querySelector(`.bot-card[data-slot="${this.escape(slot)}"]`);
if (card) {
this.update(card, frame);
} else {
card = document.createElement("div");
card.innerHTML = this.card(frame);
card = card.firstElementChild;
}
const anchor = previous ? previous.nextElementSibling : this.root.firstElementChild;
if (anchor !== card) {
this.root.insertBefore(card, anchor);
}
previous = card;
}
for (const card of [...this.root.querySelectorAll(".bot-card")]) {
if (!seen.has(card.dataset.slot)) {
card.remove();
}
}
}
update(card, frame) {
const active = frame.active ? "bot-active" : "bot-idle";
card.className = `bot-card ${active}`;
const shot = card.querySelector(".bot-shot");
const image = shot.querySelector("img");
if (frame.has_image) {
if (image) {
if (image.getAttribute("src") !== frame.frame_url) {
image.src = frame.frame_url;
}
} else {
shot.innerHTML = `<img src="${this.escape(frame.frame_url)}" alt="${this.escape(frame.label || `bot${frame.slot}`)} screenshot" loading="lazy">`;
}
} else if (!shot.querySelector(".bot-shot-empty")) {
shot.innerHTML = `<div class="bot-shot-empty">no frame yet</div>`;
}
card.querySelector(".bot-name").textContent = frame.label || `bot${frame.slot}`;
card.querySelector(".bot-persona").textContent = frame.persona || "-";
card.querySelector(".bot-action").textContent = frame.action || frame.status || "idle";
}
card(frame) {
const slot = this.escape(frame.slot);
const label = this.escape(frame.label || `bot${frame.slot}`);
const persona = this.escape(frame.persona || "-");
const action = this.escape(frame.action || frame.status || "idle");
const active = frame.active ? "bot-active" : "bot-idle";
const shot = frame.has_image
? `<img src="${this.escape(frame.frame_url)}" alt="${label} screenshot" loading="lazy">`
: `<div class="bot-shot-empty">no frame yet</div>`;
return `<figure class="bot-card ${active}" data-slot="${slot}">
<div class="bot-shot">${shot}</div>
<figcaption class="bot-meta">
<span class="bot-name">${label}</span>
<span class="bot-persona">${persona}</span>
<span class="bot-action">${action}</span>
</figcaption>
</figure>`;
}
}
+43
View File
@@ -0,0 +1,43 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { wireSearch, wireBootToggle } from "./ContainerFormControls.js";
export class ContainerEdit {
constructor(form) {
this.form = form;
this.uid = form ? form.dataset.uid : "";
}
init() {
if (!this.form) return;
wireSearch(this.form, "user", "/admin/containers/users/search", "run_as_uid", (r) => r.uid, (r) => r.username);
wireBootToggle(this.form);
this.form.addEventListener("submit", (e) => { e.preventDefault(); this.save(); });
}
toast(message, type) {
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
}
async save() {
const f = this.form;
const params = {
run_as_uid: f.elements.run_as_uid.value.trim(),
boot_language: f.elements.boot_language.value,
boot_script: f.elements.boot_script.value,
boot_command: f.elements.boot_command.value.trim(),
restart_policy: f.elements.restart_policy.value,
cpu_limit: f.elements.cpu_limit.value.trim(),
mem_limit: f.elements.mem_limit.value.trim(),
start_on_boot: f.elements.start_on_boot.checked ? "true" : "false",
};
try {
await Http.send(`/admin/containers/${this.uid}/edit`, params);
this.toast("Saved", "success");
window.location.assign(`/admin/containers/${this.uid}`);
} catch (err) {
return;
}
}
}
@@ -0,0 +1,150 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export function escapeHtml(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
export function wireBootToggle(form) {
const select = form.elements.boot_language;
const update = () => {
const script = form.querySelector('[data-boot="script"]');
if (script) script.style.display = select.value === "none" ? "none" : "";
};
select.addEventListener("change", update);
update();
}
const SEARCH_DEBOUNCE_MS = 200;
export function wireSearch(form, kind, url, hiddenName, valueOf, labelOf) {
const input = form.querySelector(`[data-search="${kind}"]`);
const list = form.querySelector(`[data-suggest="${kind}"]`);
const hidden = form.elements[hiddenName];
if (!input || !list || !hidden) return;
if (!list.id) list.id = `cm-suggest-${kind}`;
list.setAttribute("role", "listbox");
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-controls", list.id);
input.setAttribute("aria-expanded", "false");
let timer = null;
let active = -1;
const options = () => Array.from(list.querySelectorAll("li[data-value]"));
const close = () => {
list.innerHTML = "";
active = -1;
input.setAttribute("aria-expanded", "false");
input.removeAttribute("aria-activedescendant");
};
const setActive = (index) => {
const opts = options();
active = index;
opts.forEach((li, i) => {
const on = i === index;
li.classList.toggle("cm-suggest-active", on);
li.setAttribute("aria-selected", on ? "true" : "false");
if (on) {
input.setAttribute("aria-activedescendant", li.id);
li.scrollIntoView({ block: "nearest" });
}
});
if (index < 0) input.removeAttribute("aria-activedescendant");
};
const choose = (item) => {
if (!item) return;
hidden.value = item.dataset.value;
input.value = item.dataset.label;
close();
};
const render = (results) => {
list.innerHTML = results.map((r, i) =>
`<li id="${list.id}-opt-${i}" role="option" aria-selected="false" `
+ `data-value="${escapeHtml(valueOf(r))}" data-label="${escapeHtml(labelOf(r))}">`
+ `${escapeHtml(labelOf(r))}</li>`
).join("");
active = -1;
input.setAttribute("aria-expanded", results.length ? "true" : "false");
input.removeAttribute("aria-activedescendant");
};
input.addEventListener("input", () => {
hidden.value = "";
clearTimeout(timer);
const q = input.value.trim();
if (!q) { close(); return; }
timer = setTimeout(async () => {
try {
const data = await Http.getJson(`${url}?q=${encodeURIComponent(q)}`);
render(data.results || []);
} catch (err) {
close();
}
}, SEARCH_DEBOUNCE_MS);
});
input.addEventListener("keydown", (e) => {
const opts = options();
const count = opts.length;
if (!count) return;
const last = count - 1;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActive(active < last ? active + 1 : 0);
break;
case "ArrowUp":
e.preventDefault();
setActive(active > 0 ? active - 1 : last);
break;
case "Home":
e.preventDefault();
setActive(0);
break;
case "End":
e.preventDefault();
setActive(last);
break;
case "Enter":
e.preventDefault();
choose(active >= 0 ? opts[active] : opts[0]);
break;
case "Escape":
e.preventDefault();
close();
break;
case "Tab":
if (e.shiftKey) {
if (active > 0) { e.preventDefault(); setActive(active - 1); }
else close();
} else if (active < last) {
e.preventDefault();
setActive(active + 1);
} else {
choose(opts[active]);
}
break;
default:
break;
}
});
list.addEventListener("mousedown", (e) => e.preventDefault());
list.addEventListener("click", (e) => {
const item = e.target.closest("li[data-value]");
choose(item);
});
input.addEventListener("blur", () => {
window.setTimeout(close, 150);
});
}
+106 -8
View File
@@ -2,6 +2,7 @@
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
import { escapeHtml, wireSearch, wireBootToggle } from "./ContainerFormControls.js";
export class ContainerList {
constructor(root) {
@@ -12,20 +13,103 @@ export class ContainerList {
}
init() {
this.bindActions();
this.bindCreate();
this.poller = new Poller(() => this.refresh(), this.pollMs, { immediate: false });
}
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;
return escapeHtml(value);
}
bindActions() {
if (!this.body) return;
this.body.addEventListener("click", (e) => {
const btn = e.target.closest("[data-cm-action]");
if (!btn) return;
e.preventDefault();
const row = btn.closest(".cm-row");
if (!row) return;
this.runAction(btn.dataset.cmAction, row.dataset.uid, row.dataset.slug, row.dataset.name);
});
}
async runAction(action, uid, slug, name) {
if (action === "terminal") {
if (window.app && window.app.containerTerminals && slug) {
window.app.containerTerminals.open(slug, uid, name);
} else {
this.toast("Terminal needs a project slug", "error");
}
return;
}
if (action === "delete") {
const ok = window.app && window.app.dialog
? await window.app.dialog.confirm(`Delete container instance "${name}"? This cannot be undone.`)
: window.confirm(`Delete container instance "${name}"?`);
if (!ok) return;
}
try {
await Http.send(`/admin/containers/${uid}/${action}`, {});
this.toast(`Instance ${action} requested`, "success");
await this.refresh();
} catch (err) {
return;
}
}
bindCreate() {
this.form = document.getElementById("cm-admin-create-form");
if (!this.form) return;
wireSearch(this.form, "project", "/admin/containers/projects/search", "project_slug", (r) => r.slug, (r) => r.title);
wireSearch(this.form, "user", "/admin/containers/users/search", "run_as_uid", (r) => r.uid, (r) => r.username);
wireBootToggle(this.form);
this.form.addEventListener("submit", (e) => { e.preventDefault(); this.create(); });
}
async create() {
const f = this.form;
const slug = f.elements.project_slug.value.trim();
if (!slug) { this.toast("Pick a project first", "error"); return; }
const params = {
project_slug: slug,
name: f.elements.name.value.trim(),
run_as_uid: f.elements.run_as_uid.value.trim(),
boot_language: f.elements.boot_language.value,
boot_script: f.elements.boot_script.value,
boot_command: f.elements.boot_command.value.trim(),
restart_policy: f.elements.restart_policy.value,
ports: f.elements.ports.value.trim(),
env: f.elements.env.value,
cpu_limit: f.elements.cpu_limit.value.trim(),
mem_limit: f.elements.mem_limit.value.trim(),
ingress_slug: f.elements.ingress_slug.value.trim(),
ingress_port: f.elements.ingress_port.value.trim(),
start_on_boot: f.elements.start_on_boot.checked ? "true" : "false",
autostart: f.elements.autostart.checked ? "true" : "false",
};
try {
await Http.send("/admin/containers/create", params);
const modal = document.getElementById("cm-admin-create-modal");
if (modal) modal.classList.remove("visible");
f.reset();
wireBootToggle(this.form);
this.toast("Instance created", "success");
await this.refresh();
} catch (err) {
return;
}
}
async refresh() {
const instances = (await Http.getJson(this.endpoint)).instances || [];
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>`;
this.body.innerHTML = `<tr><td colspan="8" class="admin-empty">No container instances yet. Create one above or open a project's container manager.</td></tr>`;
return;
}
this.body.innerHTML = instances.map((inst) => this.row(inst)).join("");
@@ -33,19 +117,33 @@ export class ContainerList {
row(inst) {
const uid = this.escape(inst.uid);
const slug = this.escape(inst.project_slug || "");
const name = this.escape(inst.name);
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>`
? `<a class="cm-row-project" href="/projects/${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>
const boot = inst.start_on_boot
? `<span class="cm-badge cm-running">on</span>`
: `<span class="cm-muted">off</span>`;
return `<tr class="cm-row" data-uid="${uid}" data-slug="${slug}" data-name="${name}">
<td><a class="cm-row-name" href="/admin/containers/${uid}">${name}</a></td>
<td>${project}</td>
<td><span class="cm-badge cm-${this.escape(inst.status)}">${this.escape(inst.status)}</span></td>
<td>${this.escape(inst.boot_language || "none")}</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>
<td>${boot}</td>
<td class="cm-actions">
<button class="admin-btn admin-btn-sm" data-cm-action="start">Start</button>
<button class="admin-btn admin-btn-sm" data-cm-action="stop">Stop</button>
<button class="admin-btn admin-btn-sm" data-cm-action="restart">Restart</button>
<button class="admin-btn admin-btn-sm" data-cm-action="terminal">Terminal</button>
<a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}/edit">Edit</a>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-cm-action="delete">Delete</button>
</td>
</tr>`;
}
}
@@ -27,6 +27,7 @@ class CustomizationToggle {
Toast.flash(button, "Error", 1500);
} finally {
button.disabled = false;
button.classList.remove("is-loading");
}
});
}
+3
View File
@@ -53,6 +53,9 @@ export class FormManager {
initFormDisable() {
document.querySelectorAll("form").forEach((form) => {
if (form.hasAttribute("data-live-form")) {
return;
}
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
+43 -8
View File
@@ -6,6 +6,26 @@ export class Http {
window.location.href = `/auth/login?next=${next}`;
}
static notifyError(message) {
const text = (message && String(message).trim()) || "Something went wrong. Please try again.";
const app = window.app;
if (app && app.dialog && typeof app.dialog.alert === "function") {
app.dialog.alert({ title: "Error", message: text });
return;
}
if (app && app.toast && typeof app.toast.show === "function") {
app.toast.show(text, { type: "error", ms: 6000 });
return;
}
console.error(text);
}
static async _messageFrom(response) {
const data = await response.json().catch(() => ({}));
const message = (data && data.error && data.error.message) || `request failed with status ${response.status}`;
return { data, message };
}
static async getJson(url) {
const response = await fetch(url, { headers: { "Accept": "application/json" } });
if (!response.ok) {
@@ -14,10 +34,11 @@ export class Http {
return response.json();
}
static async sendForm(url, params = {}) {
static async sendForm(url, params = {}, options = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"X-Requested-With": "fetch",
"Content-Type": "application/x-www-form-urlencoded",
},
@@ -28,12 +49,14 @@ export class Http {
return new Promise(() => {});
}
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
const { message } = await Http._messageFrom(response);
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
return response.json();
return response.json().catch(() => ({}));
}
static async send(url, params = {}) {
static async send(url, params = {}, options = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
@@ -42,21 +65,33 @@ export class Http {
},
body: new URLSearchParams(params),
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return new Promise(() => {});
}
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
throw new Error((data.error && data.error.message) || `request failed: ${response.status}`);
const message = (data.error && data.error.message) || `request failed: ${response.status}`;
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
return data;
}
static async postJson(url, body) {
static async postJson(url, body, options = {}) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Accept": "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return new Promise(() => {});
}
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
const { message } = await Http._messageFrom(response);
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
return response.json();
}
+354 -1
View File
@@ -1,5 +1,11 @@
// retoor <retoor@molodetz.nl>
import { MessagesSocket } from "./MessagesSocket.js";
import { contentRenderer } from "./ContentRenderer.js";
const TYPING_THROTTLE_MS = 1500;
const TYPING_HIDE_MS = 4000;
export class MessagesLayout {
constructor() {
this.layout = document.querySelector(".messages-layout");
@@ -7,12 +13,359 @@ export class MessagesLayout {
return;
}
this.thread = document.querySelector(".messages-thread");
this.input = document.querySelector('.messages-input-area input[name="content"]');
this.form = document.querySelector(".messages-input-area");
this.input = this.form ? this.form.querySelector('input[name="content"]') : null;
this.upload = this.form ? this.form.querySelector("dp-upload") : null;
this.sendBtn = this.form ? this.form.querySelector(".messages-send-btn") : null;
this._uploading = false;
this._pendingSends = new Set();
this.presenceEl = document.getElementById("messages-presence");
this.typingEl = document.getElementById("typing-indicator");
this.selfUid = this.layout.dataset.selfUid || "";
this.otherUid = this.layout.dataset.otherUid || "";
this._lastTypingSent = 0;
this._typingHideTimer = null;
this._socketReady = false;
this.scrollThreadToEnd();
if (this.input) {
this.input.focus({ preventScroll: true });
}
this.initPresence();
this.connect();
this.bindForm();
this.bindTyping();
this.markRead();
}
connect() {
this.socket = new MessagesSocket({
onReady: () => this.onReady(),
onMessage: (frame) => this.onFrame(frame),
onClose: () => { this._socketReady = false; },
});
this.socket.connect();
}
onReady() {
this._socketReady = true;
if (this.otherUid) {
this.socket.send({ type: "presence", with_uid: this.otherUid });
this.markRead();
}
}
onFrame(frame) {
switch (frame.type) {
case "message":
this.handleIncoming(frame);
break;
case "typing":
if (frame.from_uid === this.otherUid) this.showTyping();
break;
case "read":
if (frame.by_uid === this.otherUid) this.markReceipts();
break;
case "presence":
if (frame.user_uid === this.otherUid) {
this.renderPresence(frame.online, frame.last_seen);
}
break;
default:
break;
}
}
bindForm() {
if (!this.form || !this.input) return;
this.form.addEventListener("submit", (event) => {
if (!this._socketReady || !this.socket.isOpen()) {
return;
}
event.preventDefault();
this.sendViaSocket();
});
if (this.upload) {
this.upload.addEventListener("dp-upload:busy", (event) => {
this._uploading = !!(event.detail && event.detail.busy);
this.refreshSendButton();
});
}
}
refreshSendButton() {
if (!this.sendBtn) return;
const busy = this._uploading || this._pendingSends.size > 0;
this.sendBtn.disabled = busy;
this.sendBtn.classList.toggle("is-sending", busy);
}
sendViaSocket() {
const content = (this.input.value || "").trim();
const attachmentUids = this.collectAttachments();
if (!content && attachmentUids.length === 0) return;
const clientId = "c" + Date.now() + Math.random().toString(36).slice(2, 8);
this.appendOptimistic(content, clientId, attachmentUids.length);
const ok = this.socket.send({
type: "send",
receiver_uid: this.otherUid,
content,
attachment_uids: attachmentUids,
client_id: clientId,
});
if (!ok) {
this.form.submit();
return;
}
this._pendingSends.add(clientId);
this.refreshSendButton();
window.setTimeout(() => this.clearPendingSend(clientId), 8000);
this.input.value = "";
if (this.upload && typeof this.upload.clear === "function") {
this.upload.clear();
}
this.input.focus({ preventScroll: true });
}
clearPendingSend(clientId) {
if (this._pendingSends.delete(clientId)) {
this.refreshSendButton();
}
}
collectAttachments() {
const hidden = this.form.querySelector('input[name="attachment_uids"]');
if (!hidden || !hidden.value) return [];
return hidden.value.split(",").map((v) => v.trim()).filter(Boolean);
}
bindTyping() {
if (!this.input) return;
this.input.addEventListener("input", () => {
if (!this._socketReady || !this.otherUid) return;
const now = Date.now();
if (now - this._lastTypingSent < TYPING_THROTTLE_MS) return;
this._lastTypingSent = now;
this.socket.send({ type: "typing", receiver_uid: this.otherUid });
});
}
appendOptimistic(content, clientId, attachmentCount) {
const bubble = this.buildBubble({
content,
mine: true,
clientId,
time: "now",
attachmentCount,
});
bubble.classList.add("pending");
this.insertBubble(bubble);
}
handleIncoming(frame) {
if (frame.sender_uid === this.selfUid && frame.client_id) {
this.clearPendingSend(frame.client_id);
const pending = this.thread.querySelector(`.message-bubble[data-client-id="${frame.client_id}"]`);
if (pending) {
pending.classList.remove("pending");
pending.dataset.msgUid = frame.uid;
const time = pending.querySelector(".message-time");
if (time) time.textContent = frame.time_ago;
const placeholder = pending.querySelector(".attachment-pending");
if (placeholder) placeholder.remove();
const gallery = this.renderAttachments(frame.attachments);
if (gallery) pending.insertBefore(gallery, time || null);
this.bumpConversation(frame, true);
return;
}
}
const partnerUid = frame.sender_uid === this.selfUid ? frame.receiver_uid : frame.sender_uid;
const inThisThread = this.otherUid && partnerUid === this.otherUid;
if (inThisThread) {
const mine = frame.sender_uid === this.selfUid;
const bubble = this.buildBubble({
content: frame.content,
mine,
time: frame.time_ago,
uid: frame.uid,
attachments: frame.attachments,
});
this.insertBubble(bubble);
if (!mine && this._socketReady) {
this.socket.send({ type: "read", with_uid: this.otherUid });
}
}
this.bumpConversation(frame, frame.sender_uid === this.selfUid);
}
buildBubble({ content, mine, time, uid, clientId, attachments, attachmentCount }) {
const bubble = document.createElement("div");
bubble.className = "message-bubble " + (mine ? "mine" : "theirs");
if (uid) bubble.dataset.msgUid = uid;
if (clientId) bubble.dataset.clientId = clientId;
const body = document.createElement("div");
body.className = "rendered-content";
body.setAttribute("data-render", "");
body.textContent = content || "";
bubble.appendChild(body);
const gallery = this.renderAttachments(attachments);
if (gallery) {
bubble.appendChild(gallery);
} else if (attachmentCount > 0) {
const placeholder = document.createElement("div");
placeholder.className = "attachment-pending";
placeholder.textContent = attachmentCount === 1
? "Uploading attachment..."
: `Uploading ${attachmentCount} attachments...`;
bubble.appendChild(placeholder);
}
const timeEl = document.createElement("span");
timeEl.className = "message-time";
timeEl.textContent = time || "";
bubble.appendChild(timeEl);
if (mine) {
const receipt = document.createElement("span");
receipt.className = "message-receipt";
receipt.hidden = true;
receipt.innerHTML = "&#x2713;&#x2713;";
bubble.appendChild(receipt);
}
this.renderBody(body);
return bubble;
}
renderBody(el) {
contentRenderer.applyTo(el);
el.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
img.dataset.lightbox = "";
});
contentRenderer.highlightAll();
}
renderAttachments(attachments) {
if (!attachments || !attachments.length) return null;
const gallery = document.createElement("div");
gallery.className = "attachment-gallery";
for (const att of attachments) {
const item = document.createElement("div");
item.className = "attachment-gallery-item";
if (att.is_image) {
const img = document.createElement("img");
img.src = att.thumbnail_url || att.url;
img.alt = att.original_filename || "";
img.loading = "lazy";
img.className = "gallery-thumb";
img.dataset.lightbox = "";
img.dataset.full = att.url;
if (att.mime_type) img.dataset.mime = att.mime_type;
item.appendChild(img);
} else if (att.is_video) {
const video = document.createElement("video");
video.src = att.url;
video.controls = true;
video.preload = "metadata";
video.className = "gallery-video";
item.appendChild(video);
} else {
const link = document.createElement("a");
link.href = att.url;
link.target = "_blank";
link.rel = "noopener";
link.className = "non-image";
link.download = att.original_filename || "file";
link.textContent = att.original_filename || "file";
item.appendChild(link);
}
gallery.appendChild(item);
}
return gallery;
}
insertBubble(bubble) {
if (!this.thread) return;
if (this.typingEl && this.typingEl.parentElement === this.thread) {
this.thread.insertBefore(bubble, this.typingEl);
} else {
this.thread.appendChild(bubble);
}
this.scrollThreadToEnd();
}
markReceipts() {
this.thread.querySelectorAll(".message-bubble.mine .message-receipt").forEach((el) => {
el.hidden = false;
});
}
markRead() {
if (this._socketReady && this.otherUid) {
this.socket.send({ type: "read", with_uid: this.otherUid });
}
}
showTyping() {
if (!this.typingEl) return;
this.typingEl.hidden = false;
this.scrollThreadToEnd();
clearTimeout(this._typingHideTimer);
this._typingHideTimer = setTimeout(() => {
this.typingEl.hidden = true;
}, TYPING_HIDE_MS);
}
initPresence() {
if (!this.presenceEl) return;
const online = this.layout.dataset.otherOnline === "1";
const lastSeen = this.layout.dataset.otherLastSeen || null;
this.renderPresence(online, lastSeen);
}
renderPresence(online, lastSeen) {
if (!this.presenceEl) return;
this.presenceEl.classList.toggle("online", !!online);
if (online) {
this.presenceEl.textContent = "online";
} else if (lastSeen) {
this.presenceEl.textContent = "last seen " + this.formatLastSeen(lastSeen);
} else {
this.presenceEl.textContent = "offline";
}
}
formatLastSeen(iso) {
const then = new Date(iso);
if (Number.isNaN(then.getTime())) return "recently";
const seconds = Math.max(0, Math.floor((Date.now() - then.getTime()) / 1000));
if (seconds < 60) return "just now";
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
return then.toLocaleDateString("en-GB");
}
bumpConversation(frame, mine) {
const partnerUid = mine ? frame.receiver_uid : frame.sender_uid;
const item = document.querySelector(`.conversation-item[data-conv-uid="${partnerUid}"]`);
if (!item) return;
const preview = item.querySelector(".conversation-preview");
if (preview) preview.textContent = frame.content.slice(0, 60);
const dot = item.querySelector(".conversation-unread-dot");
if (dot) {
dot.hidden = mine || partnerUid === this.otherUid;
}
const list = item.parentElement;
if (list && list.firstElementChild !== item) {
list.insertBefore(item, list.firstElementChild);
}
}
scrollThreadToEnd() {
+70
View File
@@ -0,0 +1,70 @@
// retoor <retoor@molodetz.nl>
const RECONNECT_DELAY_MS = 1500;
const WRONG_WORKER_RETRY_MS = 200;
const WRONG_WORKER_CODE = 4013;
export class MessagesSocket {
constructor(handlers) {
this.handlers = handlers || {};
this.ws = null;
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/messages/ws";
this._shouldRun = false;
this._settled = false;
}
connect() {
this._shouldRun = true;
this._open();
}
isOpen() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
_open() {
this._settled = false;
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => this._emit("onOpen"));
this.ws.addEventListener("close", (event) => {
const transient = !this._settled && event.code === WRONG_WORKER_CODE;
if (!transient) this._emit("onClose");
if (this._shouldRun) {
const delay = transient ? WRONG_WORKER_RETRY_MS : RECONNECT_DELAY_MS;
window.setTimeout(() => this._open(), delay);
}
});
this.ws.addEventListener("error", () => this._emit("onError"));
this.ws.addEventListener("message", (event) => {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (!this._settled) {
this._settled = true;
this._emit("onReady", payload);
}
this._emit("onMessage", payload);
});
}
send(message) {
if (this.isOpen()) {
this.ws.send(JSON.stringify(message));
return true;
}
return false;
}
close() {
this._shouldRun = false;
if (this.ws) this.ws.close();
}
_emit(name, payload) {
const handler = this.handlers[name];
if (typeof handler === "function") handler(payload);
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { Toast } from "./Toast.js";
export class OptimisticAction {
async submit(url, params, errorTarget, render) {
try {
const result = await Http.sendForm(url, params);
const result = await Http.sendForm(url, params, { silent: true });
if (render) render(result);
return result;
} catch (error) {
@@ -19,7 +19,7 @@ export class OptimisticAction {
async submitOptimistic(url, params, errorTarget, apply, revert, reconcile) {
apply();
try {
const result = await Http.sendForm(url, params);
const result = await Http.sendForm(url, params, { silent: true });
if (reconcile) reconcile(result);
return result;
} catch (error) {
@@ -16,8 +16,10 @@ export class AppUpload extends Component {
this.maxFiles = this.intAttr("max-files", 10);
this.allowedTypes = this.attr("allowed-types", "")
.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
this.showChips = !this.boolAttr("hide-chips");
this.extraFields = {};
this.items = [];
this.busyCount = 0;
this.build();
}
@@ -145,6 +147,7 @@ export class AppUpload extends Component {
async upload(file) {
this.button.classList.add("uploading");
this.setBusy(true);
const body = new FormData();
body.append(this.fieldName, file);
for (const [key, value] of Object.entries(this.extraFields)) {
@@ -172,10 +175,21 @@ export class AppUpload extends Component {
this.emit("error", { file, message: error.message });
} finally {
this.button.classList.remove("uploading");
this.setBusy(false);
this.updateCount();
}
}
setBusy(busy) {
this.busyCount = Math.max(0, this.busyCount + (busy ? 1 : -1));
const active = this.busyCount > 0;
if (active === this._lastBusy) {
return;
}
this._lastBusy = active;
this.emit("busy", { busy: active });
}
setFieldFiles(files) {
if (this.input.multiple) {
this.items.push(...files.map((file) => ({ file, name: file.name })));
@@ -209,6 +223,9 @@ export class AppUpload extends Component {
renderChips() {
this.chips.textContent = "";
if (!this.showChips) {
return;
}
this.items.forEach((item, index) => {
const chip = document.createElement("span");
chip.className = "dp-upload-chip";
+3
View File
@@ -26,6 +26,9 @@
<a href="/admin/containers" class="sidebar-link {% if admin_section == 'containers' %}active{% endif %}">
<span class="sidebar-icon">&#x1F4E6;</span> Containers
</a>
<a href="/admin/bots" class="sidebar-link {% if admin_section == 'bots' %}active{% endif %}">
<span class="sidebar-icon">&#x1F916;</span> Bot Monitor
</a>
<a href="/admin/ai-usage" class="sidebar-link {% if admin_section == 'ai-usage' %}active{% endif %}">
<span class="sidebar-icon">&#x1F4CA;</span> AI usage
</a>
+45
View File
@@ -0,0 +1,45 @@
{% extends "admin_base.html" %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="{{ static_url('/static/css/bots.css') }}">
{% endblock %}
{% block admin_content %}
<div class="admin-toolbar">
<h2>Bot Monitor</h2>
<span class="admin-count" id="bots-count">{{ frames|length }} bots</span>
</div>
{% if not enabled %}
<p class="admin-empty">The Bots service is not enabled. Enable it on <a href="/admin/services">Services</a> to see live screenshots.</p>
{% endif %}
<div class="bots-grid" id="bots-grid" data-endpoint="/admin/bots/data">
{% for frame in frames %}
<figure class="bot-card {% if frame['active'] %}bot-active{% else %}bot-idle{% endif %}" data-slot="{{ frame['slot'] }}">
<div class="bot-shot">
{% if frame['has_image'] %}
<img src="{{ frame['frame_url'] }}" alt="bot{{ frame['slot'] }} screenshot" loading="lazy">
{% else %}
<div class="bot-shot-empty">no frame yet</div>
{% endif %}
</div>
<figcaption class="bot-meta">
<span class="bot-name">{{ frame['label'] }}</span>
<span class="bot-persona">{{ frame['persona'] or '-' }}</span>
<span class="bot-action">{{ frame['action'] or frame['status'] or 'idle' }}</span>
</figcaption>
</figure>
{% else %}
<p class="admin-empty" id="bots-empty">No bots running. Launch the fleet on <a href="/admin/services">Services</a>.</p>
{% endfor %}
</div>
{% endblock %}
{% block extra_js %}
<script type="module">
import { BotMonitor } from "{{ static_url('/static/js/BotMonitor.js') }}";
const grid = document.getElementById("bots-grid");
const view = new BotMonitor(grid);
view.init();
window.app.botMonitor = view;
</script>
{% endblock %}
+80 -4
View File
@@ -1,4 +1,5 @@
{% extends "admin_base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="{{ static_url('/static/css/containers.css') }}">
@@ -7,6 +8,7 @@
<div class="admin-toolbar">
<h2>Containers</h2>
<span class="admin-count">{{ instances|length }} instances</span>
<a href="#" class="admin-btn admin-btn-primary" data-modal="cm-admin-create-modal">New instance</a>
</div>
<div class="admin-table-wrap">
@@ -16,14 +18,16 @@
<th>Instance</th>
<th>Project</th>
<th>Status</th>
<th>Boot</th>
<th>Ingress</th>
<th>Restart</th>
<th></th>
<th>Boot on start</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="cm-admin-rows">
{% for inst in instances %}
<tr class="cm-row" data-uid="{{ inst['uid'] }}">
<tr class="cm-row" data-uid="{{ inst['uid'] }}" data-slug="{{ inst['project_slug'] }}" data-name="{{ inst['name'] }}">
<td><a class="cm-row-name" href="/admin/containers/{{ inst['uid'] }}">{{ inst['name'] }}</a></td>
<td>
{% if inst['project_slug'] %}
@@ -31,20 +35,92 @@
{% else %}<span class="cm-muted">-</span>{% endif %}
</td>
<td><span class="cm-badge cm-{{ inst['status'] }}">{{ inst['status'] }}</span></td>
<td>{{ inst['boot_language'] or 'none' }}</td>
<td>
{% if inst['ingress_slug'] %}
<a href="/p/{{ inst['ingress_slug'] }}" target="_blank" rel="noopener">/p/{{ inst['ingress_slug'] }}</a>
{% else %}<span class="cm-muted">-</span>{% endif %}
</td>
<td>{{ inst['restart_policy'] or 'never' }}</td>
<td><a class="admin-btn admin-btn-sm" href="/admin/containers/{{ inst['uid'] }}">Manage &rarr;</a></td>
<td>{% if inst['start_on_boot'] %}<span class="cm-badge cm-running">on</span>{% else %}<span class="cm-muted">off</span>{% endif %}</td>
<td class="cm-actions">
<button class="admin-btn admin-btn-sm" data-cm-action="start">Start</button>
<button class="admin-btn admin-btn-sm" data-cm-action="stop">Stop</button>
<button class="admin-btn admin-btn-sm" data-cm-action="restart">Restart</button>
<button class="admin-btn admin-btn-sm" data-cm-action="terminal">Terminal</button>
<a class="admin-btn admin-btn-sm" href="/admin/containers/{{ inst['uid'] }}/edit">Edit</a>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-cm-action="delete">Delete</button>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="admin-empty">No container instances yet. Open a project's container manager to launch one.</td></tr>
<tr><td colspan="8" class="admin-empty">No container instances yet. Create one above or open a project's container manager.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% call modal('cm-admin-create-modal', 'New container instance', wide=true) %}
<form id="cm-admin-create-form" class="cm-form">
<label>Project
<input type="text" name="project_query" autocomplete="off" placeholder="Search projects by title" data-search="project">
<input type="hidden" name="project_slug">
<ul class="cm-suggest" data-suggest="project"></ul>
</label>
<label>Run as user (identity + API key only)
<input type="text" name="run_as_query" autocomplete="off" placeholder="Search a user (optional)" data-search="user">
<input type="hidden" name="run_as_uid">
<ul class="cm-suggest" data-suggest="user"></ul>
</label>
<label>Name
<input type="text" name="name" maxlength="64" required>
</label>
<label>Boot language
<select name="boot_language">
{% for lang in boot_languages %}
<option value="{{ lang }}">{{ lang }}</option>
{% endfor %}
</select>
</label>
<label data-boot="script">Boot script
<textarea name="boot_script" rows="8" spellcheck="false" placeholder="python or bash source run on launch"></textarea>
</label>
<label data-boot="command">Boot command (used when no boot script)
<input type="text" name="boot_command" maxlength="500" placeholder="e.g. python app.py">
</label>
<label>Restart policy
<select name="restart_policy">
{% for policy in restart_policies %}
<option value="{{ policy }}">{{ policy }}</option>
{% endfor %}
</select>
</label>
<label>Ports
<input type="text" name="ports" maxlength="500" placeholder="8899 or 20001:8080 per line">
</label>
<label>Environment
<textarea name="env" rows="3" spellcheck="false" placeholder="KEY=value per line"></textarea>
</label>
<div class="cm-form-row">
<label>CPU limit
<input type="text" name="cpu_limit" maxlength="16" placeholder="1 or 1.5">
</label>
<label>Memory limit
<input type="text" name="mem_limit" maxlength="16" placeholder="512m or 1g">
</label>
</div>
<label>Ingress slug
<input type="text" name="ingress_slug" maxlength="64" placeholder="optional public /p/<slug>">
</label>
<label>Ingress port
<input type="number" name="ingress_port" min="1" max="65535" placeholder="container port to publish">
</label>
<label class="cm-check"><input type="checkbox" name="start_on_boot"> Start on container service boot</label>
<label class="cm-check"><input type="checkbox" name="autostart" checked> Start immediately</label>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Create instance</button>
</div>
</form>
{% endcall %}
{% endblock %}
{% block extra_js %}
<script type="module">
+60
View File
@@ -0,0 +1,60 @@
{% extends "admin_base.html" %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="{{ static_url('/static/css/containers.css') }}">
{% endblock %}
{% block admin_content %}
<div class="admin-toolbar">
<h2>Edit {{ instance['name'] }}</h2>
<a class="admin-btn admin-btn-sm" href="/admin/containers/{{ instance['uid'] }}">&larr; Back to instance</a>
</div>
<form id="cm-edit-form" class="cm-form cm-card" data-uid="{{ instance['uid'] }}">
<label>Run as user (identity + API key only; container OS user is always pravda)
<input type="text" name="run_as_query" autocomplete="off" placeholder="Search a user (optional)" data-search="user"
value="{{ run_as_user['username'] if run_as_user else '' }}">
<input type="hidden" name="run_as_uid" value="{{ instance['run_as_uid'] or '' }}">
<ul class="cm-suggest" data-suggest="user"></ul>
</label>
<label>Boot language
<select name="boot_language">
{% for lang in boot_languages %}
<option value="{{ lang }}" {% if (instance['boot_language'] or 'none') == lang %}selected{% endif %}>{{ lang }}</option>
{% endfor %}
</select>
</label>
<label data-boot="script">Boot script
<textarea name="boot_script" rows="10" spellcheck="false">{{ instance['boot_script'] or '' }}</textarea>
</label>
<label data-boot="command">Boot command (used when no boot script)
<input type="text" name="boot_command" maxlength="500" value="{{ instance['boot_command'] or '' }}">
</label>
<label>Restart policy
<select name="restart_policy">
{% for policy in restart_policies %}
<option value="{{ policy }}" {% if (instance['restart_policy'] or 'never') == policy %}selected{% endif %}>{{ policy }}</option>
{% endfor %}
</select>
</label>
<div class="cm-form-row">
<label>CPU limit
<input type="text" name="cpu_limit" maxlength="16" value="{{ instance['cpu_limit'] or '' }}">
</label>
<label>Memory limit
<input type="text" name="mem_limit" maxlength="16" value="{{ instance['mem_limit'] or '' }}">
</label>
</div>
<label class="cm-check"><input type="checkbox" name="start_on_boot" {% if instance['start_on_boot'] %}checked{% endif %}> Start on container service boot</label>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script type="module">
import { ContainerEdit } from "{{ static_url('/static/js/ContainerEdit.js') }}";
const editor = new ContainerEdit(document.getElementById("cm-edit-form"));
editor.init();
window.app.containerEdit = editor;
</script>
{% endblock %}
@@ -28,9 +28,13 @@
<div class="ci-kv"><span>Command</span><code>{{ runtime['command'] }}</code></div>
<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>Boot language</span><code>{{ instance['boot_language'] or 'none' }}</code></div>
<div class="ci-kv"><span>Run as uid</span><code>{{ instance['run_as_uid'] or '(creator)' }}</code></div>
<div class="ci-kv"><span>Start on boot</span><code>{{ 'on' if instance['start_on_boot'] else 'off' }}</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>
<a class="btn btn-secondary btn-sm" href="/admin/containers/{{ instance['uid'] }}/edit">Edit configuration</a>
{% if instance['ingress_slug'] %}
<div class="ci-ingress" id="ci-ingress">
<span class="ci-ingress-label">Ingress</span>
@@ -82,6 +86,21 @@
</div>
</section>
<section class="card ci-card">
<h2>Status history</h2>
<ul class="ci-events" id="ci-events">
{% for ev in events %}
<li class="ci-event">
<span class="ci-event-name">{{ ev['event'] }}</span>
<span class="cm-muted">{{ ev['actor_kind'] }}</span>
<span class="ci-event-time">{{ ev['created_at'] }}</span>
</li>
{% else %}
<li class="cm-muted">No events yet.</li>
{% endfor %}
</ul>
</section>
<section class="card ci-card">
<h2>Logs</h2>
<pre class="ci-log" id="ci-log"></pre>
@@ -29,6 +29,7 @@ Set with the `mode` attribute:
| `allowed-types` | (none) | Comma list of permitted extensions, e.g. `.jpg,.png`. |
| `max-size` | `10` | Maximum size per file, in MB. |
| `max-files` | `10` | Maximum number of files. |
| `hide-chips` | off | Suppress the per-file name chips and keep only the `(N)` count badge on the button. |
| `endpoint` | `/uploads/upload` | Upload URL (attachment / direct). |
| `name` | `attachment_uids` | Hidden field name for collected UIDs (attachment mode). |
| `field-name` | `file` | File field name in upload requests / native field mode. |
@@ -45,7 +46,8 @@ Set with the `mode` attribute:
`dp-upload:uploaded` (per file, detail `{file, result}`), `dp-upload:error` (detail
`{file, message}`), `dp-upload:done` (direct mode, after a batch), `dp-upload:change`
(detail `{count}`).
(detail `{count}`), `dp-upload:busy` (detail `{busy}`, fired once when uploads start
and once when all in-flight uploads finish, for driving a host loader/disabled state).
## Usage
+14 -8
View File
@@ -6,7 +6,7 @@
{% block content %}
<h1 class="sr-only">Messages</h1>
<div class="page-messages">
<div class="messages-layout">
<div class="messages-layout" data-self-uid="{{ user['uid'] }}"{% if current_conversation %} data-other-uid="{{ current_conversation }}" data-other-online="{{ '1' if other_online else '0' }}"{% if other_last_seen %} data-other-last-seen="{{ other_last_seen }}"{% endif %}{% endif %}>
<div class="messages-list">
<div class="messages-list-header">
<input type="text" id="message-search" placeholder="Search conversations..." value="{{ search }}">
@@ -14,12 +14,13 @@
<div class="messages-conversations">
{% for conv in conversations %}
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}">
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}" data-conv-uid="{{ conv.other_user['uid'] }}">
<img src="{{ avatar_url('multiavatar', conv.other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ conv.other_user['username'] }}" loading="lazy">
<div class="conversation-info">
<div class="conversation-name">{{ conv.other_user['username'] }}</div>
<div class="conversation-preview">{{ conv.last_message[:60] }}</div>
</div>
<span class="conversation-unread-dot"{% if not conv.unread %} hidden{% endif %}></span>
<span class="conversation-time">{{ format_date(conv.last_message_at) }}</span>
</a>
{% else %}
@@ -33,12 +34,15 @@
<div class="messages-main-header">
<button type="button" class="messages-back-btn" id="messages-back-btn" aria-label="Back to conversations">&#x2190;</button>
{% set _user = other_user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<h3>{% set _user = other_user %}{% set _class = none %}{% include "_user_link.html" %}</h3>
<div class="messages-header-info">
<h3>{% set _user = other_user %}{% set _class = none %}{% include "_user_link.html" %}</h3>
<span class="messages-presence" id="messages-presence"></span>
</div>
</div>
<div class="messages-thread">
{% for msg in messages %}
<div class="message-bubble {% if msg.is_mine %}mine{% else %}theirs{% endif %}">
<div class="message-bubble {% if msg.is_mine %}mine{% else %}theirs{% endif %}" data-msg-uid="{{ msg.message['uid'] }}">
<div class="rendered-content" data-render>{{ msg.message['content'] }}</div>
{% if msg.attachments %}
{% with attachments=msg.attachments %}
@@ -46,18 +50,20 @@
{% endwith %}
{% endif %}
<span class="message-time">{{ msg.time_ago }}</span>
{% if msg.is_mine %}<span class="message-receipt"{% if not msg.message['read'] %} hidden{% endif %}>&#x2713;&#x2713;</span>{% endif %}
</div>
{% endfor %}
<div class="typing-indicator" id="typing-indicator" hidden><span></span><span></span><span></span></div>
</div>
<form class="messages-input-area" method="POST" action="/messages/send">
<form class="messages-input-area" method="POST" action="/messages/send" data-live-form>
<input type="hidden" name="receiver_uid" value="{{ other_user['uid'] }}">
<input type="text" name="content" placeholder="Type a message..." required maxlength="2000" autocomplete="off" data-mention>
<dp-upload multiple
<input type="text" name="content" placeholder="Type a message..." maxlength="2000" autocomplete="off" data-mention>
<dp-upload multiple hide-chips
max-size="{{ max_upload_size_mb() }}"
max-files="5"
allowed-types="{{ allowed_file_types() }}"></dp-upload>
<button type="submit" class="messages-send-btn">&#x27A4;</button>
<button type="submit" class="messages-send-btn" aria-label="Send"><span class="send-icon">&#x27A4;</span><span class="send-spinner" aria-hidden="true"></span></button>
</form>
{% else %}
<div class="messages-empty">
+2 -2
View File
@@ -3,7 +3,7 @@
from fastapi.templating import Jinja2Templates
from markupsafe import Markup
from devplacepy.cache import TTLCache
from devplacepy.config import STATIC_VERSION, TEMPLATES_DIR
from devplacepy.config import STATIC_VERSION, TEMPLATES_DIR, TEMPLATE_AUTO_RELOAD
from devplacepy.constants import TOPICS, REACTION_EMOJI
from devplacepy.database import get_int_setting, get_setting, get_table
from devplacepy.avatar import avatar_url
@@ -13,7 +13,7 @@ from devplacepy.attachments import format_file_size, file_icon_emoji
from devplacepy.content import is_owner as _owns
from devplacepy.customization import custom_css_tag, custom_js_tag, page_type_for
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates = Jinja2Templates(directory=str(TEMPLATES_DIR), auto_reload=TEMPLATE_AUTO_RELOAD)
def is_self(user, uid) -> bool:
+38 -6
View File
@@ -35,6 +35,14 @@ def verify_password(password: str, hashed: str) -> bool:
return pbkdf2_sha256.verify(password, hashed)
async def hash_password_async(password: str) -> str:
return await asyncio.to_thread(pbkdf2_sha256.hash, password)
async def verify_password_async(password: str, hashed: str) -> bool:
return await asyncio.to_thread(pbkdf2_sha256.verify, password, hashed)
def create_session(user_uid: str, max_age_seconds: int = SESSION_MAX_AGE) -> str:
token = secrets.token_hex(32)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=max_age_seconds)
@@ -252,6 +260,15 @@ def not_found(detail: str = "Not found") -> HTTPException:
def require_user_api(request: Request):
user = get_current_user(request)
if not user:
from devplacepy.services.audit import record as audit
audit.record(
request,
"security.authz.denied",
user=None,
result="denied",
summary=f"guest denied access to {request.url.path}",
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
)
@@ -357,16 +374,21 @@ async def _safe_notify(user_uid: str, payload: dict[str, str]) -> None:
def _schedule_push(user_uid: str, message: str, target_url: str | None) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
payload = {
"title": "DevPlace",
"message": message,
"icon": PUSH_ICON,
"url": target_url or DEFAULT_PUSH_URL,
}
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = background.loop
if loop is None:
logger.warning("no event loop available for push delivery to %s", user_uid)
return
asyncio.run_coroutine_threadsafe(_safe_notify(user_uid, payload), loop)
return
task = loop.create_task(_safe_notify(user_uid, payload))
_push_tasks.add(task)
task.add_done_callback(_push_tasks.discard)
@@ -461,7 +483,7 @@ def award_badge(user_uid: str, badge_name: str) -> bool:
return True
def register_account(username: str, email: str, password: str) -> tuple[str, str, bool]:
def _create_account(username: str, email: str, password_hash: str) -> tuple[str, str, bool]:
users = get_table("users")
uid = generate_uid()
is_first = users.count() == 0
@@ -472,7 +494,7 @@ def register_account(username: str, email: str, password: str) -> tuple[str, str
"username": username,
"email": email.strip().lower(),
"api_key": generate_uid(),
"password_hash": hash_password(password),
"password_hash": password_hash,
"bio": "",
"location": "",
"git_link": "",
@@ -489,6 +511,16 @@ def register_account(username: str, email: str, password: str) -> tuple[str, str
return uid, role, is_first
def register_account(username: str, email: str, password: str) -> tuple[str, str, bool]:
return _create_account(username, email, hash_password(password))
async def register_account_async(
username: str, email: str, password: str
) -> tuple[str, str, bool]:
return _create_account(username, email, await hash_password_async(password))
LEVEL_XP = 100
XP_POST = 10