forked from retoor/devplacepy
feat: add TTLCache for get_cache_version and TEMPLATE_AUTO_RELOAD config with Makefile worker count variables
This commit is contained in:
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
Executable
+2340
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 →</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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 = "✓✓";
|
||||
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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
<a href="/admin/containers" class="sidebar-link {% if admin_section == 'containers' %}active{% endif %}">
|
||||
<span class="sidebar-icon">📦</span> Containers
|
||||
</a>
|
||||
<a href="/admin/bots" class="sidebar-link {% if admin_section == 'bots' %}active{% endif %}">
|
||||
<span class="sidebar-icon">🤖</span> Bot Monitor
|
||||
</a>
|
||||
<a href="/admin/ai-usage" class="sidebar-link {% if admin_section == 'ai-usage' %}active{% endif %}">
|
||||
<span class="sidebar-icon">📊</span> AI usage
|
||||
</a>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 →</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">
|
||||
|
||||
@@ -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'] }}">← 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
|
||||
|
||||
|
||||
@@ -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">←</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 %}>✓✓</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">➤</button>
|
||||
<button type="submit" class="messages-send-btn" aria-label="Send"><span class="send-icon">➤</span><span class="send-spinner" aria-hidden="true"></span></button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="messages-empty">
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user