feat: add backup CLI commands, AI correction/modifier services, and timezone-aware date display
DevPlace CI / test (push) Failing after 25m18s

- Add `devplace backups` CLI subcommands (list, run, prune, clear) with job enqueueing and orphan cleanup
- Introduce `BACKUPS_DIR` and `BACKUP_STAGING_DIR` config paths for backup storage
- Implement `schedule_correction` and `schedule_modification` calls in content creation, comment creation, and comment editing flows
- Add `DEFAULT_CORRECTION_PROMPT` and `DEFAULT_MODIFIER_PROMPT` config constants for AI content processing
- Document timezone-aware date display using `local_dt`/`dt_ago` Jinja globals with client-side `Intl` localization
- Update README with AI content correction/modifier support in direct messages via `@ai` inline instructions
- Add `track_action(user["uid"], "vote")` call on upvote in `apply_vote`
This commit is contained in:
2026-06-16 03:32:19 +00:00
parent e59bc2d34e
commit 15bd4ad87c
115 changed files with 5839 additions and 187 deletions
+76
View File
@@ -284,6 +284,61 @@ def cmd_forks_clear(args):
print(f"Cleared {len(jobs)} fork job(s)")
def cmd_backups_list(args):
from devplacepy.services.backup import store
backups = store.list_backups()
if not backups:
print("No backups recorded")
return
for backup in backups:
size = store.human_bytes(int(backup.get("size_bytes") or 0))
print(
f"{backup['uid']} {backup.get('target', ''):<9} "
f"{backup.get('status', ''):<8} {size:>10} "
f"{backup.get('created_at', '')} {backup.get('filename', '')}"
)
def cmd_backups_run(args):
from devplacepy.services.backup import store
from devplacepy.services.jobs import queue
if not store.is_valid_target(args.target):
print(f"Unknown target '{args.target}'. Choose one of: {', '.join(store.BACKUP_TARGETS)}")
sys.exit(1)
job_uid = queue.enqueue(
"backup",
{"target": args.target, "schedule_uid": "", "created_by": "cli"},
owner_kind="system",
owner_id="cli",
preferred_name=f"{store.target_label(args.target)} (cli)",
)
store.create_backup(target=args.target, created_by="cli", job_uid=job_uid)
_audit_cli(
"cli.backups.run",
f"CLI enqueued {args.target} backup",
metadata={"target": args.target, "job_uid": job_uid},
)
print(f"Enqueued {args.target} backup job {job_uid} (processed by the running server)")
def cmd_backups_prune(args):
from devplacepy.services.backup import store
removed = store.prune_orphans()
_audit_cli("cli.backups.prune", f"CLI pruned {removed} orphan backups", metadata={"count": removed})
print(f"Pruned {removed} orphan backup record(s)")
def cmd_backups_clear(args):
from devplacepy.services.backup import store
removed = store.clear_all()
_audit_cli("cli.backups.clear", f"CLI cleared all backups ({removed})", metadata={"count": removed})
print(f"Cleared {removed} backup(s) and their archives")
def _remove_seo_artifacts(job):
import shutil
from devplacepy.config import SEO_REPORTS_DIR
@@ -753,6 +808,27 @@ def main():
)
forks_clear.set_defaults(func=cmd_forks_clear)
backups = sub.add_parser("backups", help="Backup management")
backups_sub = backups.add_subparsers(title="action", dest="action")
backups_sub.add_parser("list", help="List recorded backups").set_defaults(
func=cmd_backups_list
)
backups_run = backups_sub.add_parser(
"run", help="Enqueue a backup (processed by the running server)"
)
backups_run.add_argument(
"target",
choices=["database", "uploads", "keys", "full"],
help="What to back up",
)
backups_run.set_defaults(func=cmd_backups_run)
backups_sub.add_parser(
"prune", help="Remove backup records whose archive file is missing"
).set_defaults(func=cmd_backups_prune)
backups_sub.add_parser(
"clear", help="Delete every backup archive and record"
).set_defaults(func=cmd_backups_clear)
seo = sub.add_parser("seo", help="SEO Diagnostics job management")
seo_sub = seo.add_subparsers(title="action", dest="action")
seo_prune = seo_sub.add_parser(
+8
View File
@@ -24,6 +24,8 @@ CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
ZIPS_DIR = DATA_DIR / "zips"
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
BACKUPS_DIR = DATA_DIR / "backups"
BACKUP_STAGING_DIR = DATA_DIR / "backup_staging"
SEO_REPORTS_DIR = DATA_DIR / "seo_reports"
PLANNING_REPORTS_DIR = DATA_DIR / "planning_reports"
DBAPI_DIR = DATA_DIR / "dbapi"
@@ -72,6 +74,10 @@ INTERNAL_GATEWAY_URL = f"{INTERNAL_BASE_URL}/openai/v1/chat/completions"
INTERNAL_EMBED_URL = f"{INTERNAL_BASE_URL}/openai/v1/embeddings"
INTERNAL_MODEL = "molodetz"
INTERNAL_EMBED_MODEL = "molodetz~embed"
DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing"
DEFAULT_MODIFIER_PROMPT = (
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"
)
SERVICE_LOCK_FILE = LOCKS_DIR / "devplace-services.lock"
INIT_LOCK_FILE = LOCKS_DIR / "devplace-init.lock"
@@ -103,6 +109,8 @@ DATA_PATHS: dict[str, Path] = {
"zips": ZIPS_DIR,
"zip_staging": ZIP_STAGING_DIR,
"fork_staging": FORK_STAGING_DIR,
"backups": BACKUPS_DIR,
"backup_staging": BACKUP_STAGING_DIR,
"seo_reports": SEO_REPORTS_DIR,
"planning_reports": PLANNING_REPORTS_DIR,
"dbapi": DBAPI_DIR,
+16
View File
@@ -33,6 +33,7 @@ from devplacepy.utils import (
generate_uid,
make_combined_slug,
award_rewards,
track_action,
create_notification,
create_mention_notifications,
is_admin,
@@ -40,6 +41,8 @@ from devplacepy.utils import (
XP_UPVOTE,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "status")
@@ -131,6 +134,8 @@ def create_content_item(
metadata=metadata or None,
links=links,
)
schedule_correction(user, table_name, uid, request)
schedule_modification(user, table_name, uid, request)
return uid, slug
@@ -220,6 +225,9 @@ def apply_vote(request, user: dict, target_type: str, target_uid: str, value: in
)
award_rewards(owner_uid, XP_UPVOTE)
if did_upvote:
track_action(user["uid"], "vote")
current = votes.find_one(
user_uid=user["uid"],
target_uid=target_uid,
@@ -289,6 +297,8 @@ def create_comment_record(
)
create_mention_notifications(content, user["uid"], comment_url)
schedule_correction(user, "comments", comment_uid, request)
schedule_modification(user, "comments", comment_uid, request)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
comment_links = [
audit.target("comment", comment_uid),
@@ -315,6 +325,8 @@ def edit_comment_record(request, user: dict, comment: dict, content: str) -> str
get_table("comments").update(
{"uid": comment["uid"], "content": content, "updated_at": updated_at}, ["uid"]
)
schedule_correction(user, "comments", comment["uid"], request)
schedule_modification(user, "comments", comment["uid"], request)
logger.info(f"Comment {comment['uid']} edited by {user['username']}")
audit.record(
request,
@@ -406,6 +418,8 @@ def set_bookmark(
summary=f"{user['username']} {'bookmarked' if saved else 'removed bookmark from'} {target_type} {target_uid}",
links=[audit.target(target_type, target_uid)],
)
if saved:
track_action(user["uid"], "bookmark")
return saved
@@ -471,6 +485,8 @@ def edit_content_item(
"updated_at": datetime.now(timezone.utc).isoformat(),
}
table.update({"uid": item["uid"], **update_fields}, ["uid"])
schedule_correction(user, table_name, item["uid"], request)
schedule_modification(user, table_name, item["uid"], request)
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
label = update_fields.get("title") or item.get("title") or item["uid"]
audit.record(
+270 -1
View File
@@ -7,7 +7,13 @@ from sqlalchemy import or_
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import DATABASE_URL, INTERNAL_GATEWAY_URL, ensure_data_dirs
from devplacepy.config import (
DATABASE_URL,
DEFAULT_CORRECTION_PROMPT,
DEFAULT_MODIFIER_PROMPT,
INTERNAL_GATEWAY_URL,
ensure_data_dirs,
)
logger = logging.getLogger(__name__)
@@ -117,6 +123,159 @@ def _index(db, table, name, columns):
logger.warning(f"Could not create index {name} on {table}: {e}")
def _add_usage(usage_table: str, user_uid: str, totals: dict) -> None:
if not user_uid or int(totals.get("calls") or 0) <= 0 or usage_table not in db.tables:
return
with db:
db.query(
f"INSERT INTO {usage_table} "
"(user_uid, calls, prompt_tokens, completion_tokens, total_tokens, cost_usd, "
"upstream_latency_ms, total_latency_ms, updated_at) "
"VALUES (:user_uid, :calls, :prompt, :completion, :total, :cost, :ulat, :tlat, :now) "
"ON CONFLICT(user_uid) DO UPDATE SET "
"calls = calls + excluded.calls, "
"prompt_tokens = prompt_tokens + excluded.prompt_tokens, "
"completion_tokens = completion_tokens + excluded.completion_tokens, "
"total_tokens = total_tokens + excluded.total_tokens, "
"cost_usd = cost_usd + excluded.cost_usd, "
"upstream_latency_ms = upstream_latency_ms + excluded.upstream_latency_ms, "
"total_latency_ms = total_latency_ms + excluded.total_latency_ms, "
"updated_at = excluded.updated_at",
user_uid=user_uid,
calls=int(totals.get("calls") or 0),
prompt=int(totals.get("prompt_tokens") or 0),
completion=int(totals.get("completion_tokens") or 0),
total=int(totals.get("total_tokens") or 0),
cost=float(totals.get("cost_usd") or 0.0),
ulat=float(totals.get("upstream_latency_ms") or 0.0),
tlat=float(totals.get("total_latency_ms") or 0.0),
now=datetime.now(timezone.utc).isoformat(),
)
def _get_usage(usage_table: str, user_uid: str) -> dict:
empty = {
"calls": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cost_usd": 0.0,
"upstream_latency_ms": 0.0,
"total_latency_ms": 0.0,
"avg_tokens": 0.0,
"avg_upstream_latency_ms": 0.0,
"avg_total_latency_ms": 0.0,
"avg_tokens_per_second": 0.0,
"avg_cost_usd": 0.0,
"updated_at": None,
}
if not user_uid or usage_table not in db.tables:
return empty
row = get_table(usage_table).find_one(user_uid=user_uid)
if not row:
return empty
calls = int(row.get("calls") or 0)
completion = int(row.get("completion_tokens") or 0)
total_tokens = int(row.get("total_tokens") or 0)
cost = float(row.get("cost_usd") or 0.0)
upstream_ms = float(row.get("upstream_latency_ms") or 0.0)
total_ms = float(row.get("total_latency_ms") or 0.0)
return {
"calls": calls,
"prompt_tokens": int(row.get("prompt_tokens") or 0),
"completion_tokens": completion,
"total_tokens": total_tokens,
"cost_usd": cost,
"upstream_latency_ms": upstream_ms,
"total_latency_ms": total_ms,
"avg_tokens": round(total_tokens / calls, 1) if calls else 0.0,
"avg_upstream_latency_ms": round(upstream_ms / calls, 1) if calls else 0.0,
"avg_total_latency_ms": round(total_ms / calls, 1) if calls else 0.0,
"avg_tokens_per_second": round(completion / (upstream_ms / 1000.0), 1)
if upstream_ms > 0
else 0.0,
"avg_cost_usd": (cost / calls) if calls else 0.0,
"updated_at": row.get("updated_at"),
}
def add_correction_usage(user_uid: str, totals: dict) -> None:
_add_usage("correction_usage", user_uid, totals)
def get_correction_usage(user_uid: str) -> dict:
return _get_usage("correction_usage", user_uid)
def add_modifier_usage(user_uid: str, totals: dict) -> None:
_add_usage("modifier_usage", user_uid, totals)
def get_modifier_usage(user_uid: str) -> dict:
return _get_usage("modifier_usage", user_uid)
def record_activity(user_uid: str, action: str) -> int:
if not user_uid or not action or "user_activity" not in db.tables:
return 0
now = datetime.now(timezone.utc).isoformat()
with db:
db.query(
"INSERT INTO user_activity (user_uid, action, count, first_at, last_at) "
"VALUES (:u, :a, 1, :now, :now) "
"ON CONFLICT(user_uid, action) DO UPDATE SET "
"count = count + 1, last_at = excluded.last_at",
u=user_uid,
a=action,
now=now,
)
rows = list(
db.query(
"SELECT count FROM user_activity WHERE user_uid = :u AND action = :a",
u=user_uid,
a=action,
)
)
return int(rows[0]["count"]) if rows else 0
def record_unique_activity(user_uid: str, action: str, target: str) -> int | None:
if not user_uid or not action or "user_activity_seen" not in db.tables:
return None
now = datetime.now(timezone.utc).isoformat()
with db:
db.query(
"INSERT OR IGNORE INTO user_activity_seen "
"(user_uid, action, target, created_at) VALUES (:u, :a, :t, :now)",
u=user_uid,
a=action,
t=str(target),
now=now,
)
changed = list(db.query("SELECT changes() AS c"))
if not changed or not changed[0]["c"]:
return None
rows = list(
db.query(
"SELECT COUNT(*) AS c FROM user_activity_seen "
"WHERE user_uid = :u AND action = :a",
u=user_uid,
a=action,
)
)
return int(rows[0]["c"]) if rows else 0
def get_user_activity(user_uid: str) -> dict:
if not user_uid or "user_activity" not in db.tables:
return {}
rows = db.query(
"SELECT action, count FROM user_activity WHERE user_uid = :u",
u=user_uid,
)
return {row["action"]: int(row["count"]) for row in rows}
SOFT_DELETE_TABLES = [
"posts",
"comments",
@@ -136,6 +295,7 @@ SOFT_DELETE_TABLES = [
"sessions",
"instances",
"instance_schedules",
"backup_schedules",
"devii_conversations",
"devii_tasks",
"devii_lessons",
@@ -486,6 +646,10 @@ def init_db():
from devplacepy.services.audit import store as audit_store
audit_store.ensure_tables()
from devplacepy.services.backup import store as backup_store
backup_store.ensure_tables()
_index(db, "audit_log", "idx_audit_created_at", ["created_at"])
_index(db, "audit_log", "idx_audit_event_key", ["event_key"])
_index(db, "audit_log", "idx_audit_category", ["category"])
@@ -520,6 +684,89 @@ def init_db():
["user_uid", "notification_type"],
)
correction_usage = get_table("correction_usage")
for column, example in (
("user_uid", ""),
("calls", 0),
("prompt_tokens", 0),
("completion_tokens", 0),
("total_tokens", 0),
("cost_usd", 0.0),
("upstream_latency_ms", 0.0),
("total_latency_ms", 0.0),
("updated_at", ""),
):
if not correction_usage.has_column(column):
correction_usage.create_column_by_example(column, example)
try:
if "correction_usage" in db.tables:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_correction_usage_user "
"ON correction_usage (user_uid)"
)
except Exception as e:
logger.warning(f"Could not create unique index on correction_usage: {e}")
modifier_usage = get_table("modifier_usage")
for column, example in (
("user_uid", ""),
("calls", 0),
("prompt_tokens", 0),
("completion_tokens", 0),
("total_tokens", 0),
("cost_usd", 0.0),
("upstream_latency_ms", 0.0),
("total_latency_ms", 0.0),
("updated_at", ""),
):
if not modifier_usage.has_column(column):
modifier_usage.create_column_by_example(column, example)
try:
if "modifier_usage" in db.tables:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_modifier_usage_user "
"ON modifier_usage (user_uid)"
)
except Exception as e:
logger.warning(f"Could not create unique index on modifier_usage: {e}")
user_activity = get_table("user_activity")
for column, example in (
("user_uid", ""),
("action", ""),
("count", 0),
("first_at", ""),
("last_at", ""),
):
if not user_activity.has_column(column):
user_activity.create_column_by_example(column, example)
try:
if "user_activity" in db.tables:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_activity_user_action "
"ON user_activity (user_uid, action)"
)
except Exception as e:
logger.warning(f"Could not create unique index on user_activity: {e}")
user_activity_seen = get_table("user_activity_seen")
for column, example in (
("user_uid", ""),
("action", ""),
("target", ""),
("created_at", ""),
):
if not user_activity_seen.has_column(column):
user_activity_seen.create_column_by_example(column, example)
try:
if "user_activity_seen" in db.tables:
db.query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_activity_seen_unique "
"ON user_activity_seen (user_uid, action, target)"
)
except Exception as e:
logger.warning(f"Could not create unique index on user_activity_seen: {e}")
deepsearch_sessions = get_table("deepsearch_sessions")
for column, example in (
("uid", ""),
@@ -719,6 +966,28 @@ def backfill_api_keys() -> int:
users.create_column_by_example("cust_disable_global", 0)
if not users.has_column("cust_disable_pagetype"):
users.create_column_by_example("cust_disable_pagetype", 0)
if not users.has_column("ai_correction_enabled"):
users.create_column_by_example("ai_correction_enabled", 0)
if not users.has_column("ai_correction_prompt"):
users.create_column_by_example("ai_correction_prompt", DEFAULT_CORRECTION_PROMPT)
if not users.has_column("ai_correction_sync"):
users.create_column_by_example("ai_correction_sync", 0)
if not users.has_column("ai_modifier_enabled"):
users.create_column_by_example("ai_modifier_enabled", 1)
if not users.has_column("ai_modifier_sync"):
users.create_column_by_example("ai_modifier_sync", 1)
if not users.has_column("ai_modifier_prompt"):
users.create_column_by_example("ai_modifier_prompt", DEFAULT_MODIFIER_PROMPT)
with db:
db.query(
"UPDATE users SET ai_modifier_enabled = 1 WHERE ai_modifier_enabled IS NULL"
)
db.query("UPDATE users SET ai_modifier_sync = 1 WHERE ai_modifier_sync IS NULL")
db.query(
"UPDATE users SET ai_modifier_prompt = :prompt "
"WHERE ai_modifier_prompt IS NULL OR ai_modifier_prompt = ''",
prompt=DEFAULT_MODIFIER_PROMPT,
)
import uuid_utils
updated = 0
+300 -2
View File
@@ -1686,6 +1686,114 @@ four ways to sign requests.
),
],
),
endpoint(
id="profile-ai-correction",
method="POST",
path="/profile/{username}/ai-correction",
title="Configure AI content correction",
summary="Enable or disable automatic AI rewriting of your prose and set the correction instruction. Opt-in, default off. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"enabled",
"form",
"boolean",
False,
"true",
"true to enable background AI correction, false to disable it.",
),
field(
"sync",
"form",
"boolean",
False,
"false",
"true to apply the correction synchronously (the save waits), false for background.",
),
field(
"prompt",
"form",
"textarea",
False,
"Leave literary as is, only do punctuation and casing",
"Correction instruction, up to 2000 characters.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/bob_test",
"data": {
"url": "/profile/bob_test",
"enabled": True,
"sync": False,
"prompt": "Leave literary as is, only do punctuation and casing",
},
},
),
endpoint(
id="profile-ai-modifier",
method="POST",
path="/profile/{username}/ai-modifier",
title="Configure the AI modifier",
summary="Enable or disable the inline '@ai <instruction>' modifier on your prose and set its prompt. Enabled by default, applied synchronously by default. Admins may target any user.",
auth="user",
encoding="form",
destructive=True,
params=[
field(
"username",
"path",
"string",
True,
"bob_test",
"Profile owner. Must be yourself unless you are an admin.",
),
field(
"enabled",
"form",
"boolean",
False,
"true",
"true to enable the AI modifier, false to disable it.",
),
field(
"sync",
"form",
"boolean",
False,
"true",
"true to apply the modification synchronously (the save waits), false for background.",
),
field(
"prompt",
"form",
"textarea",
False,
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
"Modifier instruction, up to 2000 characters.",
),
],
sample_response={
"ok": True,
"redirect": "/profile/bob_test",
"data": {
"url": "/profile/bob_test",
"enabled": True,
"sync": True,
"prompt": "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
},
},
),
endpoint(
id="profile-regenerate-key",
method="POST",
@@ -3879,6 +3987,40 @@ The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`.
generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's
Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision.
## Per-call cost and usage headers
Every gateway response - chat, embeddings, and passthrough, on both success and error - carries
`X-Gateway-*` response headers describing that single call, so a client can read its own token usage
and dollar cost directly from the response with no extra request:
| Header | Meaning |
|--------|---------|
| `X-Gateway-Model` | Upstream model actually used for the call |
| `X-Gateway-Backend` | Backend that served it: `chat`, `embed`, or passthrough |
| `X-Gateway-Prompt-Tokens` | Input (prompt) tokens |
| `X-Gateway-Completion-Tokens` | Output (completion) tokens |
| `X-Gateway-Total-Tokens` | Total tokens (prompt + completion) |
| `X-Gateway-Cache-Hit-Tokens` | Prompt tokens served from the upstream prompt cache |
| `X-Gateway-Cache-Miss-Tokens` | Prompt tokens not served from cache |
| `X-Gateway-Reasoning-Tokens` | Reasoning tokens, when the model reports them |
| `X-Gateway-Cost-USD` | Total cost of the call in US dollars |
| `X-Gateway-Input-Cost-USD` | Input portion of the cost in US dollars |
| `X-Gateway-Output-Cost-USD` | Output portion of the cost in US dollars |
| `X-Gateway-Cost-Native` | `1` if the dollar cost is the upstream's own reported cost, `0` if computed from the configured per-million pricing |
| `X-Gateway-Tokens-Per-Second` | Output tokens per second for the call |
| `X-Gateway-Upstream-Latency-Ms` | Upstream round-trip latency in milliseconds |
| `X-Gateway-Total-Latency-Ms` | Full end-to-end gateway time for the call in milliseconds |
| `X-Gateway-Gateway-Overhead-Ms` | Gateway processing time minus the upstream and queue wait, in milliseconds |
| `X-Gateway-Queue-Wait-Ms` | Time spent waiting on the concurrency semaphore before dispatch, in milliseconds |
| `X-Gateway-Connect-Ms` | Upstream connection establishment time in milliseconds |
| `X-Gateway-Context-Window` | The model's context window in tokens, when known |
| `X-Gateway-Context-Utilization` | Total tokens as a fraction of the context window, when known |
Dollar costs use the upstream's native `cost` field when it returns one
(`X-Gateway-Cost-Native: 1`); otherwise they are computed from the per-million input and output
prices configured on the `openai` service. The one denied path that makes no upstream call
(embeddings disabled) returns no usage headers.
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
(the `openai` service).
@@ -3921,7 +4063,10 @@ for signing DevPlace's own requests.
"Set true for a streamed SSE response.",
),
],
notes=["Returns `503` when the gateway service is not running."],
notes=[
"Returns `503` when the gateway service is not running.",
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above), including the streamed SSE response.",
],
),
endpoint(
id="gateway-embeddings",
@@ -3958,7 +4103,8 @@ for signing DevPlace's own requests.
),
],
notes=[
"Returns `503` when the gateway service is not running or embeddings are disabled."
"Returns `503` when the gateway service is not running or embeddings are disabled.",
"Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).",
],
),
endpoint(
@@ -4564,6 +4710,158 @@ four ways to sign requests.
)
],
),
endpoint(
id="admin-backups",
method="GET",
path="/admin/backups",
title="Backups dashboard",
summary=(
"Storage usage, every backup archive, and every backup schedule. Returns HTML "
"(or JSON with Accept: application/json)."
),
auth="admin",
interactive=True,
),
endpoint(
id="admin-backups-data",
method="GET",
path="/admin/backups/data",
title="Backups data",
summary=(
"JSON dashboard payload: per-path storage usage, total data and backup size, "
"disk usage, the backup list, and the schedule list."
),
auth="admin",
),
endpoint(
id="admin-backups-run",
method="POST",
path="/admin/backups/run",
title="Create backup",
summary=(
"Enqueue an async backup job for a target (database, uploads, keys, full). "
"Returns the job uid and a status_url."
),
auth="admin",
params=[
field(
"target",
"form",
"string",
True,
"full",
"One of database, uploads, keys, full.",
)
],
sample_response={
"ok": True,
"uid": "JOB_UID",
"backup_uid": "BACKUP_UID",
"status_url": "/admin/backups/jobs/JOB_UID",
},
),
endpoint(
id="admin-backups-job",
method="GET",
path="/admin/backups/jobs/{uid}",
title="Backup job status",
summary="Status of one backup job (pending, running, done, failed) with archive stats.",
auth="admin",
params=[field("uid", "path", "string", True, "", "Backup job uid.")],
sample_response={
"uid": "JOB_UID",
"kind": "backup",
"status": "done",
"target": "full",
"backup_uid": "BACKUP_UID",
"download_url": "/admin/backups/BACKUP_UID/download",
"bytes_out": 10485760,
"file_count": 1240,
"sha256": "",
},
),
endpoint(
id="admin-backups-download",
method="GET",
path="/admin/backups/{uid}/download",
title="Download backup",
summary="Stream a completed backup archive as a tar.gz file.",
auth="admin",
encoding="none",
params=[field("uid", "path", "string", True, "", "Backup uid.")],
),
endpoint(
id="admin-backups-delete",
method="POST",
path="/admin/backups/{uid}/delete",
title="Delete backup",
summary="Permanently delete a backup archive and reclaim its disk space.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "", "Backup uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-create",
method="POST",
path="/admin/backups/schedules/create",
title="Create backup schedule",
summary=(
"Create a recurring backup. kind=interval uses every_seconds; kind=cron uses a "
"5-field cron expression. keep_last rotates older backups of the schedule."
),
auth="admin",
params=[
field("name", "form", "string", True, "Nightly full", "Schedule name."),
field("target", "form", "string", True, "full", "Backup target."),
field("kind", "form", "string", True, "interval", "interval or cron."),
field("every_seconds", "form", "int", False, "86400", "Seconds between runs (kind=interval)."),
field("cron", "form", "string", False, "0 3 * * *", "Cron expression (kind=cron)."),
field("keep_last", "form", "int", False, "7", "Keep only the newest N (0 = all)."),
],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-edit",
method="POST",
path="/admin/backups/schedules/{uid}/edit",
title="Edit backup schedule",
summary="Update a backup schedule's target, trigger, and retention.",
auth="admin",
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-toggle",
method="POST",
path="/admin/backups/schedules/{uid}/toggle",
title="Toggle backup schedule",
summary="Enable or disable a backup schedule.",
auth="admin",
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-run",
method="POST",
path="/admin/backups/schedules/{uid}/run",
title="Run backup schedule now",
summary="Immediately enqueue a backup for a schedule without waiting for its next run.",
auth="admin",
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
endpoint(
id="admin-backups-schedule-delete",
method="POST",
path="/admin/backups/schedules/{uid}/delete",
title="Delete backup schedule",
summary="Delete a backup schedule. Existing archives are kept.",
auth="admin",
destructive=True,
params=[field("uid", "path", "string", True, "", "Schedule uid.")],
sample_response={"ok": True, "redirect": "/admin/backups"},
),
],
},
]
+19
View File
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl>
import asyncio
import fcntl
import logging
import os
@@ -33,6 +34,7 @@ from devplacepy.database import (
get_int_setting,
interleave_by_author,
get_user_post_count,
get_user_stars,
)
from devplacepy.templating import templates
from devplacepy.responses import respond, wants_json, json_error
@@ -87,8 +89,12 @@ from devplacepy.services.jobs.fork_service import ForkService
from devplacepy.services.jobs.issue_create_service import IssueCreateService
from devplacepy.services.jobs.planning_service import PlanningReportService
from devplacepy.services.jobs.seo.service import SeoService
from devplacepy.services.backup import BackupService
from devplacepy.services.dbapi.service import DbApiJobService
from devplacepy.services.pubsub import PubSubService
from devplacepy.services.notification_relay import NotificationRelayService
from devplacepy.services.live_view_relay import LiveViewRelayService
from devplacepy.services.correction import PENDING_SCOPE_KEY
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
from devplacepy.services.gitea.service import IssueTrackerService
from devplacepy.services.containers.service import ContainerService
@@ -193,8 +199,11 @@ async def lifespan(app: FastAPI):
service_manager.register(ZipService())
service_manager.register(ForkService())
service_manager.register(SeoService())
service_manager.register(BackupService())
service_manager.register(DbApiJobService())
service_manager.register(PubSubService())
service_manager.register(NotificationRelayService())
service_manager.register(LiveViewRelayService())
service_manager.register(DeepsearchService())
service_manager.register(IssueCreateService())
service_manager.register(PlanningReportService())
@@ -388,6 +397,15 @@ async def refresh_db_snapshot(request: Request, call_next):
return await call_next(request)
@app.middleware("http")
async def await_pending_corrections(request: Request, call_next):
response = await call_next(request)
pending = request.scope.get(PENDING_SCOPE_KEY)
if pending:
await asyncio.gather(*pending, return_exceptions=True)
return response
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
@@ -552,6 +570,7 @@ async def landing(request: Request):
"user": user,
"is_authenticated": bool(user),
"user_post_count": get_user_post_count(user["uid"]) if user else 0,
"user_stars": get_user_stars(user["uid"]) if user else 0,
"landing_articles": landing_articles,
"landing_posts": landing_posts,
},
+32
View File
@@ -4,6 +4,7 @@ from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS, REACTION_EMOJI
from devplacepy.config import DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT
def normalize_european_date(value):
@@ -208,6 +209,25 @@ class ProjectEditForm(BaseModel):
return normalize_european_date(value)
class BackupRunForm(BaseModel):
target: Literal["database", "uploads", "keys", "full"] = "full"
class BackupScheduleForm(BaseModel):
name: str = Field(min_length=1, max_length=120)
target: Literal["database", "uploads", "keys", "full"] = "full"
kind: Literal["interval", "cron"] = "interval"
every_seconds: int = Field(default=86400, ge=60)
cron: str = Field(default="", max_length=120)
keep_last: int = Field(default=7, ge=0, le=1000)
@model_validator(mode="after")
def _validate_schedule(self) -> "BackupScheduleForm":
if self.kind == "cron" and not self.cron.strip():
raise ValueError("A cron schedule requires a cron expression")
return self
class ProjectFlagForm(BaseModel):
value: bool = False
@@ -228,6 +248,18 @@ class NotificationDefaultForm(BaseModel):
value: bool = False
class AiCorrectionForm(BaseModel):
enabled: bool = False
sync: bool = False
prompt: str = Field(default=DEFAULT_CORRECTION_PROMPT, max_length=2000)
class AiModifierForm(BaseModel):
enabled: bool = False
sync: bool = False
prompt: str = Field(default=DEFAULT_MODIFIER_PROMPT, max_length=2000)
class ForkForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
+2
View File
@@ -4,6 +4,7 @@ from devplacepy.routers.admin import (
aiquota,
aiusage,
auditlog,
backups,
bots,
containers,
issues,
@@ -27,6 +28,7 @@ router.include_router(notifications.router)
router.include_router(news.router)
router.include_router(issues.router)
router.include_router(auditlog.router)
router.include_router(backups.router)
router.include_router(bots.router)
router.include_router(services.router, prefix="/services")
router.include_router(containers.router, prefix="/containers")
+369
View File
@@ -0,0 +1,369 @@
# retoor <retoor@molodetz.nl>
import logging
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Form, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from devplacepy import config
from devplacepy.models import BackupRunForm, BackupScheduleForm
from devplacepy.responses import action_result, respond, wants_json
from devplacepy.schemas import BackupDashboardOut, BackupJobOut, BackupOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.backup import store
from devplacepy.services.devii.tasks.schedule import cron_next, next_run, now_utc, to_iso
from devplacepy.services.jobs import queue
from devplacepy.utils import not_found, require_admin
logger = logging.getLogger(__name__)
router = APIRouter()
RETENTION_EXTEND_SECONDS = 7 * 24 * 60 * 60
def _download_url(row: dict) -> str | None:
if row.get("status") == store.STATUS_DONE and row.get("local_path"):
return f"/admin/backups/{row['uid']}/download"
return None
def _backup_payload(row: dict) -> dict:
return {
**row,
"size_human": store.human_bytes(int(row.get("size_bytes") or 0)),
"download_url": _download_url(row),
}
def _metrics(backups: list[dict]) -> dict:
done = [b for b in backups if b["status"] == store.STATUS_DONE]
return {
"total": len(backups),
"done": len(done),
"running": len([b for b in backups if b["status"] == store.STATUS_RUNNING]),
"pending": len([b for b in backups if b["status"] == store.STATUS_PENDING]),
"failed": len([b for b in backups if b["status"] == store.STATUS_FAILED]),
"size_bytes": sum(int(b.get("size_bytes") or 0) for b in done),
"size_human": store.human_bytes(
sum(int(b.get("size_bytes") or 0) for b in done)
),
}
def _targets() -> list[dict]:
return [
{"key": key, "label": meta["label"], "description": meta["description"]}
for key, meta in store.BACKUP_TARGETS.items()
]
def _dashboard(request: Request) -> dict:
backups = [_backup_payload(row) for row in store.list_backups()]
schedules = store.list_schedules()
return {
"storage": store.compute_storage_stats(),
"backups": backups,
"schedules": schedules,
"targets": _targets(),
"metrics": _metrics(backups),
"generated_at": store.now_iso(),
}
@router.get("/backups", response_class=HTMLResponse)
async def admin_backups(request: Request):
admin = require_admin(request)
data = _dashboard(request)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Backups - Admin",
description="Create, schedule, and manage encrypted-at-rest data backups.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Backups", "url": "/admin/backups"},
],
schemas=[website_schema(base)],
)
return respond(
request,
"admin_backups.html",
{
**seo_ctx,
**data,
"request": request,
"user": admin,
"admin_section": "backups",
},
model=BackupDashboardOut,
)
@router.get("/backups/data")
async def admin_backups_data(request: Request):
require_admin(request)
data = _dashboard(request)
return JSONResponse(BackupDashboardOut.model_validate(data).model_dump(mode="json"))
@router.post("/backups/run")
async def admin_backups_run(
request: Request, data: Annotated[BackupRunForm, Form()]
):
admin = require_admin(request)
job_uid = queue.enqueue(
"backup",
{"target": data.target, "schedule_uid": "", "created_by": admin["uid"]},
owner_kind="user",
owner_id=admin["uid"],
preferred_name=f"{store.target_label(data.target)} (manual)",
)
backup_uid = store.create_backup(
target=data.target, created_by=admin["uid"], job_uid=job_uid
)
audit.record(
request,
"admin.backup.run",
target_type="backup",
target_uid=backup_uid,
metadata={"target": data.target},
summary=f"started manual backup of {data.target}",
links=[audit.job(job_uid)],
)
if wants_json(request):
return JSONResponse(
{
"ok": True,
"uid": job_uid,
"backup_uid": backup_uid,
"status_url": f"/admin/backups/jobs/{job_uid}",
}
)
return action_result(request, "/admin/backups")
def _job_payload(job: dict) -> dict:
result = job.get("result", {})
done = job.get("status") == queue.DONE
return {
"uid": job.get("uid", ""),
"kind": job.get("kind", ""),
"status": job.get("status", ""),
"target": (job.get("payload") or {}).get("target"),
"backup_uid": result.get("backup_uid") if done else None,
"download_url": (
f"/admin/backups/{result.get('backup_uid')}/download"
if done and result.get("backup_uid")
else None
),
"error": job.get("error") or None,
"bytes_out": int(job.get("bytes_out") or 0),
"file_count": int(job.get("item_count") or 0),
"sha256": result.get("sha256"),
"created_at": job.get("created_at"),
"completed_at": job.get("completed_at") or None,
}
@router.get("/backups/jobs/{uid}")
async def admin_backups_job(request: Request, uid: str):
require_admin(request)
job = queue.get_job(uid)
if not job or job.get("kind") != "backup":
raise not_found("Backup job not found")
return JSONResponse(
BackupJobOut.model_validate(_job_payload(job)).model_dump(mode="json")
)
def _first_run(form: BackupScheduleForm) -> str:
reference = now_utc()
if form.kind == "cron":
return to_iso(cron_next(form.cron.strip(), reference))
moment = next_run("interval", form.every_seconds, None, reference)
return to_iso(moment) if moment else ""
@router.post("/backups/schedules/create")
async def admin_backups_schedule_create(
request: Request, data: Annotated[BackupScheduleForm, Form()]
):
admin = require_admin(request)
schedule_uid = store.create_schedule(
name=data.name,
target=data.target,
kind=data.kind,
every_seconds=data.every_seconds,
cron=data.cron.strip(),
keep_last=data.keep_last,
created_by=admin["uid"],
next_run_at=_first_run(data),
)
audit.record(
request,
"admin.backup_schedule.create",
target_type="backup_schedule",
target_uid=schedule_uid,
new_value=f"{data.name} -> {data.target}",
metadata={"target": data.target, "kind": data.kind},
summary=f"created backup schedule {data.name}",
)
return action_result(request, "/admin/backups")
@router.post("/backups/schedules/{uid}/edit")
async def admin_backups_schedule_edit(
request: Request, uid: str, data: Annotated[BackupScheduleForm, Form()]
):
require_admin(request)
if not store.get_schedule(uid):
raise not_found("Schedule not found")
store.update_schedule(
uid,
{
"name": data.name,
"target": data.target,
"kind": data.kind,
"every_seconds": data.every_seconds,
"cron": data.cron.strip(),
"keep_last": data.keep_last,
"next_run_at": _first_run(data),
},
)
audit.record(
request,
"admin.backup_schedule.update",
target_type="backup_schedule",
target_uid=uid,
new_value=f"{data.name} -> {data.target}",
summary=f"updated backup schedule {data.name}",
)
return action_result(request, "/admin/backups")
@router.post("/backups/schedules/{uid}/toggle")
async def admin_backups_schedule_toggle(request: Request, uid: str):
require_admin(request)
schedule = store.get_schedule(uid)
if not schedule:
raise not_found("Schedule not found")
enabled = 0 if int(schedule.get("enabled") or 0) else 1
store.update_schedule(uid, {"enabled": enabled})
audit.record(
request,
"admin.backup_schedule.toggle",
target_type="backup_schedule",
target_uid=uid,
new_value="enabled" if enabled else "disabled",
summary=f"{'enabled' if enabled else 'disabled'} backup schedule {schedule.get('name')}",
)
return action_result(request, "/admin/backups")
@router.post("/backups/schedules/{uid}/run")
async def admin_backups_schedule_run(request: Request, uid: str):
admin = require_admin(request)
schedule = store.get_schedule(uid)
if not schedule:
raise not_found("Schedule not found")
job_uid = queue.enqueue(
"backup",
{
"target": schedule["target"],
"schedule_uid": uid,
"created_by": admin["uid"],
"keep_last": int(schedule.get("keep_last") or 0),
},
owner_kind="user",
owner_id=admin["uid"],
preferred_name=f"{schedule.get('name')} (manual run)",
)
store.create_backup(
target=schedule["target"],
created_by=admin["uid"],
job_uid=job_uid,
schedule_uid=uid,
)
audit.record(
request,
"admin.backup.run",
target_type="backup_schedule",
target_uid=uid,
metadata={"target": schedule["target"], "schedule_uid": uid},
summary=f"manually ran backup schedule {schedule.get('name')}",
links=[audit.job(job_uid)],
)
return action_result(request, "/admin/backups")
@router.post("/backups/schedules/{uid}/delete")
async def admin_backups_schedule_delete(request: Request, uid: str):
admin = require_admin(request)
schedule = store.get_schedule(uid)
if not schedule:
raise not_found("Schedule not found")
store.delete_schedule(uid, admin["uid"])
audit.record(
request,
"admin.backup_schedule.delete",
target_type="backup_schedule",
target_uid=uid,
old_value=schedule.get("name"),
summary=f"deleted backup schedule {schedule.get('name')}",
)
return action_result(request, "/admin/backups")
@router.get("/backups/{uid}/download")
async def admin_backups_download(request: Request, uid: str):
require_admin(request)
row = store.get_backup(uid)
if not row or row.get("status") != store.STATUS_DONE:
raise not_found("Backup not available")
local_path = row.get("local_path") or ""
resolved = Path(local_path).resolve()
if (
not local_path
or not resolved.is_relative_to(config.BACKUPS_DIR.resolve())
or not resolved.is_file()
):
raise not_found("Backup not available")
return FileResponse(
resolved,
filename=row.get("filename", "backup.tar.gz"),
media_type="application/gzip",
)
@router.post("/backups/{uid}/delete")
async def admin_backups_delete(request: Request, uid: str):
require_admin(request)
row = store.get_backup(uid)
if not row:
raise not_found("Backup not found")
store.delete_backup(uid)
audit.record(
request,
"admin.backup.delete",
target_type="backup",
target_uid=uid,
old_value=row.get("filename"),
metadata={"target": row.get("target"), "size_bytes": row.get("size_bytes")},
summary=f"deleted backup {row.get('filename')}",
)
return action_result(request, "/admin/backups")
@router.get("/backups/{uid}")
async def admin_backups_detail(request: Request, uid: str):
require_admin(request)
row = store.get_backup(uid)
if not row:
raise not_found("Backup not found")
return JSONResponse(
BackupOut.model_validate(_backup_payload(row)).model_dump(mode="json")
)
+4
View File
@@ -14,6 +14,8 @@ from devplacepy.database import (
)
from devplacepy.content import apply_vote, delete_comment_record, is_owner, is_admin
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
from devplacepy.services.devrant.params import merge_params
from devplacepy.services.devrant.tokens import resolve_user
from devplacepy.services.devrant.ids import as_int, comment_by_id
@@ -77,6 +79,8 @@ async def edit_comment(request: Request, comment_id: str):
},
["uid"],
)
schedule_correction(user, "comments", comment["uid"], request)
schedule_modification(user, "comments", comment["uid"], request)
audit.record(
request,
"comment.edit",
+4
View File
@@ -18,6 +18,8 @@ from devplacepy.content import (
is_admin,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
from devplacepy.services.devrant.params import merge_params
from devplacepy.services.devrant.tokens import resolve_user
from devplacepy.services.devrant.ids import as_int, post_by_id
@@ -143,6 +145,8 @@ async def edit_rant(request: Request, rant_id: str):
},
["uid"],
)
schedule_correction(user, "posts", post["uid"], request)
schedule_modification(user, "posts", post["uid"], request)
audit.record(
request,
"post.edit",
+32
View File
@@ -81,6 +81,24 @@ DOCS_PAGES = [
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "timezones",
"title": "Timezone-aware dates",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "ai-correction",
"title": "AI content correction",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "ai-modifier",
"title": "AI modifier",
"kind": "prose",
"section": SECTION_GENERAL,
},
# Tools - public developer tools (everyone)
{
"slug": "tools-seo",
@@ -304,6 +322,20 @@ DOCS_PAGES = [
"admin": True,
"section": SECTION_ADMIN,
},
{
"slug": "backups",
"title": "Backups",
"kind": "prose",
"admin": True,
"section": SECTION_ADMIN,
},
{
"slug": "gamification",
"title": "Gamification and badges",
"kind": "prose",
"admin": True,
"section": SECTION_ADMIN,
},
# Devii internals - technical reference for the Devii assistant (admins only)
{
"slug": "audit-log",
+8 -1
View File
@@ -4,7 +4,12 @@ import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, not_found, is_admin as user_is_admin
from devplacepy.utils import (
get_current_user,
not_found,
is_admin as user_is_admin,
track_action,
)
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.docs_api import render_group, build_services_group
from devplacepy.services.manager import service_manager
@@ -124,6 +129,8 @@ async def docs_page(request: Request, slug: str):
raise not_found("Documentation page not found")
if page.get("admin") and not is_admin:
raise not_found("Documentation page not found")
if user:
track_action(user["uid"], "docs.read", slug)
if page["kind"] == "live":
seo_ctx = base_seo_context(
request,
+2
View File
@@ -9,6 +9,7 @@ from devplacepy.utils import (
require_user,
create_notification,
award_rewards,
track_action,
XP_FOLLOW,
)
from devplacepy.responses import action_result
@@ -54,6 +55,7 @@ async def follow_user(request: Request, username: str):
f"/profile/{user['username']}",
)
award_rewards(target["uid"], XP_FOLLOW)
track_action(user["uid"], "follow")
logger.info(f"{user['username']} followed {username}")
audit.record(
+2 -1
View File
@@ -12,7 +12,7 @@ from devplacepy.schemas import IssueJobOut
from devplacepy.services.audit import record as audit
from devplacepy.services.gitea.config import gitea_config
from devplacepy.services.jobs import queue
from devplacepy.utils import not_found, require_user
from devplacepy.utils import not_found, require_user, track_action
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -44,6 +44,7 @@ async def create_issue(request: Request, data: Annotated[IssueForm, Form()]):
metadata={"title": title},
links=[audit.job(uid)],
)
track_action(user["uid"], "issue")
logger.info("Issue report enqueued by %s: %s", user["username"], title[:60])
return JSONResponse({"uid": uid, "status_url": f"/issues/jobs/{uid}"})
+19 -2
View File
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
from typing import Annotated, Optional
from fastapi import APIRouter, Request, Form, WebSocket, WebSocketDisconnect
@@ -19,6 +20,7 @@ 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.correction import PENDING_SCOPE_KEY
from devplacepy.services.messaging import (
message_frame,
message_hub,
@@ -208,7 +210,7 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
if message is None:
return action_result(request, "/messages")
await broadcast_message(user, message)
await _finalize_and_broadcast(user, message, request)
return action_result(
request, f"/messages?with_uid={receiver_uid}", data={"uid": message["uid"]}
)
@@ -223,6 +225,20 @@ async def broadcast_message(
await message_hub.send_to_users(targets, frame)
async def _finalize_and_broadcast(
sender: dict, message: dict, request: object, client_id: Optional[str] = None
) -> None:
scope = getattr(request, "scope", None)
pending = scope.get(PENDING_SCOPE_KEY) if scope is not None else None
if pending:
await asyncio.gather(*pending, return_exceptions=True)
pending.clear()
row = get_table("messages").find_one(uid=message["uid"])
if row:
message["content"] = row["content"]
await broadcast_message(sender, message, client_id)
def _resolve_ws_user(websocket: WebSocket):
user = _user_from_session(websocket)
if user:
@@ -283,6 +299,7 @@ async def messages_ws(websocket: WebSocket):
receiver_uid,
content,
attachment_uids,
request=websocket,
origin="websocket",
)
if message is None:
@@ -290,7 +307,7 @@ async def messages_ws(websocket: WebSocket):
{"type": "error", "client_id": client_id, "text": "Message not sent."}
)
continue
await broadcast_message(user, message, client_id)
await _finalize_and_broadcast(user, message, websocket, client_id)
elif kind == "typing":
receiver_uid = str(data.get("receiver_uid", "")).strip()
if receiver_uid:
+3 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, get_poll_for_post, _now_iso
from devplacepy.utils import generate_uid, require_user
from devplacepy.utils import generate_uid, require_user, track_action
from devplacepy.models import PollVoteForm
from devplacepy.services.audit import record as audit
@@ -78,6 +78,8 @@ async def vote_poll(
summary=f"{user['username']} {poll_event.split('.')[-1]} vote in poll {poll.get('question')}",
links=[audit.poll(poll_uid, poll.get("question")), audit.option(data.option_uid, option.get("text"))],
)
if poll_event == "poll.vote.cast":
track_action(user["uid"], "poll")
result = get_poll_for_post(poll["post_uid"], user)
if request.headers.get("x-requested-with") == "fetch":
+9 -1
View File
@@ -2,12 +2,20 @@
from fastapi import APIRouter
from devplacepy.routers.profile import customization, index, notifications
from devplacepy.routers.profile import (
ai_correction,
ai_modifier,
customization,
index,
notifications,
)
from devplacepy.routers.profile.usage import _ai_quota
router = APIRouter()
router.include_router(index.router)
router.include_router(customization.router)
router.include_router(notifications.router)
router.include_router(ai_correction.router)
router.include_router(ai_modifier.router)
__all__ = ["router", "_ai_quota"]
@@ -0,0 +1,67 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Request, Form
from devplacepy.models import AiCorrectionForm
from devplacepy.config import DEFAULT_CORRECTION_PROMPT
from devplacepy.database import get_table
from devplacepy.responses import action_result
from devplacepy.utils import clear_user_cache
from devplacepy.services.audit import record as audit
from devplacepy.routers.profile._shared import resolve_customization_target
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{username}/ai-correction")
async def set_ai_correction(
request: Request,
username: str,
data: Annotated[AiCorrectionForm, Form()],
):
target, denied = resolve_customization_target(request, username)
if denied is not None:
return denied
enabled = 1 if data.enabled else 0
sync = 1 if data.sync else 0
prompt = data.prompt.strip() or DEFAULT_CORRECTION_PROMPT
get_table("users").update(
{
"uid": target["uid"],
"ai_correction_enabled": enabled,
"ai_correction_prompt": prompt,
"ai_correction_sync": sync,
},
["uid"],
)
clear_user_cache(target["uid"])
logger.info(
f"AI correction {'enabled' if enabled else 'disabled'} for {target['username']}"
)
audit.record(
request,
"profile.ai_correction",
target_type="user",
target_uid=target["uid"],
target_label=target["username"],
new_value=enabled,
summary=(
f"{'enabled' if enabled else 'disabled'} AI content correction "
f"for {target['username']}"
),
links=[audit.target("user", target["uid"], target["username"])],
)
url = f"/profile/{target['username']}"
return action_result(
request,
url,
data={
"url": url,
"enabled": bool(enabled),
"sync": bool(sync),
"prompt": prompt,
},
)
+67
View File
@@ -0,0 +1,67 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Request, Form
from devplacepy.models import AiModifierForm
from devplacepy.config import DEFAULT_MODIFIER_PROMPT
from devplacepy.database import get_table
from devplacepy.responses import action_result
from devplacepy.utils import clear_user_cache
from devplacepy.services.audit import record as audit
from devplacepy.routers.profile._shared import resolve_customization_target
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{username}/ai-modifier")
async def set_ai_modifier(
request: Request,
username: str,
data: Annotated[AiModifierForm, Form()],
):
target, denied = resolve_customization_target(request, username)
if denied is not None:
return denied
enabled = 1 if data.enabled else 0
sync = 1 if data.sync else 0
prompt = data.prompt.strip() or DEFAULT_MODIFIER_PROMPT
get_table("users").update(
{
"uid": target["uid"],
"ai_modifier_enabled": enabled,
"ai_modifier_prompt": prompt,
"ai_modifier_sync": sync,
},
["uid"],
)
clear_user_cache(target["uid"])
logger.info(
f"AI modifier {'enabled' if enabled else 'disabled'} for {target['username']}"
)
audit.record(
request,
"profile.ai_modifier",
target_type="user",
target_uid=target["uid"],
target_label=target["username"],
new_value=enabled,
summary=(
f"{'enabled' if enabled else 'disabled'} AI modifier "
f"for {target['username']}"
),
links=[audit.target("user", target["uid"], target["username"])],
)
url = f"/profile/{target['username']}"
return action_result(
request,
url,
data={
"url": url,
"enabled": bool(enabled),
"sync": bool(sync),
"prompt": prompt,
},
)
+64 -3
View File
@@ -34,6 +34,8 @@ from devplacepy.utils import (
clear_user_cache,
not_found,
generate_uid,
track_action,
build_achievements,
)
from devplacepy.responses import respond, action_result
from devplacepy.schemas import ProfileOut
@@ -45,8 +47,11 @@ from devplacepy.seo import (
profile_page_schema,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
from devplacepy.config import DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT
from devplacepy.routers.profile.usage import _ai_quota
from devplacepy.routers.profile.usage import _ai_quota, _correction_usage, _modifier_usage
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -139,7 +144,10 @@ async def profile_page(
posts_table = get_table("posts")
raw_posts = list(
posts_table.find(
user_uid=profile_user["uid"], deleted_at=None, order_by=["-created_at"]
user_uid=profile_user["uid"],
deleted_at=None,
order_by=["-created_at"],
_limit=10,
)
)
post_uids = [p["uid"] for p in raw_posts]
@@ -162,6 +170,9 @@ async def profile_page(
item["poll"] = polls_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
achievements = build_achievements({b["badge_name"] for b in badges})
badge_total = sum(group["total"] for group in achievements)
badge_earned = sum(group["earned"] for group in achievements)
projects = list(
get_table("projects").find(user_uid=profile_user["uid"], deleted_at=None)
)
@@ -172,7 +183,7 @@ async def profile_page(
gists = []
for g in gists_raw:
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
posts_count = len(posts) or get_table("posts").count(
posts_count = get_table("posts").count(
user_uid=profile_user["uid"], deleted_at=None
)
@@ -234,6 +245,28 @@ async def profile_page(
viewer_is_admin = bool(current_user and current_user.get("role") == "Admin")
can_view_api_key = bool(current_user and (is_owner or viewer_is_admin))
api_key = profile_user.get("api_key", "") if can_view_api_key else ""
ai_correction_enabled = (
bool(profile_user.get("ai_correction_enabled")) if is_owner else False
)
ai_correction_sync = (
bool(profile_user.get("ai_correction_sync")) if is_owner else False
)
ai_correction_prompt = (
(profile_user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT)
if is_owner
else None
)
ai_modifier_enabled = (
bool(profile_user.get("ai_modifier_enabled")) if is_owner else False
)
ai_modifier_sync = (
bool(profile_user.get("ai_modifier_sync")) if is_owner else False
)
ai_modifier_prompt = (
(profile_user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT)
if is_owner
else None
)
profile_is_admin = profile_user.get("role") == "Admin"
ai_quota = (
_ai_quota(
@@ -242,6 +275,16 @@ async def profile_page(
if (is_owner or viewer_is_admin)
else None
)
correction_usage = (
_correction_usage(profile_user["uid"], include_cost=viewer_is_admin)
if (is_owner or viewer_is_admin)
else None
)
modifier_usage = (
_modifier_usage(profile_user["uid"], include_cost=viewer_is_admin)
if (is_owner or viewer_is_admin)
else None
)
can_manage_customization = is_owner or viewer_is_admin
customization_prefs = (
get_customization_prefs("user", profile_user["uid"])
@@ -291,6 +334,9 @@ async def profile_page(
"profile_user": profile_user,
"posts": posts,
"badges": badges,
"achievements": achievements,
"badge_total": badge_total,
"badge_earned": badge_earned,
"projects": projects,
"gists": gists,
"current_tab": tab,
@@ -302,11 +348,19 @@ async def profile_page(
"media_pagination": media_pagination,
"can_view_api_key": can_view_api_key,
"api_key": api_key,
"ai_correction_enabled": ai_correction_enabled,
"ai_correction_sync": ai_correction_sync,
"ai_correction_prompt": ai_correction_prompt,
"ai_modifier_enabled": ai_modifier_enabled,
"ai_modifier_sync": ai_modifier_sync,
"ai_modifier_prompt": ai_modifier_prompt,
"can_manage_customization": can_manage_customization,
"cust_disable_global": customization_prefs["disable_global"],
"cust_disable_pagetype": customization_prefs["disable_pagetype"],
"notification_prefs": notification_prefs,
"ai_quota": ai_quota,
"correction_usage": correction_usage,
"modifier_usage": modifier_usage,
"activities": activities,
"rank": rank,
"heatmap": heatmap,
@@ -336,6 +390,8 @@ async def update_profile(request: Request, data: Annotated[ProfileForm, Form()])
["uid"],
)
clear_user_cache(user["uid"])
schedule_correction(user, "users", user["uid"], request)
schedule_modification(user, "users", user["uid"], request)
logger.info(f"Profile updated for {user['username']}")
audit.record(
@@ -349,6 +405,11 @@ async def update_profile(request: Request, data: Annotated[ProfileForm, Form()])
summary=f"{user['username']} updated their profile",
links=[audit.target("user", user["uid"], user["username"])],
)
if any(
value.strip()
for value in (data.bio, data.location, data.git_link, data.website)
):
track_action(user["uid"], "profile")
return action_result(request, f"/profile/{user['username']}")
+28
View File
@@ -2,12 +2,40 @@
import logging
from devplacepy.database import get_correction_usage, get_modifier_usage
from devplacepy.services.manager import service_manager
from devplacepy.services.openai_gateway.analytics import user_spend_24h
logger = logging.getLogger(__name__)
def _usage_view(data: dict, include_cost: bool) -> dict:
usage = {
"calls": data["calls"],
"prompt_tokens": data["prompt_tokens"],
"completion_tokens": data["completion_tokens"],
"total_tokens": data["total_tokens"],
"avg_tokens": data["avg_tokens"],
"avg_latency_ms": data["avg_upstream_latency_ms"],
"avg_total_latency_ms": data["avg_total_latency_ms"],
"avg_tokens_per_second": data["avg_tokens_per_second"],
"total_time_s": round(data["upstream_latency_ms"] / 1000.0, 1),
"last_used": data["updated_at"],
}
if include_cost:
usage["cost_usd"] = round(data["cost_usd"], 6)
usage["avg_cost_usd"] = round(data["avg_cost_usd"], 6)
return usage
def _correction_usage(user_uid: str, include_cost: bool = False) -> dict:
return _usage_view(get_correction_usage(user_uid), include_cost)
def _modifier_usage(user_uid: str, include_cost: bool = False) -> dict:
return _usage_view(get_modifier_usage(user_uid), include_cost)
def _ai_quota(
user_uid: str, include_cost: bool = False, is_admin: bool = False
) -> dict | None:
+5 -1
View File
@@ -22,7 +22,7 @@ from devplacepy.responses import respond, action_result, wants_json, json_error
from devplacepy.schemas import ProjectFilesOut
from devplacepy.services.jobs import queue
from devplacepy.seo import base_seo_context
from devplacepy.utils import get_current_user, require_user, not_found, is_admin
from devplacepy.utils import get_current_user, require_user, not_found, is_admin, track_action
from devplacepy import project_files
from devplacepy.project_files import ProjectFileError
from devplacepy.services.audit import record as audit
@@ -160,6 +160,8 @@ async def project_files_zip(request: Request, project_slug: str, path: str = "")
summary=f"requested a zip of {subpath or 'whole tree'} in project {project.get('title')}",
links=[audit.project(project["uid"], project.get("title")), audit.job(uid)],
)
if user:
track_action(user["uid"], "zip")
return JSONResponse({"uid": uid, "status_url": f"/zips/{uid}"})
@@ -196,6 +198,8 @@ async def project_file_write(
return _fail(request, project, exc, user, write_event, data.path)
logger.info("project %s file written: %s", project["uid"], node["path"])
_audit_file(request, project, user, write_event, node["path"])
if not existed:
track_action(user["uid"], "project_file")
return action_result(
request, _files_url(project), data=project_files.node_to_dict(node)
)
+4
View File
@@ -37,6 +37,7 @@ from devplacepy.utils import (
require_user,
not_found,
is_admin,
track_action,
XP_PROJECT,
)
from devplacepy.seo import (
@@ -247,6 +248,8 @@ async def zip_project(request: Request, project_slug: str):
summary=f"requested a zip of project {project.get('title')}",
links=[audit.target("project", project["uid"], project.get("title")), audit.job(uid)],
)
if user:
track_action(user["uid"], "zip")
return JSONResponse({"uid": uid, "status_url": f"/zips/{uid}"})
@@ -283,6 +286,7 @@ async def fork_project(
summary=f"{user['username']} requested a fork of project {source.get('title')}",
links=[audit.source("project", source["uid"], source.get("title")), audit.job(uid)],
)
track_action(user["uid"], "fork")
return JSONResponse({"uid": uid, "status_url": f"/forks/{uid}"})
+3 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, _now_iso
from devplacepy.utils import generate_uid, require_user
from devplacepy.utils import generate_uid, require_user, track_action
from devplacepy.models import ReactionForm
from devplacepy.services.audit import record as audit
@@ -61,6 +61,8 @@ async def react(
added = True
reaction_event = "reaction.add" if added else "reaction.remove"
if added:
track_action(user["uid"], "reaction")
audit.record(
request,
+3 -1
View File
@@ -20,7 +20,7 @@ from devplacepy.services.jobs.deepsearch.progress import hub
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
from devplacepy.services.manager import service_manager
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, is_admin, not_found
from devplacepy.utils import generate_uid, get_current_user, is_admin, not_found, track_action
from ._shared import owner_for
@@ -164,6 +164,8 @@ async def deepsearch_run(request: Request, data: Annotated[DeepsearchRunForm, Fo
metadata={"query": data.query, "depth": data.depth, "max_pages": data.max_pages},
links=[audit.job(uid)],
)
if owner_kind == "user":
track_action(owner_id, "deepsearch")
return JSONResponse(
{
"uid": uid,
+3 -1
View File
@@ -16,7 +16,7 @@ from devplacepy.services.jobs import queue
from devplacepy.services.jobs.seo.progress import hub
from devplacepy.services.manager import service_manager
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, not_found
from devplacepy.utils import get_current_user, not_found, track_action
from ._shared import owner_for as _owner
logger = logging.getLogger(__name__)
@@ -122,6 +122,8 @@ async def seo_run(request: Request, data: Annotated[SeoRunForm, Form()]):
metadata={"target": data.url, "mode": data.mode, "max_pages": data.max_pages},
links=[audit.job(uid)],
)
if owner_kind == "user":
track_action(owner_id, "seo")
return JSONResponse(
{
"uid": uid,
+2 -1
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Form, Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_int_setting
from devplacepy.models import UploadUrlForm
from devplacepy.utils import require_user_api, is_admin
from devplacepy.utils import require_user_api, is_admin, track_action
from devplacepy.attachments import (
store_attachment,
store_attachment_from_url,
@@ -64,6 +64,7 @@ async def upload_file(request: Request):
summary=f"{user['username']} uploaded attachment {file.filename}",
links=[audit.attachment_link(result.get("uid"), file.filename)],
)
track_action(user["uid"], "upload")
return JSONResponse(result, status_code=201)
+83
View File
@@ -130,6 +130,77 @@ class SeoJobOut(_Out):
completed_at: Optional[str] = None
class BackupStoragePathOut(_Out):
key: str = ""
label: str = ""
path: str = ""
size_bytes: int = 0
size_human: str = ""
file_count: int = 0
exists: bool = False
class BackupOut(_Out):
uid: str = ""
job_uid: str = ""
target: str = ""
label: str = ""
status: str = ""
filename: str = ""
size_bytes: int = 0
size_human: str = ""
bytes_in: int = 0
file_count: int = 0
dir_count: int = 0
sha256: str = ""
schedule_uid: str = ""
created_by: str = ""
created_at: Optional[str] = None
completed_at: Optional[str] = None
error: Optional[str] = None
download_url: Optional[str] = None
class BackupJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
target: Optional[str] = None
backup_uid: Optional[str] = None
download_url: Optional[str] = None
error: Optional[str] = None
bytes_out: int = 0
file_count: int = 0
sha256: Optional[str] = None
created_at: Optional[str] = None
completed_at: Optional[str] = None
class BackupScheduleOut(_Out):
uid: str = ""
name: str = ""
target: str = ""
kind: str = ""
every_seconds: int = 0
cron: Optional[str] = None
enabled: int = 0
keep_last: int = 0
next_run_at: Optional[str] = None
last_run_at: Optional[str] = None
last_job_uid: Optional[str] = None
run_count: int = 0
created_at: Optional[str] = None
class BackupDashboardOut(_Out):
storage: dict = {}
backups: list[BackupOut] = []
schedules: list[BackupScheduleOut] = []
targets: list[dict] = []
metrics: dict = {}
generated_at: Optional[str] = None
class SeoReportOut(_Out):
uid: str = ""
status: str = ""
@@ -650,6 +721,9 @@ class ProfileOut(_Out):
profile_user: UserOut
posts: list[FeedItemOut] = []
badges: list[BadgeOut] = []
achievements: list[dict] = []
badge_total: int = 0
badge_earned: int = 0
projects: list[ProjectOut] = []
gists: list[GistItemOut] = []
current_tab: Optional[str] = None
@@ -658,10 +732,18 @@ class ProfileOut(_Out):
is_owner: bool = False
can_view_api_key: bool = False
api_key: Optional[str] = None
ai_correction_enabled: bool = False
ai_correction_sync: bool = False
ai_correction_prompt: Optional[str] = None
ai_modifier_enabled: bool = False
ai_modifier_sync: bool = False
ai_modifier_prompt: Optional[str] = None
can_manage_customization: bool = False
cust_disable_global: bool = False
cust_disable_pagetype: bool = False
ai_quota: Optional[dict] = None
correction_usage: Optional[dict] = None
modifier_usage: Optional[dict] = None
activities: list[Any] = []
rank: Optional[int] = None
heatmap: Optional[Any] = None
@@ -892,6 +974,7 @@ class LandingPostOut(_Out):
class LandingOut(_Out):
is_authenticated: bool = False
user_post_count: int = 0
user_stars: int = 0
landing_articles: list[LandingArticleOut] = []
landing_posts: list[LandingPostOut] = []
+186
View File
@@ -0,0 +1,186 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime, timezone
from devplacepy.database import (
db,
get_follow_counts,
get_table,
get_user_post_count,
get_user_rank,
)
logger = logging.getLogger(__name__)
MAX_BIO = 280
MAX_EXCERPT = 400
MAX_SOURCE = 1500
MAX_THREAD_MESSAGES = 6
MAX_MESSAGE = 240
TARGET_TABLES = {
"post": "posts",
"project": "projects",
"gist": "gists",
"news": "news",
}
def _truncate(text: str, limit: int) -> str:
text = " ".join((text or "").split())
if len(text) <= limit:
return text
return text[:limit].rstrip() + "..."
def _today() -> str:
return datetime.now(timezone.utc).strftime("%d/%m/%Y")
def _author_block(user_uid: str) -> str:
user = get_table("users").find_one(uid=user_uid)
if not user:
return ""
stats = [
f"role {user.get('role') or 'Member'}",
f"level {user.get('level', 1)}",
f"{user.get('stars', 0)} stars",
]
try:
stats.append(f"{get_user_post_count(user_uid)} posts")
except Exception:
pass
try:
rank = get_user_rank(user_uid)
if rank:
stats.append(f"rank #{rank}")
except Exception:
pass
try:
follows = get_follow_counts(user_uid)
stats.append(f"{follows.get('followers', 0)} followers")
except Exception:
pass
joined = (user.get("created_at") or "")[:10]
if joined:
stats.append(f"member since {joined}")
line = f"Author: {user.get('username', '')} ({', '.join(stats)})."
bio = _truncate(user.get("bio") or "", MAX_BIO)
if bio:
line += f' Bio: "{bio}".'
return line
def _target_excerpt(target_type: str, target_uid: str) -> str:
table = TARGET_TABLES.get(target_type)
if not table or table not in db.tables or not target_uid:
return ""
row = get_table(table).find_one(uid=target_uid)
if not row:
return ""
title = (row.get("title") or "").strip()
body = _truncate(row.get("content") or row.get("description") or "", MAX_EXCERPT)
label = f'the {target_type} "{title}"' if title else f"a {target_type}"
text = f"This is a comment on {label}."
if body:
text += f' Excerpt: "{body}".'
return text
def _comment_block(row: dict) -> str:
target_type = row.get("target_type") or "post"
target_uid = row.get("target_uid") or row.get("post_uid") or ""
text = _target_excerpt(target_type, target_uid)
parent_uid = row.get("parent_uid")
if parent_uid and "comments" in db.tables:
parent = get_table("comments").find_one(uid=parent_uid)
if parent:
text += f' In reply to: "{_truncate(parent.get("content") or "", MAX_EXCERPT)}".'
return text
def _post_block(row: dict) -> str:
topic = row.get("topic") or "general"
text = f"This is a post (topic: {topic})."
project_uid = row.get("project_uid")
if project_uid and "projects" in db.tables:
project = get_table("projects").find_one(uid=project_uid)
if project and project.get("title"):
text += f' Attached to the project "{project["title"]}".'
return text
def _item_block(table: str, row: dict) -> str:
kind = "project" if table == "projects" else "gist"
title = (row.get("title") or "").strip()
text = f'This is a {kind}' + (f' titled "{title}".' if title else ".")
description = _truncate(row.get("description") or "", MAX_EXCERPT)
if description:
text += f' Description: "{description}".'
if table == "gists":
language = row.get("language") or "plaintext"
source = _truncate(row.get("source_code") or "", MAX_SOURCE)
text += f" Language: {language}."
if source:
text += f" Source code (reference only, do not include unless asked): {source}"
return text
def _message_block(row: dict) -> str:
sender_uid = row.get("sender_uid")
receiver_uid = row.get("receiver_uid")
receiver = get_table("users").find_one(uid=receiver_uid) if receiver_uid else None
receiver_name = (receiver or {}).get("username") or "the recipient"
text = f"This is a direct message to {receiver_name}."
if "messages" not in db.tables or not sender_uid or not receiver_uid:
return text
rows = list(
db.query(
"SELECT sender_uid, content FROM messages "
"WHERE ((sender_uid = :a AND receiver_uid = :b) "
"OR (sender_uid = :b AND receiver_uid = :a)) "
"AND uid != :current AND deleted_at IS NULL "
"ORDER BY id DESC LIMIT :lim",
a=sender_uid,
b=receiver_uid,
current=row.get("uid") or "",
lim=MAX_THREAD_MESSAGES,
)
)
if not rows:
return text
lines = []
for entry in reversed(rows):
who = "you" if entry["sender_uid"] == sender_uid else receiver_name
lines.append(f"{who}: {_truncate(entry['content'] or '', MAX_MESSAGE)}")
return text + " Recent conversation:\n" + "\n".join(lines)
def _location_block(table: str, row: dict) -> str:
if table == "comments":
return _comment_block(row)
if table == "posts":
return _post_block(row)
if table in ("projects", "gists"):
return _item_block(table, row)
if table == "messages":
return _message_block(row)
return ""
def build_context(table: str, uid: str, row: dict, user_uid: str) -> str:
parts = [f"Today is {_today()} on the DevPlace developer network."]
try:
author = _author_block(user_uid)
if author:
parts.append(author)
except Exception as exc:
logger.warning("ai context author block failed: %s", exc)
try:
location = _location_block(table, row)
if location:
parts.append(location)
except Exception as exc:
logger.warning("ai context location block failed: %s", exc)
return "\n".join(parts)
+132
View File
@@ -0,0 +1,132 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import re
from devplacepy.config import DEFAULT_MODIFIER_PROMPT
from devplacepy.database import add_modifier_usage, get_table
from devplacepy.services.ai_context import build_context
from devplacepy.services.background import background
from devplacepy.services.correction import (
CORRECTABLE_FIELDS,
PENDING_SCOPE_KEY,
gateway_complete,
)
logger = logging.getLogger(__name__)
MODIFIER_TIMEOUT_SECONDS = 60.0
AI_DIRECTIVE = re.compile(r"@ai\s+\S", re.IGNORECASE)
def has_ai_directive(text: str | None) -> bool:
return bool(text) and AI_DIRECTIVE.search(text) is not None
def modify_text(
api_key: str, prompt: str, text: str, context: str = ""
) -> tuple[str, dict | None]:
system = (
"The user's message contains an inline instruction marked with @ai. "
+ (prompt or DEFAULT_MODIFIER_PROMPT).strip()
+ " Return ONLY the resulting text, with no preamble, no explanation, no "
"quotes and no code fences."
)
if context:
system += (
"\n\n# Context (use it to inform the result; never echo this block)\n"
+ context
)
return gateway_complete(api_key, system, text, MODIFIER_TIMEOUT_SECONDS, None)
def schedule_modification(
user: dict | None, table: str, uid: str, request: object = None
) -> None:
if not user or table not in CORRECTABLE_FIELDS or not uid:
return
if not user.get("ai_modifier_enabled"):
return
api_key = (user.get("api_key") or "").strip()
if not api_key:
return
row = get_table(table).find_one(uid=uid)
if not row:
return
if not any(
has_ai_directive(row.get(field) or "") for field in CORRECTABLE_FIELDS[table]
):
return
prompt = (user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT).strip()
user_uid = user.get("uid") or ""
if user.get("ai_modifier_sync") and _run_inline_awaited(
api_key, prompt, table, uid, user_uid, request
):
return
background.submit(_run_modification, api_key, prompt, table, uid, user_uid)
def _run_inline_awaited(
api_key: str,
prompt: str,
table: str,
uid: str,
user_uid: str,
request: object,
) -> bool:
scope = getattr(request, "scope", None)
if scope is None:
return False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return False
future = loop.run_in_executor(
None, _run_modification, api_key, prompt, table, uid, user_uid
)
scope.setdefault(PENDING_SCOPE_KEY, []).append(future)
return True
def _run_modification(
api_key: str, prompt: str, table: str, uid: str, user_uid: str
) -> None:
fields = CORRECTABLE_FIELDS.get(table)
if not fields:
return
row = get_table(table).find_one(uid=uid)
if not row:
return
updates: dict = {}
totals = {
"calls": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cost_usd": 0.0,
"upstream_latency_ms": 0.0,
"total_latency_ms": 0.0,
}
context = None
for field in fields:
original = row.get(field) or ""
if not has_ai_directive(original):
continue
if context is None:
context = build_context(table, uid, row, user_uid)
modified, usage = modify_text(api_key, prompt, original, context)
if usage:
for key in totals:
totals[key] += usage[key]
if modified and modified != original:
updates[field] = modified
if updates:
updates["uid"] = uid
get_table(table).update(updates, ["uid"])
if table == "users" and user_uid:
from devplacepy.utils import clear_user_cache
clear_user_cache(user_uid)
if totals["calls"] and user_uid:
add_modifier_usage(user_uid, totals)
+2
View File
@@ -20,6 +20,8 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
"dir": "project_files",
"files": "project_files",
"job.zip": "project_files",
"job.backup": "backup",
"backup": "backup",
"attachment": "attachment",
"news": "news",
"admin": "admin",
+5
View File
@@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.backup.service import BackupService
__all__ = ["BackupService"]
+238
View File
@@ -0,0 +1,238 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
import shutil
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
from devplacepy import config
from devplacepy.attachments import _directory_for
from devplacepy.services.backup import store
from devplacepy.services.devii.tasks.schedule import next_run as schedule_next_run
from devplacepy.services.devii.tasks.schedule import now_utc, to_iso
from devplacepy.services.jobs import queue
from devplacepy.services.jobs.base import JobService, _human_bytes
from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
WORKER_MODULE = "devplacepy.services.jobs.backup_worker"
class BackupService(JobService):
kind = "backup"
title = "Backup"
description = (
"Builds compressed tar.gz backups of selected data targets in a subprocess off the "
"request path, snapshots the database consistently, records full statistics, fires "
"scheduled backups, and rotates them by retention."
)
def __init__(self):
super().__init__(name="backup", interval_seconds=15)
async def run_once(self) -> None:
await super().run_once()
try:
self._fire_due_schedules()
except Exception as exc:
self.log(f"Schedule pass failed: {exc}")
async def process(self, job: dict) -> dict:
from devplacepy.services.audit import record as audit
payload = job["payload"]
target = payload.get("target", "")
schedule_uid = payload.get("schedule_uid", "")
keep_last = int(payload.get("keep_last") or 0)
job_uid = job["uid"]
record = store.get_backup_by_job(job_uid)
if record is None:
backup_uid = store.create_backup(
target=target,
created_by=job.get("owner_id", ""),
job_uid=job_uid,
schedule_uid=schedule_uid,
)
record = store.get_backup(backup_uid)
backup_uid = record["uid"]
if not store.is_valid_target(target):
store.fail_backup(backup_uid, f"unknown backup target: {target}")
raise ValueError(f"unknown backup target: {target}")
store.mark_running(backup_uid)
staging = config.BACKUP_STAGING_DIR / job_uid
try:
sources = await asyncio.to_thread(self._materialize, target, staging)
spec_path = staging / "spec.json"
await asyncio.to_thread(
spec_path.write_text, json.dumps({"sources": sources})
)
final_dir = config.BACKUPS_DIR / _directory_for(backup_uid)
await asyncio.to_thread(final_dir.mkdir, parents=True, exist_ok=True)
filename = self._archive_name(target, backup_uid)
tmp_path = final_dir / f"{generate_uid()}.partial"
stats = await self._run_worker(spec_path, tmp_path)
final_path = final_dir / filename
await asyncio.to_thread(tmp_path.replace, final_path)
except Exception as exc:
store.fail_backup(backup_uid, str(exc) or exc.__class__.__name__)
audit.record_system(
"job.backup.failed",
actor_kind="system",
origin="scheduler" if schedule_uid else "web",
result="failure",
target_type="backup",
target_uid=backup_uid,
metadata={"target": target, "schedule_uid": schedule_uid},
summary=f"backup {target} failed",
links=[audit.job(job_uid)],
)
raise
finally:
await asyncio.to_thread(shutil.rmtree, staging, ignore_errors=True)
store.finalize_backup(
backup_uid,
filename=filename,
local_path=str(final_path),
stats=stats,
)
removed = store.rotate_schedule(schedule_uid, keep_last)
audit.record_system(
"job.backup.complete",
actor_kind="system",
origin="scheduler" if schedule_uid else "web",
target_type="backup",
target_uid=backup_uid,
metadata={
"target": target,
"schedule_uid": schedule_uid,
"bytes_out": stats["bytes_out"],
"sha256": stats["sha256"],
"file_count": stats["file_count"],
"rotated": removed,
},
summary=f"backup {target} completed ({_human_bytes(stats['bytes_out'])})",
links=[audit.job(job_uid)],
)
return {
"backup_uid": backup_uid,
"target": target,
"filename": filename,
"local_path": str(final_path),
"sha256": stats["sha256"],
"file_count": stats["file_count"],
"dir_count": stats["dir_count"],
"bytes_in": stats["bytes_in"],
"bytes_out": stats["bytes_out"],
"item_count": stats["file_count"],
}
def cleanup(self, job: dict) -> None:
shutil.rmtree(config.BACKUP_STAGING_DIR / job["uid"], ignore_errors=True)
def _materialize(self, target: str, staging: Path) -> list[dict]:
staging.mkdir(parents=True, exist_ok=True)
sources: list[dict] = []
if target in ("database", "full"):
snapshot = staging / "db_snapshot"
self._snapshot_databases(snapshot)
sources.append({"root": "database", "path": str(snapshot)})
if target in ("uploads", "full"):
sources.append({"root": "uploads", "path": str(config.UPLOADS_DIR)})
if target in ("keys", "full"):
sources.append({"root": "keys", "path": str(config.KEYS_DIR)})
return sources
def _snapshot_databases(self, dest: Path) -> None:
dest.mkdir(parents=True, exist_ok=True)
database_file = Path(str(config.DATABASE_URL).replace("sqlite:///", ""))
for source in (database_file, config.DEVII_TASKS_DB, config.DEVII_LESSONS_DB):
if Path(source).exists():
self._sqlite_backup(Path(source), dest / Path(source).name)
def _sqlite_backup(self, source: Path, destination: Path) -> None:
origin = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
try:
target = sqlite3.connect(str(destination))
try:
origin.backup(target)
finally:
target.close()
finally:
origin.close()
async def _run_worker(self, spec_path: Path, output_path: Path) -> dict:
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
WORKER_MODULE,
str(spec_path),
str(output_path),
cwd=str(config.BASE_DIR),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(
f"backup worker exited {proc.returncode}: "
f"{err.decode('utf-8', 'replace')[:500]}"
)
return json.loads(out.decode("utf-8"))
def _archive_name(self, target: str, backup_uid: str) -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
tail = backup_uid.replace("-", "")[-8:]
return f"{target}-{stamp}-{tail}.tar.gz"
def _fire_due_schedules(self) -> None:
reference = to_iso(now_utc())
for schedule in store.list_due_schedules(reference):
self._enqueue_scheduled(schedule, reference)
def _enqueue_scheduled(self, schedule: dict, reference: str) -> None:
target = schedule["target"]
if not store.is_valid_target(target):
self.log(f"Schedule {schedule['uid']} skipped: unknown target {target}")
return
keep_last = int(schedule.get("keep_last") or 0)
created_by = schedule.get("created_by", "")
job_uid = queue.enqueue(
"backup",
{
"target": target,
"schedule_uid": schedule["uid"],
"created_by": created_by,
"keep_last": keep_last,
},
owner_kind="system",
owner_id=created_by,
preferred_name=f"{schedule.get('name', target)} ({target})",
)
store.create_backup(
target=target,
created_by=created_by,
job_uid=job_uid,
schedule_uid=schedule["uid"],
)
moment = schedule_next_run(
schedule["kind"],
int(schedule.get("every_seconds") or 0),
schedule.get("cron") or None,
now_utc(),
)
store.set_schedule_runtime(
schedule["uid"],
next_run_at=to_iso(moment) if moment else "",
last_run_at=reference,
last_job_uid=job_uid,
run_count=int(schedule.get("run_count") or 0) + 1,
)
self.log(f"Scheduled backup '{schedule.get('name')}' enqueued ({job_uid})")
+437
View File
@@ -0,0 +1,437 @@
# retoor <retoor@molodetz.nl>
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from devplacepy import config
from devplacepy.database import _index, db, get_table
from devplacepy.utils import generate_uid
BACKUP_TARGETS: dict[str, dict] = {
"database": {
"label": "Database",
"description": "Consistent snapshot of the SQLite database and the Devii task and lesson databases.",
},
"uploads": {
"label": "Uploads",
"description": "All attachments and project files under the uploads directory.",
},
"keys": {
"label": "Keys and config",
"description": "VAPID notification keys and other small config artifacts.",
},
"full": {
"label": "Full data directory",
"description": "Database, uploads, and keys in one archive, excluding regenerable staging, locks, and caches.",
},
}
STATUS_PENDING = "pending"
STATUS_RUNNING = "running"
STATUS_DONE = "done"
STATUS_FAILED = "failed"
STORAGE_CACHE_TTL_SECONDS = 30
_storage_cache: dict = {"at": 0.0, "data": None}
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def is_valid_target(key: str) -> bool:
return key in BACKUP_TARGETS
def target_label(key: str) -> str:
return BACKUP_TARGETS.get(key, {}).get("label", key)
def human_bytes(size: int) -> str:
value = float(max(0, int(size or 0)))
for unit in ("B", "KB", "MB", "GB", "TB"):
if value < 1024 or unit == "TB":
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
value /= 1024
return f"{value:.1f} TB"
def ensure_tables() -> None:
backups = get_table("backups")
for column, example in (
("uid", ""),
("job_uid", ""),
("target", ""),
("label", ""),
("status", STATUS_PENDING),
("filename", ""),
("local_path", ""),
("size_bytes", 0),
("bytes_in", 0),
("file_count", 0),
("dir_count", 0),
("sha256", ""),
("schedule_uid", ""),
("created_by", ""),
("created_at", ""),
("completed_at", ""),
("error", ""),
):
if not backups.has_column(column):
backups.create_column_by_example(column, example)
schedules = get_table("backup_schedules")
for column, example in (
("uid", ""),
("name", ""),
("target", ""),
("kind", "interval"),
("every_seconds", 0),
("cron", ""),
("enabled", 1),
("keep_last", 0),
("next_run_at", ""),
("last_run_at", ""),
("last_job_uid", ""),
("run_count", 0),
("created_by", ""),
("created_at", ""),
("updated_at", ""),
("deleted_at", None),
("deleted_by", None),
):
if not schedules.has_column(column):
schedules.create_column_by_example(column, example)
_index(db, "backups", "idx_backups_status", ["status"])
_index(db, "backups", "idx_backups_job", ["job_uid"])
_index(db, "backups", "idx_backups_schedule", ["schedule_uid", "created_at"])
_index(db, "backup_schedules", "idx_backup_schedules_enabled", ["enabled"])
def create_backup(
*, target: str, created_by: str, job_uid: str, schedule_uid: str = ""
) -> str:
uid = generate_uid()
get_table("backups").insert(
{
"uid": uid,
"job_uid": job_uid,
"target": target,
"label": target_label(target),
"status": STATUS_PENDING,
"filename": "",
"local_path": "",
"size_bytes": 0,
"bytes_in": 0,
"file_count": 0,
"dir_count": 0,
"sha256": "",
"schedule_uid": schedule_uid,
"created_by": created_by,
"created_at": now_iso(),
"completed_at": "",
"error": "",
}
)
return uid
def get_backup(uid: str) -> dict | None:
if "backups" not in db.tables:
return None
return get_table("backups").find_one(uid=uid)
def get_backup_by_job(job_uid: str) -> dict | None:
if "backups" not in db.tables:
return None
return get_table("backups").find_one(job_uid=job_uid)
def list_backups(limit: int = 100) -> list[dict]:
if "backups" not in db.tables:
return []
return list(get_table("backups").find(order_by=["-created_at"], _limit=limit))
def mark_running(uid: str) -> None:
get_table("backups").update(
{"uid": uid, "status": STATUS_RUNNING}, ["uid"]
)
def finalize_backup(uid: str, *, filename: str, local_path: str, stats: dict) -> None:
get_table("backups").update(
{
"uid": uid,
"status": STATUS_DONE,
"filename": filename,
"local_path": local_path,
"size_bytes": int(stats.get("bytes_out", 0)),
"bytes_in": int(stats.get("bytes_in", 0)),
"file_count": int(stats.get("file_count", 0)),
"dir_count": int(stats.get("dir_count", 0)),
"sha256": stats.get("sha256", ""),
"completed_at": now_iso(),
"error": "",
},
["uid"],
)
def fail_backup(uid: str, error: str) -> None:
get_table("backups").update(
{
"uid": uid,
"status": STATUS_FAILED,
"completed_at": now_iso(),
"error": error[:2000],
},
["uid"],
)
def delete_backup(uid: str) -> dict | None:
row = get_backup(uid)
if not row:
return None
_unlink_archive(row)
get_table("backups").delete(uid=uid)
return row
def rotate_schedule(schedule_uid: str, keep_last: int) -> int:
if keep_last <= 0 or not schedule_uid:
return 0
rows = list(
get_table("backups").find(
schedule_uid=schedule_uid,
status=STATUS_DONE,
order_by=["-created_at"],
)
)
removed = 0
for row in rows[keep_last:]:
delete_backup(row["uid"])
removed += 1
return removed
def prune_orphans() -> int:
removed = 0
for row in list_backups(limit=100000):
if row.get("status") != STATUS_DONE:
continue
local_path = row.get("local_path") or ""
if not local_path or not Path(local_path).is_file():
get_table("backups").delete(uid=row["uid"])
removed += 1
return removed
def clear_all() -> int:
rows = list_backups(limit=100000)
for row in rows:
_unlink_archive(row)
get_table("backups").delete()
return len(rows)
def _unlink_archive(row: dict) -> None:
local_path = row.get("local_path") or ""
if local_path:
Path(local_path).unlink(missing_ok=True)
def create_schedule(
*,
name: str,
target: str,
kind: str,
every_seconds: int,
cron: str,
keep_last: int,
created_by: str,
next_run_at: str,
) -> str:
uid = generate_uid()
get_table("backup_schedules").insert(
{
"uid": uid,
"name": name,
"target": target,
"kind": kind,
"every_seconds": every_seconds,
"cron": cron,
"enabled": 1,
"keep_last": keep_last,
"next_run_at": next_run_at,
"last_run_at": "",
"last_job_uid": "",
"run_count": 0,
"created_by": created_by,
"created_at": now_iso(),
"updated_at": now_iso(),
"deleted_at": None,
"deleted_by": None,
}
)
return uid
def get_schedule(uid: str) -> dict | None:
if "backup_schedules" not in db.tables:
return None
return get_table("backup_schedules").find_one(uid=uid, deleted_at=None)
def list_schedules() -> list[dict]:
if "backup_schedules" not in db.tables:
return []
return list(
get_table("backup_schedules").find(
deleted_at=None, order_by=["-created_at"]
)
)
def list_due_schedules(reference: str) -> list[dict]:
if "backup_schedules" not in db.tables:
return []
rows = get_table("backup_schedules").find(deleted_at=None, enabled=1)
return [
row
for row in rows
if (row.get("next_run_at") or "") and row["next_run_at"] <= reference
]
def update_schedule(uid: str, changes: dict) -> None:
changes = {**changes, "uid": uid, "updated_at": now_iso()}
get_table("backup_schedules").update(changes, ["uid"])
def set_schedule_runtime(
uid: str, *, next_run_at: str, last_run_at: str, last_job_uid: str, run_count: int
) -> None:
get_table("backup_schedules").update(
{
"uid": uid,
"next_run_at": next_run_at,
"last_run_at": last_run_at,
"last_job_uid": last_job_uid,
"run_count": run_count,
"updated_at": now_iso(),
},
["uid"],
)
def delete_schedule(uid: str, deleted_by: str) -> bool:
row = get_schedule(uid)
if not row:
return False
get_table("backup_schedules").update(
{"uid": uid, "deleted_at": now_iso(), "deleted_by": deleted_by}, ["uid"]
)
return True
def _path_size(path: Path) -> tuple[int, int]:
if not path.exists():
return 0, 0
if path.is_file():
return path.stat().st_size, 1
total = 0
files = 0
for entry in path.rglob("*"):
try:
if entry.is_file() and not entry.is_symlink():
total += entry.stat().st_size
files += 1
except OSError:
continue
return total, files
def _storage_paths() -> list[tuple[str, str, Path]]:
database_file = Path(str(config.DATABASE_URL).replace("sqlite:///", ""))
return [
("database", "Database file", database_file),
("devii_tasks", "Devii tasks DB", config.DEVII_TASKS_DB),
("devii_lessons", "Devii lessons DB", config.DEVII_LESSONS_DB),
("uploads", "Uploads", config.UPLOADS_DIR),
("attachments", "Attachments", config.ATTACHMENTS_DIR),
("project_files", "Project files", config.PROJECT_FILES_DIR),
("keys", "Keys", config.KEYS_DIR),
("zips", "Zip archives", config.ZIPS_DIR),
("deepsearch", "DeepSearch", config.DEEPSEARCH_DIR),
("container_workspaces", "Container workspaces", config.CONTAINER_WORKSPACES_DIR),
("backups", "Backups", config.BACKUPS_DIR),
]
def compute_storage_stats() -> dict:
now = time.monotonic()
if (
_storage_cache["data"] is not None
and (now - _storage_cache["at"]) < STORAGE_CACHE_TTL_SECONDS
):
return _storage_cache["data"]
paths = []
for key, label, path in _storage_paths():
size, files = _path_size(path)
paths.append(
{
"key": key,
"label": label,
"path": str(path),
"size_bytes": size,
"size_human": human_bytes(size),
"file_count": files,
"exists": path.exists(),
}
)
data_size, data_files = _path_size(config.DATA_DIR)
backups_size, backups_files = _path_size(config.BACKUPS_DIR)
backup_count = len(
[b for b in list_backups(limit=100000) if b.get("status") == STATUS_DONE]
)
usage = shutil.disk_usage(str(config.DATA_DIR))
data = {
"paths": paths,
"data_dir": {
"path": str(config.DATA_DIR),
"size_bytes": data_size,
"size_human": human_bytes(data_size),
"file_count": data_files,
},
"backups_total": {
"count": backup_count,
"size_bytes": backups_size,
"size_human": human_bytes(backups_size),
"file_count": backups_files,
},
"disk": {
"total_bytes": usage.total,
"used_bytes": usage.used,
"free_bytes": usage.free,
"total_human": human_bytes(usage.total),
"used_human": human_bytes(usage.used),
"free_human": human_bytes(usage.free),
"used_percent": round(usage.used / usage.total * 100, 1)
if usage.total
else 0.0,
},
"generated_at": now_iso(),
}
_storage_cache["data"] = data
_storage_cache["at"] = now
return data
+4
View File
@@ -269,6 +269,10 @@ async def create_instance(
store.record_event(
instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE}
)
if actor and actor[0] == "user":
from devplacepy.utils import track_action
track_action(actor[1], "container")
return instance
+192
View File
@@ -0,0 +1,192 @@
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import httpx
from devplacepy import stealth
from devplacepy.config import (
DEFAULT_CORRECTION_PROMPT,
INTERNAL_GATEWAY_URL,
INTERNAL_MODEL,
)
from devplacepy.database import add_correction_usage, get_table
from devplacepy.services.background import background
logger = logging.getLogger(__name__)
CORRECTABLE_FIELDS: dict[str, tuple[str, ...]] = {
"posts": ("title", "content"),
"projects": ("title", "description"),
"gists": ("title", "description"),
"comments": ("content",),
"messages": ("content",),
"users": ("bio",),
}
CORRECTION_TIMEOUT_SECONDS = 20.0
MAX_GROWTH_FACTOR = 3
PENDING_SCOPE_KEY = "devplace_pending_corrections"
def _usage_from_headers(response_headers) -> dict:
def _int(name: str) -> int:
try:
return int(response_headers.get(name) or 0)
except (TypeError, ValueError):
return 0
def _float(name: str) -> float:
try:
return float(response_headers.get(name) or 0.0)
except (TypeError, ValueError):
return 0.0
return {
"calls": 1,
"prompt_tokens": _int("X-Gateway-Prompt-Tokens"),
"completion_tokens": _int("X-Gateway-Completion-Tokens"),
"total_tokens": _int("X-Gateway-Total-Tokens"),
"cost_usd": _float("X-Gateway-Cost-USD"),
"upstream_latency_ms": _float("X-Gateway-Upstream-Latency-Ms"),
"total_latency_ms": _float("X-Gateway-Total-Latency-Ms"),
}
def gateway_complete(
api_key: str,
system: str,
text: str,
timeout: float,
max_growth_factor: int | None = None,
) -> tuple[str, dict | None]:
text = text or ""
if not text.strip():
return text, None
payload = {
"model": INTERNAL_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": text},
],
"temperature": 0.1,
}
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
with stealth.stealth_sync_client(timeout=timeout) as client:
response = client.post(INTERNAL_GATEWAY_URL, json=payload, headers=headers)
response.raise_for_status()
usage = _usage_from_headers(response.headers)
content = (
response.json()
.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
.strip()
)
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
logger.warning("AI gateway completion failed, keeping original: %s", exc)
return text, None
if not content:
return text, usage
if max_growth_factor and len(content) > len(text) * max_growth_factor + 200:
logger.warning("AI gateway output too large, keeping original")
return text, usage
return content, usage
def correct_text(api_key: str, prompt: str, text: str) -> tuple[str, dict | None]:
system = (
"You are a text correction engine. Apply the correction instruction below to "
"the user's message and return ONLY the resulting text, with no preamble, no "
"explanation, no quotes and no code fences. Preserve the original meaning, "
"language, line breaks and markdown. Correction instruction: "
+ (prompt or DEFAULT_CORRECTION_PROMPT).strip()
)
return gateway_complete(
api_key, system, text, CORRECTION_TIMEOUT_SECONDS, MAX_GROWTH_FACTOR
)
def schedule_correction(
user: dict | None, table: str, uid: str, request: object = None
) -> None:
if not user or table not in CORRECTABLE_FIELDS or not uid:
return
if not user.get("ai_correction_enabled"):
return
api_key = (user.get("api_key") or "").strip()
if not api_key:
return
prompt = (user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT).strip()
user_uid = user.get("uid") or ""
if user.get("ai_correction_sync") and _run_inline_awaited(
api_key, prompt, table, uid, user_uid, request
):
return
background.submit(_run_correction, api_key, prompt, table, uid, user_uid)
def _run_inline_awaited(
api_key: str,
prompt: str,
table: str,
uid: str,
user_uid: str,
request: object,
) -> bool:
scope = getattr(request, "scope", None)
if scope is None:
return False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return False
future = loop.run_in_executor(
None, _run_correction, api_key, prompt, table, uid, user_uid
)
scope.setdefault(PENDING_SCOPE_KEY, []).append(future)
return True
def _run_correction(
api_key: str, prompt: str, table: str, uid: str, user_uid: str
) -> None:
fields = CORRECTABLE_FIELDS.get(table)
if not fields:
return
row = get_table(table).find_one(uid=uid)
if not row:
return
updates: dict = {}
totals = {
"calls": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cost_usd": 0.0,
"upstream_latency_ms": 0.0,
"total_latency_ms": 0.0,
}
for field in fields:
original = row.get(field) or ""
if not original.strip():
continue
corrected, usage = correct_text(api_key, prompt, original)
if usage:
for key in totals:
totals[key] += usage[key]
if corrected and corrected != original:
updates[field] = corrected
if updates:
updates["uid"] = uid
get_table(table).update(updates, ["uid"])
if table == "users" and user_uid:
from devplacepy.utils import clear_user_cache
clear_user_cache(user_uid)
if totals["calls"] and user_uid:
add_correction_usage(user_uid, totals)
@@ -0,0 +1,68 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
AI_CORRECTION_ACTIONS: tuple[Action, ...] = (
Action(
name="ai_correction_get",
method="LOCAL",
path="",
summary="Show the user's AI content correction setting and prompt",
description=(
"Returns whether automatic AI content correction is enabled for the user and the "
"correction instruction in use. Use this before changing the setting."
),
handler="ai_correction",
requires_auth=True,
read_only=True,
),
Action(
name="ai_correction_set",
method="LOCAL",
path="",
summary="Enable or disable AI content correction and set its prompt",
description=(
"Turns automatic AI content correction on or off for the user. When enabled, prose the "
"user authors (posts, comments, projects, gists, direct messages, profile bio) is "
"rewritten using the supplied instruction and the user's own API key. By default the "
"correction runs in the background (applied just after saving); pass sync=true to apply it "
"synchronously so the save waits for the correction. "
"Pass 'prompt' to change the correction instruction; omit it to keep the current one."
),
handler="ai_correction",
requires_auth=True,
params=(
arg(
"enabled",
"true to enable automatic correction, false to disable it.",
required=True,
kind="boolean",
),
arg(
"sync",
"true to apply correction synchronously (the save waits), false for background. "
"Omit to keep the current mode.",
kind="boolean",
),
arg(
"prompt",
"The correction instruction (max 2000 chars). Omit to keep the current one.",
),
),
),
)
@@ -0,0 +1,71 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
AI_MODIFIER_ACTIONS: tuple[Action, ...] = (
Action(
name="ai_modifier_get",
method="LOCAL",
path="",
summary="Show the user's AI modifier setting and prompt",
description=(
"Returns whether the AI modifier is enabled for the user and the instruction in use. "
"The modifier runs only when authored text contains an inline '@ai <instruction>' "
"directive: it executes that instruction and replaces the marked part. Use this before "
"changing the setting."
),
handler="ai_modifier",
requires_auth=True,
read_only=True,
),
Action(
name="ai_modifier_set",
method="LOCAL",
path="",
summary="Enable or disable the AI modifier and set its prompt",
description=(
"Turns the AI modifier on or off for the user. When enabled, prose the user authors "
"(posts, comments, projects, gists, direct messages, profile bio) is rewritten using the "
"supplied instruction and the user's own API key, but ONLY where the text contains an "
"inline '@ai <instruction>' directive: that part is executed and the '@ai' marker removed. "
"By default the modification runs synchronously (the save waits); pass sync=false to apply "
"it in the background just after saving. Pass 'prompt' to change the instruction; omit it to "
"keep the current one."
),
handler="ai_modifier",
requires_auth=True,
params=(
arg(
"enabled",
"true to enable the AI modifier, false to disable it.",
required=True,
kind="boolean",
),
arg(
"sync",
"true to apply the modification synchronously (the save waits), false for background. "
"Omit to keep the current mode.",
kind="boolean",
),
arg(
"prompt",
"The modifier instruction (max 2000 chars). Omit to keep the current one.",
),
),
),
)
@@ -1415,6 +1415,96 @@ ACTIONS: tuple[Action, ...] = (
params=(confirm(),),
requires_admin=True,
),
Action(
name="backups_overview",
method="GET",
path="/admin/backups/data",
summary="Backup dashboard: storage usage, backups, and schedules (admin only)",
description=(
"Returns JSON: per-path storage usage (database, uploads, attachments, project files, "
"keys, zips, deepsearch, container workspaces, backups), total data-directory size, total "
"stored-backup size and count, disk usage (total/used/free/percent), every backup archive "
"(target, status, size, file count, sha256 checksum, created/completed time, download_url), "
"and every configured backup schedule. Use this for any question about backups, data "
"footprint, disk space, or what is scheduled."
),
requires_admin=True,
),
Action(
name="backup_run",
method="POST",
path="/admin/backups/run",
summary="Start a backup of a data target (admin only)",
description=(
"Enqueues an async backup job and returns its job uid and status_url. Target is one of: "
"database (consistent SQLite snapshot plus Devii databases), uploads (attachments and "
"project files), keys (VAPID keys and config), or full (database, uploads, and keys in one "
"archive). Poll backup_status with the returned uid until status is done, then read the "
"download_url. The job runs off the request path and never blocks the server."
),
params=(
body("target", "One of database, uploads, keys, full.", required=True),
),
requires_admin=True,
),
Action(
name="backup_status",
method="GET",
path="/admin/backups/jobs/{uid}",
summary="Check the status of a backup job (admin only)",
description=(
"Returns JSON for one backup job: status (pending, running, done, failed), target, "
"backup_uid, download_url (when done), sha256, archive size, file count, and timestamps. "
"Poll this after backup_run."
),
params=(path("uid", "Backup job uid returned by backup_run."),),
requires_admin=True,
),
Action(
name="backup_delete",
method="POST",
path="/admin/backups/{uid}/delete",
summary="Permanently delete a backup archive (admin only)",
description=(
"Removes a backup archive file and its record. This is irreversible and reclaims disk "
"space. The uid is a backup uid (from backups_overview), not a job uid."
),
params=(path("uid", "Backup uid to delete."), confirm()),
requires_admin=True,
),
Action(
name="backup_schedule_create",
method="POST",
path="/admin/backups/schedules/create",
summary="Create a recurring backup schedule (admin only)",
description=(
"Creates a schedule that fires backups automatically. kind is 'interval' (use every_seconds, "
"minimum 60) or 'cron' (use a 5-field cron expression in 'minute hour dom month dow'). "
"keep_last rotates older backups of this schedule, keeping only the newest N (0 keeps all). "
"target is one of database, uploads, keys, full."
),
params=(
body("name", "Human-readable schedule name.", required=True),
body("target", "One of database, uploads, keys, full.", required=True),
body("kind", "interval or cron.", required=True),
body("every_seconds", "Seconds between runs when kind=interval (min 60)."),
body("cron", "Cron expression when kind=cron, e.g. '0 3 * * *'."),
body("keep_last", "Keep only the newest N backups of this schedule (0 = all)."),
),
requires_admin=True,
),
Action(
name="backup_schedule_delete",
method="POST",
path="/admin/backups/schedules/{uid}/delete",
summary="Delete a backup schedule (admin only)",
description=(
"Removes a backup schedule so it stops firing. Existing backup archives are kept; only the "
"schedule is deleted."
),
params=(path("uid", "Backup schedule uid."), confirm()),
requires_admin=True,
),
Action(
name="restore_media",
method="POST",
@@ -50,6 +50,8 @@ CONFIRM_REQUIRED = {
"admin_reset_all_ai_quota",
"admin_reset_guest_ai_quota",
"admin_reset_user_ai_quota",
"backup_delete",
"backup_schedule_delete",
"notification_reset",
"db_insert_row",
"db_update_row",
@@ -285,6 +287,12 @@ class Dispatcher:
from ..notification import NotificationController
self._notification = NotificationController(owner_kind, owner_id)
from ..ai_correction import AiCorrectionController
self._ai_correction = AiCorrectionController(owner_kind, owner_id)
from ..ai_modifier import AiModifierController
self._ai_modifier = AiModifierController(owner_kind, owner_id)
self._virtual_tools = virtual_tools
self._behavior = behavior
self._read_files: set[tuple[str, str]] = set()
@@ -434,6 +442,12 @@ class Dispatcher:
if action.handler == "notification":
return await self._notification.dispatch(action.name, arguments)
if action.handler == "ai_correction":
return await self._ai_correction.dispatch(action.name, arguments)
if action.handler == "ai_modifier":
return await self._ai_modifier.dispatch(action.name, arguments)
if action.handler == "behavior":
if self._behavior is None:
return error_result(
@@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from .controller import AiCorrectionController
__all__ = ["AiCorrectionController"]
@@ -0,0 +1,94 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
from typing import Any
from devplacepy.config import DEFAULT_CORRECTION_PROMPT
from devplacepy.database import get_table
from devplacepy.services.devii.errors import ToolInputError
logger = logging.getLogger("devii.ai_correction")
class AiCorrectionController:
def __init__(self, owner_kind: str, owner_id: str) -> None:
self._owner_kind = owner_kind
self._owner_id = owner_id
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
if name == "ai_correction_get":
return self._get()
if name == "ai_correction_set":
return self._set(arguments)
raise ToolInputError(f"Unknown AI correction tool: {name}")
def _require_user(self) -> None:
if self._owner_kind != "user":
raise ToolInputError(
"AI content correction is only available for signed-in users."
)
def _coerce_bool(self, raw: Any) -> bool:
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() in ("true", "1", "yes", "on")
def _value(self, arguments: dict[str, Any]) -> bool:
return self._coerce_bool(arguments.get("enabled"))
def _user(self) -> dict[str, Any]:
user = get_table("users").find_one(uid=self._owner_id)
if not user:
raise ToolInputError("User not found.")
return user
def _get(self) -> str:
self._require_user()
user = self._user()
return json.dumps(
{
"status": "success",
"enabled": bool(user.get("ai_correction_enabled")),
"sync": bool(user.get("ai_correction_sync")),
"prompt": user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT,
},
ensure_ascii=False,
)
def _set(self, arguments: dict[str, Any]) -> str:
self._require_user()
user = self._user()
enabled = 1 if self._value(arguments) else 0
if "sync" in arguments and arguments.get("sync") is not None:
sync = 1 if self._coerce_bool(arguments.get("sync")) else 0
else:
sync = 1 if user.get("ai_correction_sync") else 0
prompt_raw = arguments.get("prompt")
if prompt_raw is None:
prompt = user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT
else:
prompt = str(prompt_raw).strip()[:2000] or DEFAULT_CORRECTION_PROMPT
get_table("users").update(
{
"uid": self._owner_id,
"ai_correction_enabled": enabled,
"ai_correction_sync": sync,
"ai_correction_prompt": prompt,
},
["uid"],
)
from devplacepy.utils import clear_user_cache
clear_user_cache(self._owner_id)
return json.dumps(
{
"status": "success",
"enabled": bool(enabled),
"sync": bool(sync),
"prompt": prompt,
},
ensure_ascii=False,
)
@@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from .controller import AiModifierController
__all__ = ["AiModifierController"]
@@ -0,0 +1,94 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
from typing import Any
from devplacepy.config import DEFAULT_MODIFIER_PROMPT
from devplacepy.database import get_table
from devplacepy.services.devii.errors import ToolInputError
logger = logging.getLogger("devii.ai_modifier")
class AiModifierController:
def __init__(self, owner_kind: str, owner_id: str) -> None:
self._owner_kind = owner_kind
self._owner_id = owner_id
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
if name == "ai_modifier_get":
return self._get()
if name == "ai_modifier_set":
return self._set(arguments)
raise ToolInputError(f"Unknown AI modifier tool: {name}")
def _require_user(self) -> None:
if self._owner_kind != "user":
raise ToolInputError(
"AI modifier is only available for signed-in users."
)
def _coerce_bool(self, raw: Any) -> bool:
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() in ("true", "1", "yes", "on")
def _value(self, arguments: dict[str, Any]) -> bool:
return self._coerce_bool(arguments.get("enabled"))
def _user(self) -> dict[str, Any]:
user = get_table("users").find_one(uid=self._owner_id)
if not user:
raise ToolInputError("User not found.")
return user
def _get(self) -> str:
self._require_user()
user = self._user()
return json.dumps(
{
"status": "success",
"enabled": bool(user.get("ai_modifier_enabled")),
"sync": bool(user.get("ai_modifier_sync")),
"prompt": user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT,
},
ensure_ascii=False,
)
def _set(self, arguments: dict[str, Any]) -> str:
self._require_user()
user = self._user()
enabled = 1 if self._value(arguments) else 0
if "sync" in arguments and arguments.get("sync") is not None:
sync = 1 if self._coerce_bool(arguments.get("sync")) else 0
else:
sync = 1 if user.get("ai_modifier_sync") else 0
prompt_raw = arguments.get("prompt")
if prompt_raw is None:
prompt = user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT
else:
prompt = str(prompt_raw).strip()[:2000] or DEFAULT_MODIFIER_PROMPT
get_table("users").update(
{
"uid": self._owner_id,
"ai_modifier_enabled": enabled,
"ai_modifier_sync": sync,
"ai_modifier_prompt": prompt,
},
["uid"],
)
from devplacepy.utils import clear_user_cache
clear_user_cache(self._owner_id)
return json.dumps(
{
"status": "success",
"enabled": bool(enabled),
"sync": bool(sync),
"prompt": prompt,
},
ensure_ascii=False,
)
+4
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from .actions.ai_correction_actions import AI_CORRECTION_ACTIONS
from .actions.ai_modifier_actions import AI_MODIFIER_ACTIONS
from .actions.avatar_actions import AVATAR_ACTIONS
from .actions.behavior_actions import BEHAVIOR_ACTIONS
from .actions.catalog import ACTIONS
@@ -34,6 +36,8 @@ CATALOG = Catalog(
+ CUSTOMIZATION_ACTIONS
+ BEHAVIOR_ACTIONS
+ NOTIFICATION_ACTIONS
+ AI_CORRECTION_ACTIONS
+ AI_MODIFIER_ACTIONS
+ VIRTUAL_TOOL_ACTIONS
)
+4
View File
@@ -401,6 +401,10 @@ class DeviiSession:
finally:
if not cancelled and epoch == self._turn_epoch:
self._record_turn(turn_id, started_at, text, reply, error, before)
if self.owner_kind == "user" and self.channel != "docs" and not error:
from devplacepy.utils import track_action
track_action(self.owner_id, "devii")
def _builtin_tools(self) -> list[dict[str, Any]]:
schemas = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
+83
View File
@@ -0,0 +1,83 @@
# retoor <retoor@molodetz.nl>
import hashlib
import json
import sys
import tarfile
from pathlib import Path
CHUNK_SIZE = 1024 * 1024
def _reset(info: tarfile.TarInfo) -> tarfile.TarInfo:
info.uid = 0
info.gid = 0
info.uname = ""
info.gname = ""
return info
def _add_file(archive: tarfile.TarFile, full: Path, arcname: str) -> int:
size = full.stat().st_size
info = _reset(archive.gettarinfo(str(full), arcname=arcname))
with full.open("rb") as handle:
archive.addfile(info, handle)
return size
def _build(spec: dict, output_path: str) -> dict:
bytes_in = 0
file_count = 0
dir_count = 0
with tarfile.open(output_path, "w:gz") as archive:
for source in spec.get("sources", []):
root = source["root"]
base = Path(source["path"])
if not base.exists():
continue
if base.is_file():
bytes_in += _add_file(archive, base, f"{root}/{base.name}")
file_count += 1
continue
for entry in sorted(base.rglob("*"), key=lambda p: str(p)):
arcname = f"{root}/{entry.relative_to(base)}"
if entry.is_symlink():
continue
if entry.is_dir():
info = _reset(archive.gettarinfo(str(entry), arcname=arcname))
archive.addfile(info)
dir_count += 1
elif entry.is_file():
try:
bytes_in += _add_file(archive, entry, arcname)
file_count += 1
except OSError:
continue
digest = hashlib.sha256()
with open(output_path, "rb") as handle:
for chunk in iter(lambda: handle.read(CHUNK_SIZE), b""):
digest.update(chunk)
return {
"bytes_in": bytes_in,
"bytes_out": Path(output_path).stat().st_size,
"file_count": file_count,
"dir_count": dir_count,
"sha256": digest.hexdigest(),
}
def main(argv: list) -> int:
if len(argv) != 3:
sys.stderr.write("usage: backup_worker <spec_json> <output_tar_gz>\n")
return 2
spec_path, output_path = argv[1], argv[2]
spec = json.loads(Path(spec_path).read_text())
stats = _build(spec, output_path)
sys.stdout.write(json.dumps(stats))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+164
View File
@@ -0,0 +1,164 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import logging
import re
import time
from typing import Optional
from devplacepy.services.base import BaseService
from devplacepy.services.manager import service_manager
from devplacepy.services.pubsub import publish as pubsub_publish
from devplacepy.services.pubsub.hub import pubsub
logger = logging.getLogger(__name__)
_SEGMENT = r"[A-Za-z0-9_-]+"
LOG_TAIL = 400
async def _container_list(_match: re.Match) -> dict:
from devplacepy.routers.admin.containers import _decorate
from devplacepy.services.containers import store
return {"instances": _decorate(store.all_instances())}
async def _project_containers(match: re.Match) -> Optional[dict]:
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.services.containers import store
project = resolve_by_slug(get_table("projects"), match.group("slug"))
if not project:
return None
return {"instances": store.list_instances(project["uid"])}
async def _container_detail(match: re.Match) -> Optional[dict]:
from devplacepy.services.containers import api, store
uid = match.group("uid")
inst = store.get_instance(uid)
if not inst:
return None
return {
"instance": inst,
"events": store.list_events(uid),
"schedules": store.list_schedules(uid),
"stats": api.instance_stats(uid),
"runtime": api.instance_runtime(inst),
}
async def _container_logs(match: re.Match) -> Optional[dict]:
from devplacepy.services.containers import store
from devplacepy.services.containers.runtime import get_backend
uid = match.group("uid")
inst = store.get_instance(uid)
if not inst:
return None
if not inst.get("container_id"):
return {"logs": ""}
lines: list = []
async def collect(line: str) -> None:
lines.append(line)
await get_backend().logs(
inst["container_id"], follow=False, tail=LOG_TAIL, on_log=collect
)
return {"logs": "\n".join(lines)}
async def _bots(_match: re.Match) -> dict:
from devplacepy.routers.admin.bots import _frames_payload
return _frames_payload()
async def _services(_match: re.Match) -> dict:
return {"services": service_manager.describe_all()}
async def _service_detail(match: re.Match) -> Optional[dict]:
svc = service_manager.get_service(match.group("name"))
if svc is None:
return None
return {"service": svc.describe()}
async def _ai_usage(match: re.Match) -> dict:
from devplacepy.services.openai_gateway.analytics import build_analytics
from devplacepy.services.openai_gateway.usage import pricing_from_cfg
hours = int(match.group("hours"))
svc = service_manager.get_service("openai")
pricing = pricing_from_cfg(svc.get_config()) if svc is not None else None
return build_analytics(hours, top_n=10, pricing=pricing)
VIEWS = [
(re.compile(r"^container\.list$"), _container_list, 4.0),
(re.compile(rf"^project\.(?P<slug>{_SEGMENT})\.containers$"), _project_containers, 3.0),
(re.compile(rf"^container\.(?P<uid>{_SEGMENT})\.detail$"), _container_detail, 4.0),
(re.compile(rf"^container\.(?P<uid>{_SEGMENT})\.logs$"), _container_logs, 3.0),
(re.compile(r"^fleet\.bots$"), _bots, 2.0),
(re.compile(r"^admin\.services$"), _services, 5.0),
(re.compile(rf"^admin\.services\.(?P<name>{_SEGMENT})$"), _service_detail, 5.0),
(re.compile(r"^admin\.ai-usage\.(?P<hours>\d+)$"), _ai_usage, 15.0),
]
class LiveViewRelayService(BaseService):
title = "Live view relay"
description = (
"Pushes admin live-view snapshots (container list and instances, bot fleet, "
"background services, AI usage) onto the pub/sub bus, computed only for topics "
"that currently have subscribers. Runs on the service lock owner where pub/sub "
"subscribers converge, replacing per-client HTTP polling with server push."
)
default_enabled = True
def __init__(self):
super().__init__(name="live_view_relay", interval_seconds=1)
self._last: dict[str, float] = {}
def _handler_for(self, topic: str):
for pattern, compute, interval in VIEWS:
match = pattern.match(topic)
if match is not None:
return compute, interval, match
return None
async def run_once(self) -> None:
now = time.monotonic()
active: set[str] = set()
published = 0
for entry in pubsub.topics():
topic = entry["topic"]
if not entry["subscribers"] or "*" in topic:
continue
handler = self._handler_for(topic)
if handler is None:
continue
active.add(topic)
compute, interval, match = handler
if now - self._last.get(topic, 0.0) < interval:
continue
self._last[topic] = now
try:
payload = await compute(match)
except Exception:
logger.exception("live view relay failed for %s", topic)
continue
if payload is None:
continue
published += await pubsub_publish(topic, payload)
self._last = {topic: ts for topic, ts in self._last.items() if topic in active}
if published:
self.log(f"pushed {published} live-view frame(s)")
def collect_metrics(self) -> dict:
return {"tracked_topics": len(self._last)}
+6
View File
@@ -12,8 +12,11 @@ from devplacepy.utils import (
create_notification,
generate_uid,
time_ago,
track_action,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
logger = logging.getLogger("messaging.persist")
@@ -86,6 +89,8 @@ def persist_message(
)
link_attachments(attachment_uids, "message", msg_uid)
schedule_correction(sender, "messages", msg_uid, request)
schedule_modification(sender, "messages", msg_uid, request)
if sender_uid != receiver_uid:
create_notification(
@@ -100,6 +105,7 @@ def persist_message(
create_mention_notifications(
content, sender_uid, f"/messages?with_uid={receiver_uid}"
)
track_action(sender_uid, "message")
logger.info(
"Message %s sent from %s to %s via %s",
+92
View File
@@ -0,0 +1,92 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from devplacepy.database import db
from devplacepy.services.base import BaseService
from devplacepy.services.pubsub import publish as pubsub_publish
BATCH_LIMIT = 500
class NotificationRelayService(BaseService):
title = "Notification relay"
description = (
"Bridges newly persisted in-app notifications onto the pub/sub bus so the "
"recipient's browser raises a live toast. Polls the notifications table on the "
"service lock owner, where every pub/sub subscriber converges. A toast fires "
"exactly when the in-app channel is enabled, since the row only exists then."
)
default_enabled = True
def __init__(self):
super().__init__(name="notification_relay", interval_seconds=1)
self._watermark = 0
self._primed = False
def _max_id(self) -> int:
if "notifications" not in db.tables:
return 0
rows = list(db.query("SELECT MAX(id) AS max_id FROM notifications"))
value = rows[0]["max_id"] if rows else None
return int(value or 0)
def _counts(self, user_uid: str) -> dict:
notifications = 0
messages = 0
if "notifications" in db.tables:
rows = list(
db.query(
"SELECT COUNT(*) AS c FROM notifications "
"WHERE user_uid = :u AND read = 0",
u=user_uid,
)
)
notifications = int(rows[0]["c"]) if rows else 0
if "messages" in db.tables:
rows = list(
db.query(
"SELECT COUNT(*) AS c FROM messages "
"WHERE receiver_uid = :u AND read = 0",
u=user_uid,
)
)
messages = int(rows[0]["c"]) if rows else 0
return {"notifications": notifications, "messages": messages}
async def run_once(self) -> None:
if "notifications" not in db.tables:
return
if not self._primed:
self._watermark = self._max_id()
self._primed = True
return
rows = list(
db.query(
"SELECT id, uid, user_uid, type, message, target_url "
"FROM notifications WHERE id > :wm ORDER BY id ASC LIMIT :lim",
wm=self._watermark,
lim=BATCH_LIMIT,
)
)
if not rows:
return
delivered = 0
for row in rows:
delivered += await pubsub_publish(
f"user.{row['user_uid']}.notifications",
{
"uid": row["uid"],
"type": row["type"],
"message": row["message"],
"target_url": row["target_url"],
},
)
for user_uid in {row["user_uid"] for row in rows}:
await pubsub_publish(f"user.{user_uid}.counts", self._counts(user_uid))
self._watermark = max(row["id"] for row in rows)
if delivered:
self.log(f"relayed {len(rows)} notification(s), {delivered} live toast(s)")
def collect_metrics(self) -> dict:
return {"watermark": self._watermark, "primed": self._primed}
+39 -19
View File
@@ -20,6 +20,7 @@ from devplacepy.services.openai_gateway.usage import (
extract_params,
parse_context_map,
pricing_from_cfg,
usage_response_headers,
)
from devplacepy.services.openai_gateway.vision import VisionAugmenter, VisionCache
@@ -294,10 +295,12 @@ class GatewayRuntime:
base["success"] = success
base["error_category"] = category
base["usage"] = usage
self._ledger.record(base, pricing, context_map)
return usage_response_headers(
self._ledger.record(base, pricing, context_map)
)
if timing["circuit_open"]:
finalize(503, False, "circuit_open")
resp_headers = finalize(503, False, "circuit_open")
return JSONResponse(
status_code=503,
content={
@@ -306,9 +309,10 @@ class GatewayRuntime:
"type": "circuit_open",
}
},
headers=resp_headers,
)
if exc is not None:
finalize(502, False, classify_error(0, exc))
resp_headers = finalize(502, False, classify_error(0, exc))
return JSONResponse(
status_code=502,
content={
@@ -317,9 +321,10 @@ class GatewayRuntime:
"type": "upstream_error",
}
},
headers=resp_headers,
)
if resp.status_code != 200:
finalize(
resp_headers = finalize(
resp.status_code,
False,
classify_error(resp.status_code, None, resp.text),
@@ -328,12 +333,13 @@ class GatewayRuntime:
return JSONResponse(
status_code=resp.status_code,
content={"error": {"message": resp.text, "type": "upstream_error"}},
headers=resp_headers,
)
try:
data = resp.json()
except ValueError:
self.errors += 1
finalize(502, False, "gateway")
resp_headers = finalize(502, False, "gateway")
log("chat upstream returned 200 but body was not valid JSON")
return JSONResponse(
status_code=502,
@@ -343,14 +349,17 @@ class GatewayRuntime:
"type": "upstream_error",
}
},
headers=resp_headers,
)
finalize(200, True, None, data.get("usage"))
resp_headers = finalize(200, True, None, data.get("usage"))
log(f"chat POST -> 200 ({timing['upstream_latency_ms']:.0f}ms)")
if stream:
return StreamingResponse(
_fake_stream(data, model), media_type="text/event-stream"
_fake_stream(data, model),
media_type="text/event-stream",
headers=resp_headers,
)
return JSONResponse(content=data)
return JSONResponse(content=data, headers=resp_headers)
async def handle_embeddings(
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
@@ -447,10 +456,12 @@ class GatewayRuntime:
base["success"] = success
base["error_category"] = category
base["usage"] = usage
self._ledger.record(base, pricing, context_map)
return usage_response_headers(
self._ledger.record(base, pricing, context_map)
)
if timing["circuit_open"]:
finalize(503, False, "circuit_open")
resp_headers = finalize(503, False, "circuit_open")
return JSONResponse(
status_code=503,
content={
@@ -459,9 +470,10 @@ class GatewayRuntime:
"type": "circuit_open",
}
},
headers=resp_headers,
)
if exc is not None:
finalize(502, False, classify_error(0, exc))
resp_headers = finalize(502, False, classify_error(0, exc))
return JSONResponse(
status_code=502,
content={
@@ -470,9 +482,10 @@ class GatewayRuntime:
"type": "upstream_error",
}
},
headers=resp_headers,
)
if resp.status_code != 200:
finalize(
resp_headers = finalize(
resp.status_code,
False,
classify_error(resp.status_code, None, resp.text),
@@ -481,12 +494,13 @@ class GatewayRuntime:
return JSONResponse(
status_code=resp.status_code,
content={"error": {"message": resp.text, "type": "upstream_error"}},
headers=resp_headers,
)
try:
data = resp.json()
except ValueError:
self.errors += 1
finalize(502, False, "gateway")
resp_headers = finalize(502, False, "gateway")
log("embed upstream returned 200 but body was not valid JSON")
return JSONResponse(
status_code=502,
@@ -496,11 +510,12 @@ class GatewayRuntime:
"type": "upstream_error",
}
},
headers=resp_headers,
)
self.embed_calls += 1
finalize(200, True, None, data.get("usage"))
resp_headers = finalize(200, True, None, data.get("usage"))
log(f"embed POST -> 200 ({timing['upstream_latency_ms']:.0f}ms)")
return JSONResponse(content=data)
return JSONResponse(content=data, headers=resp_headers)
async def handle_passthrough(
self,
@@ -559,10 +574,12 @@ class GatewayRuntime:
base["success"] = success
base["error_category"] = category
base["usage"] = usage
self._ledger.record(base, pricing, context_map)
return usage_response_headers(
self._ledger.record(base, pricing, context_map)
)
if timing["circuit_open"]:
finalize(503, False, "circuit_open")
resp_headers = finalize(503, False, "circuit_open")
return JSONResponse(
status_code=503,
content={
@@ -571,9 +588,10 @@ class GatewayRuntime:
"type": "circuit_open",
}
},
headers=resp_headers,
)
if exc is not None:
finalize(502, False, classify_error(0, exc))
resp_headers = finalize(502, False, classify_error(0, exc))
return JSONResponse(
status_code=502,
content={
@@ -582,6 +600,7 @@ class GatewayRuntime:
"type": "upstream_error",
}
},
headers=resp_headers,
)
usage = None
if resp.status_code < 400 and "application/json" in (
@@ -591,7 +610,7 @@ class GatewayRuntime:
usage = resp.json().get("usage")
except ValueError:
usage = None
finalize(
resp_headers = finalize(
resp.status_code,
resp.status_code < 400,
None
@@ -606,6 +625,7 @@ class GatewayRuntime:
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type"),
headers=resp_headers,
)
def metrics(self) -> dict:
+33 -1
View File
@@ -219,8 +219,38 @@ def audit_actor_for(owner_kind: str, owner_id: str) -> tuple[str, Optional[str],
return actor_kind, actor_uid, actor_role
def usage_response_headers(row: Optional[dict]) -> dict:
if not row:
return {}
headers = {
"X-Gateway-Model": str(row.get("model") or ""),
"X-Gateway-Backend": str(row.get("backend") or ""),
"X-Gateway-Prompt-Tokens": str(int(row.get("prompt_tokens") or 0)),
"X-Gateway-Completion-Tokens": str(int(row.get("completion_tokens") or 0)),
"X-Gateway-Total-Tokens": str(int(row.get("total_tokens") or 0)),
"X-Gateway-Cache-Hit-Tokens": str(int(row.get("cache_hit_tokens") or 0)),
"X-Gateway-Cache-Miss-Tokens": str(int(row.get("cache_miss_tokens") or 0)),
"X-Gateway-Reasoning-Tokens": str(int(row.get("reasoning_tokens") or 0)),
"X-Gateway-Cost-USD": f"{float(row.get('cost_usd') or 0.0):.8f}",
"X-Gateway-Input-Cost-USD": f"{float(row.get('input_cost_usd') or 0.0):.8f}",
"X-Gateway-Output-Cost-USD": f"{float(row.get('output_cost_usd') or 0.0):.8f}",
"X-Gateway-Cost-Native": "1" if row.get("native_cost") else "0",
"X-Gateway-Tokens-Per-Second": str(row.get("tokens_per_second") or 0),
"X-Gateway-Upstream-Latency-Ms": str(row.get("upstream_latency_ms") or 0),
"X-Gateway-Total-Latency-Ms": str(row.get("total_latency_ms") or 0),
"X-Gateway-Gateway-Overhead-Ms": str(row.get("gateway_overhead_ms") or 0),
"X-Gateway-Queue-Wait-Ms": str(row.get("queue_wait_ms") or 0),
"X-Gateway-Connect-Ms": str(row.get("connect_ms") or 0),
}
if row.get("context_window"):
headers["X-Gateway-Context-Window"] = str(int(row["context_window"]))
if row.get("context_utilization") is not None:
headers["X-Gateway-Context-Utilization"] = str(row["context_utilization"])
return headers
class GatewayUsageLedger:
def record(self, raw: dict, pricing: Pricing, context_map: dict) -> None:
def record(self, raw: dict, pricing: Pricing, context_map: dict) -> Optional[dict]:
try:
usage = raw.get("usage") or {}
norm = normalize_usage(usage)
@@ -278,8 +308,10 @@ class GatewayUsageLedger:
}
get_table(GATEWAY_LEDGER).insert(row)
self._audit(raw, norm, cost_usd)
return row
except Exception as exc:
logger.warning("gateway usage record failed: %s", exc)
return None
def _audit(self, raw: dict, norm: dict, cost_usd: float) -> None:
from devplacepy.services.audit import record as audit
+16 -7
View File
@@ -9,6 +9,11 @@
.admin-content {
min-width: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.5rem;
}
.admin-toolbar {
@@ -41,28 +46,28 @@
gap: 0.4rem;
padding: 0.4rem 0.85rem;
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--border-color);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 600;
}
.admin-tab:hover {
color: var(--text-color);
border-color: var(--accent-color);
color: var(--text-primary);
border-color: var(--accent);
}
.admin-tab.active {
color: var(--accent-color);
border-color: var(--accent-color);
background: var(--accent-soft, rgba(127, 127, 127, 0.08));
color: var(--accent);
border-color: var(--accent);
background: var(--accent-light);
}
.admin-tab-count {
font-size: 0.6875rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--bg-muted, rgba(127, 127, 127, 0.15));
background: var(--bg-card-hover);
color: var(--text-muted);
}
@@ -425,6 +430,10 @@
}
@media (max-width: 480px) {
.admin-content {
padding: 1rem;
}
.admin-toolbar {
flex-direction: column;
align-items: flex-start;
+109
View File
@@ -0,0 +1,109 @@
/* retoor <retoor@molodetz.nl> */
.backup-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-md);
}
.backup-controls label {
display: flex;
align-items: center;
gap: var(--space-xs);
color: var(--text-secondary);
}
.backup-generated {
color: var(--text-muted);
font-size: 0.85rem;
}
.backups {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.backups-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-lg);
}
.backups-section h3 {
margin: 0 0 var(--space-md);
color: var(--text-primary);
}
.backups-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.backups-sha,
.backups-path {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-muted);
word-break: break-all;
}
.backups-missing {
color: var(--danger);
}
.backups-status {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: var(--radius);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
border: 1px solid var(--border);
}
.backups-status-done {
color: var(--success);
border-color: var(--success);
}
.backups-status-failed {
color: var(--danger);
border-color: var(--danger);
}
.backups-status-running,
.backups-status-pending {
color: var(--warning);
border-color: var(--warning);
}
.backup-schedule-form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.backup-schedule-form label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
color: var(--text-secondary);
}
.backup-schedule-form input,
.backup-schedule-form select {
padding: var(--space-sm);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
}
.backup-schedule-form .modal-actions {
display: flex;
justify-content: flex-end;
}
+2 -3
View File
@@ -338,6 +338,7 @@ body {
color: var(--text-primary);
min-height: 100vh;
line-height: 1.5;
padding-top: var(--nav-height);
}
a {
@@ -934,7 +935,7 @@ body:has(.page-messages) {
}
.page:has(.page-messages) {
padding-top: var(--nav-height);
padding-top: 0.5rem;
padding-bottom: 0;
flex: 1 1 auto;
min-height: 0;
@@ -989,7 +990,6 @@ body:has(.page-messages) {
max-width: var(--max-content);
margin: 0 auto;
padding: 0.5rem 1rem;
padding-top: calc(var(--nav-height) + 0.5rem);
font-size: 0.8125rem;
}
@@ -1089,7 +1089,6 @@ body:has(.page-messages) {
@media (max-width: 1024px) {
.breadcrumb {
padding: 0.375rem 0.5rem;
padding-top: calc(var(--nav-height) + 0.375rem);
font-size: 0.75rem;
}
}
+4
View File
@@ -271,6 +271,10 @@ dp-upload[hidden] {
transform: translateY(0);
}
.dp-toast-link {
cursor: pointer;
}
.dp-toast-success {
border-color: var(--success);
}
+143
View File
@@ -442,6 +442,25 @@ a.profile-stat-value:hover {
color: var(--text-secondary);
}
.ai-correction-prompt {
width: 100%;
margin: 0.5rem 0;
resize: vertical;
}
.ai-correction-status {
font-size: 0.75rem;
color: var(--text-secondary);
}
.ai-correction-status[data-state="success"] {
color: var(--success, #2f9e44);
}
.ai-correction-status[data-state="error"] {
color: var(--danger, #e03131);
}
.follow-row {
display: flex;
align-items: center;
@@ -515,6 +534,37 @@ a.profile-stat-value:hover {
color: var(--text-secondary);
}
.correction-usage-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: var(--space-sm);
}
.correction-usage-stat {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
}
.correction-usage-value {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
}
.correction-usage-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.correction-usage-cost .correction-usage-value {
color: var(--accent);
}
.ai-usage-empty {
color: var(--text-muted);
font-size: 0.85rem;
@@ -748,3 +798,96 @@ a.profile-stat-value:hover {
.notif-prefs-actions {
margin-top: 1rem;
}
.profile-achievements {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
}
.profile-achievements-summary {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: var(--text-primary);
list-style: none;
}
.profile-achievements-summary::-webkit-details-marker {
display: none;
}
.profile-achievements-count {
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.achievement-group {
margin-top: var(--space-md);
}
.achievement-group-title {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
margin: 0 0 var(--space-sm) 0;
display: flex;
gap: var(--space-sm);
align-items: baseline;
}
.achievement-group-count {
color: var(--text-muted);
font-size: 0.8rem;
}
.achievement-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-sm);
}
.achievement {
display: flex;
gap: var(--space-sm);
align-items: center;
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
}
.achievement.locked {
opacity: 0.45;
filter: grayscale(1);
}
.achievement.earned {
border-color: var(--accent);
}
.achievement-icon {
font-size: 1.5rem;
line-height: 1;
}
.achievement-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.achievement-name {
font-weight: 600;
color: var(--text-primary);
}
.achievement-desc {
font-size: 0.8rem;
color: var(--text-muted);
}
-1
View File
@@ -14,7 +14,6 @@
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
padding-top: calc(var(--nav-height) + 0.5rem);
scroll-margin-top: calc(var(--nav-height) + 1rem);
}
+52
View File
@@ -0,0 +1,52 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
class AiCorrection {
constructor() {
this.root = document.querySelector("[data-ai-correction]");
if (!this.root) return;
this.username = this.root.dataset.username;
this.toggle = this.root.querySelector("[data-ai-correction-toggle]");
this.mode = this.root.querySelector("[data-ai-correction-sync]");
this.prompt = this.root.querySelector("[data-ai-correction-prompt]");
this.status = this.root.querySelector("[data-ai-correction-status]");
this.save = this.root.querySelector("[data-ai-correction-save]");
if (this.save) this.save.addEventListener("click", () => this.submit());
}
async submit() {
if (!this.toggle || !this.prompt) return;
this.save.disabled = true;
this.setStatus("Saving...", "");
try {
await Http.send(`/profile/${this.username}/ai-correction`, {
enabled: this.toggle.checked ? "true" : "false",
sync: this.mode && this.mode.value === "sync" ? "true" : "false",
prompt: this.prompt.value,
});
this.setStatus("Saved", "success");
this.flash("AI correction settings saved", "success");
} catch (error) {
this.setStatus("Could not save", "error");
this.flash("Could not save AI correction settings", "error");
} finally {
this.save.disabled = false;
}
}
setStatus(message, type) {
if (!this.status) return;
this.status.textContent = message;
this.status.dataset.state = type;
}
flash(message, type) {
if (window.app && window.app.toast) {
window.app.toast.show(message, { type });
}
}
}
window.AiCorrection = AiCorrection;
export { AiCorrection };
+52
View File
@@ -0,0 +1,52 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
class AiModifier {
constructor() {
this.root = document.querySelector("[data-ai-modifier]");
if (!this.root) return;
this.username = this.root.dataset.username;
this.toggle = this.root.querySelector("[data-ai-modifier-toggle]");
this.mode = this.root.querySelector("[data-ai-modifier-sync]");
this.prompt = this.root.querySelector("[data-ai-modifier-prompt]");
this.status = this.root.querySelector("[data-ai-modifier-status]");
this.save = this.root.querySelector("[data-ai-modifier-save]");
if (this.save) this.save.addEventListener("click", () => this.submit());
}
async submit() {
if (!this.toggle || !this.prompt) return;
this.save.disabled = true;
this.setStatus("Saving...", "");
try {
await Http.send(`/profile/${this.username}/ai-modifier`, {
enabled: this.toggle.checked ? "true" : "false",
sync: this.mode && this.mode.value === "sync" ? "true" : "false",
prompt: this.prompt.value,
});
this.setStatus("Saved", "success");
this.flash("AI modifier settings saved", "success");
} catch (error) {
this.setStatus("Could not save", "error");
this.flash("Could not save AI modifier settings", "error");
} finally {
this.save.disabled = false;
}
}
setStatus(message, type) {
if (!this.status) return;
this.status.textContent = message;
this.status.dataset.state = type;
}
flash(message, type) {
if (window.app && window.app.toast) {
window.app.toast.show(message, { type });
}
}
}
window.AiModifier = AiModifier;
export { AiModifier };
+10 -1
View File
@@ -19,6 +19,7 @@ class AiUsageMonitor {
if (this.windowSelect) {
this.windowSelect.addEventListener("change", () => {
this.hours = parseInt(this.windowSelect.value, 10) || 48;
this.subscribe();
this.poll();
});
}
@@ -27,7 +28,15 @@ class AiUsageMonitor {
this.root.classList.toggle("show-divided", this.dividedToggle.checked);
});
}
this.poller = new Poller(() => this.poll(), this.pollMs);
this.subscribe();
this.poller = new Poller(() => this.poll(), 60000);
}
subscribe() {
const pubsub = window.app && window.app.pubsub;
if (!pubsub) return;
if (this.unsubscribe) this.unsubscribe();
this.unsubscribe = pubsub.subscribe(`admin.ai-usage.${this.hours}`, (data) => this.render(data));
}
async poll() {
+10 -2
View File
@@ -20,6 +20,8 @@ import { PollManager } from "./PollManager.js";
import { ApiKeyManager } from "./ApiKeyManager.js";
import { CustomizationToggle } from "./CustomizationToggle.js";
import { NotificationPrefs } from "./NotificationPrefs.js";
import { AiCorrection } from "./AiCorrection.js";
import { AiModifier } from "./AiModifier.js";
import { AdminNotificationDefaults } from "./AdminNotificationDefaults.js";
import { UserAiUsage } from "./UserAiUsage.js";
import { Tabs } from "./Tabs.js";
@@ -32,6 +34,8 @@ import { MediaGallery } from "./MediaGallery.js";
import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
import { PubSubClient } from "./PubSubClient.js";
import { LiveNotifications } from "./LiveNotifications.js";
import { LocalTime } from "./LocalTime.js";
class Application {
constructor() {
@@ -52,13 +56,16 @@ class Application {
this.content = new ContentEnhancer();
this.dom = new DomUtils();
this.push = new PushManager();
this.counters = new CounterManager();
this.pubsub = new PubSubClient();
this.counters = new CounterManager(this.pubsub);
this.reactions = new ReactionBar();
this.bookmarks = new BookmarkManager();
this.polls = new PollManager();
this.apiKey = new ApiKeyManager();
this.customizationToggle = new CustomizationToggle();
this.notificationPrefs = new NotificationPrefs();
this.aiCorrection = new AiCorrection();
this.aiModifier = new AiModifier();
this.adminNotificationDefaults = new AdminNotificationDefaults();
this.userAiUsage = new UserAiUsage();
this.tabs = new Tabs();
@@ -70,7 +77,8 @@ class Application {
this.issueReporter = new IssueReporter();
this.planningGenerator = new PlanningGenerator();
this.mediaGallery = new MediaGallery();
this.pubsub = new PubSubClient();
this.liveNotifications = new LiveNotifications(this.pubsub, this.toast);
this.localTime = new LocalTime();
}
}
+334
View File
@@ -0,0 +1,334 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
import { JobPoller } from "./JobPoller.js";
import { DateFormat } from "./DateFormat.js";
class BackupMonitor {
constructor() {
this.root = document.getElementById("backups-root");
this.generated = document.querySelector("[data-meta='generated']");
this.runForm = document.getElementById("backup-run-form");
this.scheduleForm = document.getElementById("backup-schedule-form");
this.scheduleModal = document.getElementById("backup-schedule-modal");
this.scheduleKind = document.getElementById("backup-schedule-kind");
this.scheduleTitle = document.getElementById("backup-schedule-title");
this.pollMs = 8000;
this.targets = [];
}
start() {
if (!this.root) return;
this.bindRunForm();
this.bindScheduleForm();
this.bindActions();
this.poller = new Poller(() => this.poll(), this.pollMs);
}
async poll() {
try {
const data = await Http.getJson("/admin/backups/data");
this.targets = data.targets || [];
this.render(data);
} catch {
// ignore transient poll errors
}
}
bindRunForm() {
if (!this.runForm) return;
this.runForm.addEventListener("submit", async (event) => {
event.preventDefault();
const target = this.runForm.querySelector("[name='target']").value;
const button = this.runForm.querySelector("button[type='submit']");
button.disabled = true;
try {
const result = await Http.send("/admin/backups/run", { target });
window.app.toast.show("Backup started", { type: "info" });
this.followJob(result.status_url);
} catch {
// Http.send already surfaced the error
} finally {
button.disabled = false;
}
});
}
followJob(statusUrl) {
if (!statusUrl) return;
this.poll();
JobPoller.run(statusUrl, {
onDone: () => {
window.app.toast.show("Backup completed", { type: "success" });
this.poll();
},
onFailed: (status) => {
Http.notifyError((status && status.error) || "Backup failed");
this.poll();
},
});
}
bindScheduleForm() {
if (!this.scheduleForm) return;
const newButton = document.getElementById("backup-schedule-new");
if (newButton) {
newButton.addEventListener("click", () => this.resetScheduleForm());
}
if (this.scheduleKind) {
this.scheduleKind.addEventListener("change", () => this.syncScheduleKind());
}
this.scheduleForm.addEventListener("submit", async (event) => {
event.preventDefault();
const params = Object.fromEntries(new FormData(this.scheduleForm).entries());
try {
await Http.send(this.scheduleForm.action, params);
this.scheduleModal.classList.remove("visible");
window.app.toast.show("Schedule saved", { type: "success" });
this.poll();
} catch {
// error already surfaced
}
});
}
syncScheduleKind() {
const kind = this.scheduleKind.value;
this.scheduleForm.querySelectorAll("[data-kind]").forEach((node) => {
node.hidden = node.dataset.kind !== kind;
});
}
resetScheduleForm() {
this.scheduleForm.reset();
this.scheduleForm.action = "/admin/backups/schedules/create";
this.scheduleTitle.textContent = "New backup schedule";
this.syncScheduleKind();
}
openScheduleEdit(schedule) {
const form = this.scheduleForm;
form.action = `/admin/backups/schedules/${schedule.uid}/edit`;
form.querySelector("[name='name']").value = schedule.name || "";
form.querySelector("[name='target']").value = schedule.target || "full";
form.querySelector("[name='kind']").value = schedule.kind || "interval";
form.querySelector("[name='every_seconds']").value = schedule.every_seconds || 86400;
form.querySelector("[name='cron']").value = schedule.cron || "";
form.querySelector("[name='keep_last']").value = schedule.keep_last || 0;
this.scheduleTitle.textContent = `Edit schedule: ${schedule.name || ""}`;
this.syncScheduleKind();
this.scheduleModal.classList.add("visible");
}
bindActions() {
this.root.addEventListener("click", async (event) => {
const button = event.target.closest("[data-action]");
if (!button) return;
const action = button.dataset.action;
const uid = button.dataset.uid;
if (action === "edit-schedule") {
const schedule = this.schedulesByUid[uid];
if (schedule) this.openScheduleEdit(schedule);
return;
}
const prompts = {
"delete-backup": "Permanently delete this backup archive?",
"delete-schedule": "Delete this backup schedule?",
"toggle-schedule": null,
"run-schedule": null,
};
const message = prompts[action];
if (message) {
const ok = await window.app.dialog.confirm({ message, danger: true });
if (!ok) return;
}
const urls = {
"delete-backup": `/admin/backups/${uid}/delete`,
"delete-schedule": `/admin/backups/schedules/${uid}/delete`,
"toggle-schedule": `/admin/backups/schedules/${uid}/toggle`,
"run-schedule": `/admin/backups/schedules/${uid}/run`,
};
try {
await Http.send(urls[action], {});
this.poll();
} catch {
// error already surfaced
}
});
}
el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = String(text);
return node;
}
cards(items) {
const grid = this.el("div", "metric-cards");
for (const item of items) {
const card = this.el("div", "metric-card");
card.appendChild(this.el("span", "metric-value", item.value));
card.appendChild(this.el("span", "metric-label", item.label));
grid.appendChild(card);
}
return grid;
}
section(title, ...nodes) {
const wrap = this.el("div", "backups-section");
wrap.appendChild(this.el("h3", null, title));
for (const node of nodes) if (node) wrap.appendChild(node);
return wrap;
}
storageSection(storage) {
const disk = storage.disk || {};
const totals = storage.backups_total || {};
const data = storage.data_dir || {};
const overview = this.cards([
{ label: "Disk used", value: `${disk.used_human || "0"} / ${disk.total_human || "0"}` },
{ label: "Disk free", value: disk.free_human || "0" },
{ label: "Disk usage", value: `${disk.used_percent || 0}%` },
{ label: "Data directory", value: data.size_human || "0" },
{ label: "Backups stored", value: totals.count || 0 },
{ label: "Backups size", value: totals.size_human || "0" },
]);
const table = this.el("table", "metric-table");
const head = this.el("thead");
const headRow = this.el("tr");
for (const col of ["Path", "Size", "Files", "Location"]) headRow.appendChild(this.el("th", null, col));
head.appendChild(headRow);
table.appendChild(head);
const body = this.el("tbody");
for (const path of storage.paths || []) {
const tr = this.el("tr");
tr.appendChild(this.el("td", null, path.label));
tr.appendChild(this.el("td", null, path.size_human));
tr.appendChild(this.el("td", null, path.file_count));
const location = this.el("td", "backups-path", path.path);
if (!path.exists) location.classList.add("backups-missing");
tr.appendChild(location);
body.appendChild(tr);
}
table.appendChild(body);
return this.section("Storage", overview, table);
}
backupsSection(backups, metrics) {
const overview = this.cards([
{ label: "Total", value: metrics.total || 0 },
{ label: "Done", value: metrics.done || 0 },
{ label: "Running", value: metrics.running || 0 },
{ label: "Pending", value: metrics.pending || 0 },
{ label: "Failed", value: metrics.failed || 0 },
{ label: "Archive size", value: metrics.size_human || "0" },
]);
if (!backups.length) {
return this.section("Backups", overview, this.el("p", "admin-empty", "No backups yet."));
}
const table = this.el("table", "metric-table backups-table");
const head = this.el("thead");
const headRow = this.el("tr");
for (const col of ["Created", "Target", "Status", "Size", "Files", "Checksum", "Actions"]) {
headRow.appendChild(this.el("th", null, col));
}
head.appendChild(headRow);
table.appendChild(head);
const body = this.el("tbody");
for (const backup of backups) {
const tr = this.el("tr");
tr.appendChild(this.el("td", null, DateFormat.format(backup.created_at, true)));
tr.appendChild(this.el("td", null, backup.label || backup.target));
const status = this.el("td");
status.appendChild(this.el("span", `backups-status backups-status-${backup.status}`, backup.status));
tr.appendChild(status);
tr.appendChild(this.el("td", null, backup.size_human || "-"));
tr.appendChild(this.el("td", null, backup.file_count || 0));
tr.appendChild(this.el("td", "backups-sha", backup.sha256 ? backup.sha256.slice(0, 12) : "-"));
tr.appendChild(this.backupActions(backup));
body.appendChild(tr);
}
table.appendChild(body);
return this.section("Backups", overview, table);
}
backupActions(backup) {
const cell = this.el("td", "backups-actions");
if (backup.download_url) {
const link = this.el("a", "admin-btn admin-btn-sm", "Download");
link.href = backup.download_url;
cell.appendChild(link);
}
const remove = this.el("button", "admin-btn admin-btn-sm admin-btn-danger", "Delete");
remove.dataset.action = "delete-backup";
remove.dataset.uid = backup.uid;
cell.appendChild(remove);
return cell;
}
schedulesSection(schedules) {
this.schedulesByUid = {};
for (const schedule of schedules) this.schedulesByUid[schedule.uid] = schedule;
if (!schedules.length) {
return this.section("Schedules", this.el("p", "admin-empty", "No backup schedules configured."));
}
const table = this.el("table", "metric-table backups-schedules");
const head = this.el("thead");
const headRow = this.el("tr");
for (const col of ["Name", "Target", "Trigger", "Keep", "Next run", "Last run", "Runs", "State", "Actions"]) {
headRow.appendChild(this.el("th", null, col));
}
head.appendChild(headRow);
table.appendChild(head);
const body = this.el("tbody");
for (const schedule of schedules) {
const tr = this.el("tr");
tr.appendChild(this.el("td", null, schedule.name));
tr.appendChild(this.el("td", null, schedule.target));
const trigger = schedule.kind === "cron" ? `cron ${schedule.cron}` : `every ${schedule.every_seconds}s`;
tr.appendChild(this.el("td", null, trigger));
tr.appendChild(this.el("td", null, schedule.keep_last || "all"));
tr.appendChild(this.el("td", null, schedule.next_run_at ? DateFormat.format(schedule.next_run_at, true) : "-"));
tr.appendChild(this.el("td", null, schedule.last_run_at ? DateFormat.format(schedule.last_run_at, true) : "-"));
tr.appendChild(this.el("td", null, schedule.run_count || 0));
const state = this.el("td");
state.appendChild(this.el("span", `backups-status backups-status-${schedule.enabled ? "done" : "failed"}`, schedule.enabled ? "enabled" : "disabled"));
tr.appendChild(state);
tr.appendChild(this.scheduleActions(schedule));
body.appendChild(tr);
}
table.appendChild(body);
return this.section("Schedules", table);
}
scheduleActions(schedule) {
const cell = this.el("td", "backups-actions");
const definitions = [
["run-schedule", "Run", ""],
["edit-schedule", "Edit", ""],
["toggle-schedule", schedule.enabled ? "Disable" : "Enable", ""],
["delete-schedule", "Delete", "admin-btn-danger"],
];
for (const [action, label, extra] of definitions) {
const button = this.el("button", `admin-btn admin-btn-sm ${extra}`.trim(), label);
button.dataset.action = action;
button.dataset.uid = schedule.uid;
cell.appendChild(button);
}
return cell;
}
render(data) {
if (this.generated && data.generated_at) {
this.generated.textContent = `Updated ${DateFormat.format(data.generated_at, true)}`;
}
this.root.textContent = "";
this.root.appendChild(this.storageSection(data.storage || {}));
this.root.appendChild(this.backupsSection(data.backups || [], data.metrics || {}));
this.root.appendChild(this.schedulesSection(data.schedules || []));
}
}
window.BackupMonitor = BackupMonitor;
+10 -3
View File
@@ -12,7 +12,7 @@ export class BotMonitor {
}
init() {
this.poller = new Poller(() => this.refresh(), this.pollMs, {
this.poller = new Poller(() => this.refresh(), 15000, {
immediate: false,
pauseHidden: true,
});
@@ -20,6 +20,10 @@ export class BotMonitor {
immediate: true,
pauseHidden: true,
});
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
pubsub.subscribe("fleet.bots", (payload) => this.apply(payload));
}
}
escape(value) {
@@ -62,8 +66,11 @@ export class BotMonitor {
}
async refresh() {
const payload = await Http.getJson(this.endpoint);
const frames = payload.frames || [];
this.apply(await Http.getJson(this.endpoint));
}
apply(payload) {
const frames = (payload && payload.frames) || [];
if (this.count) {
this.count.textContent = `${frames.length} bots`;
}
+21 -9
View File
@@ -21,6 +21,14 @@ export class ContainerInstance {
this.bind();
this.startDetailPoll();
this.startLogPoll();
this.subscribe();
}
subscribe() {
const pubsub = window.app && window.app.pubsub;
if (!pubsub) return;
pubsub.subscribe(`container.${this.uid}.detail`, (data) => this.applyDetail(data));
pubsub.subscribe(`container.${this.uid}.logs`, (data) => this.renderLogs(data.logs || ""));
}
bind() {
@@ -97,7 +105,7 @@ export class ContainerInstance {
this.detailPoll = new Poller(async () => {
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
this.applyDetail(detail);
}, 4000);
}, 20000);
}
applyDetail(detail) {
@@ -127,21 +135,25 @@ export class ContainerInstance {
}
startLogPoll() {
const pre = this.q("#ci-log");
this.logPoll = new Poller(async () => {
try {
const data = await Http.getJson(`${this.base}/instances/${this.uid}/logs?tail=400`);
const logs = data.logs || "";
pre.classList.toggle("ci-empty", !logs);
pre.textContent = logs || (this.status === "running"
? "No output yet. This panel shows the container's main process (stdout and stderr); commands run in the terminal above are not included here."
: "No logs. Start the instance to capture its main-process output.");
if (logs) pre.scrollTop = pre.scrollHeight;
this.renderLogs(data.logs || "");
} catch (error) {
const pre = this.q("#ci-log");
pre.classList.add("ci-empty");
pre.textContent = "Logs are currently unavailable.";
}
}, 3000);
}, 20000);
}
renderLogs(logs) {
const pre = this.q("#ci-log");
pre.classList.toggle("ci-empty", !logs);
pre.textContent = logs || (this.status === "running"
? "No output yet. This panel shows the container's main process (stdout and stderr); commands run in the terminal above are not included here."
: "No logs. Start the instance to capture its main-process output.");
if (logs) pre.scrollTop = pre.scrollHeight;
}
// ---------------- exec ----------------
+9 -2
View File
@@ -15,7 +15,11 @@ export class ContainerList {
init() {
this.bindActions();
this.bindCreate();
this.poller = new Poller(() => this.refresh(), this.pollMs, { immediate: false });
this.poller = new Poller(() => this.refresh(), 20000, { immediate: false });
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
pubsub.subscribe("container.list", (data) => this.render(data.instances || []));
}
}
toast(message, type) {
@@ -106,7 +110,10 @@ export class ContainerList {
}
async refresh() {
const instances = (await Http.getJson(this.endpoint)).instances || [];
this.render((await Http.getJson(this.endpoint)).instances || []);
}
render(instances) {
if (!this.body) return;
if (!instances.length) {
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>`;
+8 -1
View File
@@ -17,7 +17,14 @@ export class ContainerManager {
this.instances = data.instances || [];
this.bind();
this.renderInstances();
this.poller = new Poller(() => this.refresh(), this.pollMs, { immediate: false });
this.poller = new Poller(() => this.refresh(), 20000, { immediate: false });
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
pubsub.subscribe(`project.${this.slug}.containers`, (payload) => {
this.instances = payload.instances || [];
this.renderInstances();
});
}
}
bind() {
+13 -4
View File
@@ -4,7 +4,7 @@ import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class CounterManager {
constructor() {
constructor(pubsub) {
if (!document.querySelector("[data-counter]")) {
return;
}
@@ -13,7 +13,17 @@ export class CounterManager {
this.poll();
}
});
this.poller = new Poller(() => this.poll(), 30000, { pauseHidden: true });
const uid = document.body.dataset.userUid || "";
if (pubsub && uid) {
pubsub.subscribe(`user.${uid}.counts`, (data) => this.applyCounts(data));
}
this.poller = new Poller(() => this.poll(), 60000, { pauseHidden: true });
}
applyCounts(counts) {
if (!counts) return;
this.apply("notifications", counts.notifications);
this.apply("messages", counts.messages);
}
async poll() {
@@ -24,8 +34,7 @@ export class CounterManager {
console.error("counter poll failed", error);
return;
}
this.apply("notifications", counts.notifications);
this.apply("messages", counts.messages);
this.applyCounts(counts);
}
apply(key, count) {
+28
View File
@@ -0,0 +1,28 @@
// retoor <retoor@molodetz.nl>
const TOAST_MS = 6000;
export class LiveNotifications {
constructor(pubsub, toast) {
this.pubsub = pubsub;
this.toast = toast;
this.uid = document.body.dataset.userUid || "";
if (!this.uid) return;
this.topic = `user.${this.uid}.notifications`;
this.pubsub.subscribe(this.topic, (data) => this.onNotification(data));
}
onNotification(data) {
if (!data || !data.message) return;
this.toast.show(data.message, {
type: "info",
ms: TOAST_MS,
url: this._openUrl(data),
});
}
_openUrl(data) {
if (data.uid) return `/notifications/open/${data.uid}`;
return data.target_url || "/notifications";
}
}
+69
View File
@@ -0,0 +1,69 @@
// retoor <retoor@molodetz.nl>
const REFRESH_MS = 60000;
export class LocalTime {
constructor() {
this.localize(document);
this.observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) this.localize(node);
}
}
});
if (document.body) {
this.observer.observe(document.body, { childList: true, subtree: true });
}
window.setInterval(() => this.refreshRelative(), REFRESH_MS);
}
format(iso, mode) {
const date = new Date(iso);
if (isNaN(date.getTime())) return null;
if (mode === "ago") return this.relative(date);
if (mode === "date") return date.toLocaleDateString();
if (mode === "time") {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
}
relative(date) {
const seconds = (Date.now() - date.getTime()) / 1000;
if (seconds < 0) return "just now";
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + "m ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + "h ago";
const days = Math.floor(hours / 24);
if (days <= 30) return days + "d ago";
return date.toLocaleDateString();
}
apply(el) {
const iso = el.getAttribute("datetime");
if (!iso) return;
const mode = el.dataset.dtMode || "datetime";
const text = this.format(iso, mode);
if (text === null) return;
el.textContent = text;
const full = new Date(iso);
if (!isNaN(full.getTime())) {
el.title = full.toLocaleString([], { dateStyle: "full", timeStyle: "long" });
}
}
localize(root) {
if (root.matches && root.matches("[data-dt]")) this.apply(root);
if (!root.querySelectorAll) return;
root.querySelectorAll("[data-dt]").forEach((el) => this.apply(el));
}
refreshRelative() {
document
.querySelectorAll('[data-dt][data-dt-mode="ago"]')
.forEach((el) => this.apply(el));
}
}
+31 -4
View File
@@ -183,6 +183,7 @@ export class MessagesLayout {
mine: true,
clientId,
time: "now",
iso: new Date().toISOString(),
attachmentCount,
});
bubble.classList.add("pending");
@@ -196,8 +197,25 @@ export class MessagesLayout {
if (pending) {
pending.classList.remove("pending");
pending.dataset.msgUid = frame.uid;
const oldBody = pending.querySelector(".rendered-content");
if (oldBody && frame.content !== undefined) {
const body = document.createElement("div");
body.className = "rendered-content";
body.setAttribute("data-render", "");
body.textContent = frame.content || "";
oldBody.replaceWith(body);
this.renderBody(body);
}
const time = pending.querySelector(".message-time");
if (time) time.textContent = frame.time_ago;
if (time && frame.created_at) {
time.setAttribute("datetime", frame.created_at);
time.dataset.dt = "";
time.dataset.dtMode = "ago";
if (window.app && window.app.localTime) window.app.localTime.apply(time);
else time.textContent = frame.time_ago;
} else if (time) {
time.textContent = frame.time_ago;
}
const placeholder = pending.querySelector(".attachment-pending");
if (placeholder) placeholder.remove();
const gallery = this.renderAttachments(frame.attachments);
@@ -216,6 +234,7 @@ export class MessagesLayout {
content: frame.content,
mine,
time: frame.time_ago,
iso: frame.created_at,
uid: frame.uid,
attachments: frame.attachments,
});
@@ -227,7 +246,7 @@ export class MessagesLayout {
this.bumpConversation(frame, frame.sender_uid === this.selfUid);
}
buildBubble({ content, mine, time, uid, clientId, attachments, attachmentCount }) {
buildBubble({ content, mine, time, iso, uid, clientId, attachments, attachmentCount }) {
const bubble = document.createElement("div");
bubble.className = "message-bubble " + (mine ? "mine" : "theirs");
if (uid) bubble.dataset.msgUid = uid;
@@ -251,9 +270,17 @@ export class MessagesLayout {
bubble.appendChild(placeholder);
}
const timeEl = document.createElement("span");
const timeEl = document.createElement(iso ? "time" : "span");
timeEl.className = "message-time";
timeEl.textContent = time || "";
if (iso) {
timeEl.setAttribute("datetime", iso);
timeEl.dataset.dt = "";
timeEl.dataset.dtMode = "ago";
if (window.app && window.app.localTime) window.app.localTime.apply(timeEl);
else timeEl.textContent = time || "";
} else {
timeEl.textContent = time || "";
}
bubble.appendChild(timeEl);
if (mine) {
+20 -8
View File
@@ -18,7 +18,24 @@ class ServiceMonitor {
if (!container) return;
this.bindActions(container);
this.bindConfigForms(container);
this.poller = new Poller(() => this.poll(), this.pollInterval);
this.poller = new Poller(() => this.poll(), 20000);
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
const topic = this.detail ? `admin.services.${this.detailName}` : "admin.services";
pubsub.subscribe(topic, (data) => this.applyData(data));
}
}
applyData(data) {
if (!data) return;
if (this.detail) {
if (data.service) this.update(this.detail, data.service);
} else if (this.list && Array.isArray(data.services)) {
for (const svc of data.services) {
const row = this.list.querySelector(`[data-service="${svc.name}"]`);
if (row) this.update(row, svc);
}
}
}
bindActions(container) {
@@ -93,14 +110,9 @@ class ServiceMonitor {
async poll() {
try {
if (this.detail) {
const data = await Http.getJson(`/admin/services/${this.detailName}/data`);
if (data.service) this.update(this.detail, data.service);
this.applyData(await Http.getJson(`/admin/services/${this.detailName}/data`));
} else if (this.list) {
const data = await Http.getJson("/admin/services/data");
for (const svc of data.services) {
const row = this.list.querySelector(`[data-service="${svc.name}"]`);
if (row) this.update(row, svc);
}
this.applyData(await Http.getJson("/admin/services/data"));
}
} catch {
// silently ignore poll errors
@@ -13,6 +13,11 @@ export class AppToast extends Component {
const toast = document.createElement("div");
toast.className = `dp-toast dp-toast-${type}`;
toast.textContent = message;
const action = this._action(options);
if (action) {
toast.classList.add("dp-toast-link");
toast.addEventListener("click", action);
}
this.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("visible"));
setTimeout(() => {
@@ -21,6 +26,16 @@ export class AppToast extends Component {
}, ms);
return toast;
}
_action(options) {
if (typeof options.onClick === "function") return options.onClick;
if (options.url) {
return () => {
window.location.href = options.url;
};
}
return null;
}
}
customElements.define("dp-toast", AppToast);
+1 -1
View File
@@ -16,7 +16,7 @@
<div class="comment-header">
{% set _user = item.author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
{% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
<span class="comment-time">{{ item.time_ago }}</span>
<span class="comment-time">{{ dt_ago(item.comment.get('created_at')) if item.comment.get('created_at') else item.time_ago }}</span>
</div>
<div class="comment-text rendered-content" data-render data-raw="{{ item.comment['content'] }}">{{ item.comment['content'] }}</div>
{% set attachments = item.get('attachments', []) %}
+1 -1
View File
@@ -1,6 +1,6 @@
{% from "_macros.html" import content_url %}
<article class="post-card">
{% include "_post_header.html" %}
{% set _created = item.post.get('created_at') %}{% include "_post_header.html" %}
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
+1 -1
View File
@@ -6,5 +6,5 @@
<span class="post-author-role">{{ _author['role'] }}</span>
{% endif %}
</div>
<span class="post-time">{{ _time }}</span>
<span class="post-time">{% if _created %}{{ dt_ago(_created) }}{% else %}{{ _time }}{% endif %}</span>
</div>
+1 -1
View File
@@ -13,7 +13,7 @@
<dl class="audit-detail-grid">
<dt>Time</dt>
<dd>{{ format_date(event['created_at'], include_time=True) }} ({{ event.get('time_ago', '') }})</dd>
<dd>{{ local_dt(event['created_at']) }} ({{ dt_ago(event['created_at']) if event['created_at'] else event.get('time_ago', '') }})</dd>
<dt>Category</dt>
<dd>{{ event.get('category', '') }}</dd>
+1 -1
View File
@@ -90,7 +90,7 @@
<tbody>
{% for e in entries %}
<tr>
<td title="{{ format_date(e['created_at'], include_time=True) }}">{{ e.get('time_ago', '') }}</td>
<td title="{{ format_date(e['created_at'], include_time=True) }}">{{ dt_ago(e.created_at) if e.created_at else e.get('time_ago', '') }}</td>
<td><span class="audit-badge">{{ e['event_key'] }}</span></td>
<td>
{% if e.get('actor_user') %}
+74
View File
@@ -0,0 +1,74 @@
{% extends "admin_base.html" %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="{{ static_url('/static/css/services.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/backups.css') }}">
{% endblock %}
{% block admin_content %}
<div class="admin-toolbar">
<h2>Backups</h2>
<div class="backup-controls">
<form id="backup-run-form" method="POST" action="/admin/backups/run" class="admin-inline-form">
<label>Target
<select name="target" id="backup-run-target">
{% for target in targets %}
<option value="{{ target.key }}">{{ target.label }}</option>
{% endfor %}
</select>
</label>
<button type="submit" class="admin-btn admin-btn-primary"><span class="icon">&#x1F4BE;</span> Create backup</button>
</form>
<a href="#" class="admin-btn admin-btn-sm" data-modal="backup-schedule-modal" id="backup-schedule-new"><span class="icon">&#x1F551;</span> New schedule</a>
<span class="backup-generated" data-meta="generated">-</span>
</div>
</div>
<div id="backups-root" class="backups">
<p class="admin-empty" data-empty>Loading backup status...</p>
</div>
<div id="backup-schedule-modal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3 id="backup-schedule-title">New backup schedule</h3>
<a href="#" class="modal-close">&times;</a>
</div>
<form id="backup-schedule-form" method="POST" action="/admin/backups/schedules/create" class="modal-body backup-schedule-form">
<label>Name
<input type="text" name="name" maxlength="120" required placeholder="Nightly full backup">
</label>
<label>Target
<select name="target">
{% for target in targets %}
<option value="{{ target.key }}">{{ target.label }}</option>
{% endfor %}
</select>
</label>
<label>Schedule type
<select name="kind" id="backup-schedule-kind">
<option value="interval">Every N seconds</option>
<option value="cron">Cron expression</option>
</select>
</label>
<label data-kind="interval">Interval (seconds)
<input type="number" name="every_seconds" min="60" value="86400">
</label>
<label data-kind="cron" hidden>Cron (minute hour dom month dow)
<input type="text" name="cron" maxlength="120" placeholder="0 3 * * *">
</label>
<label>Keep last N backups (0 = keep all)
<input type="number" name="keep_last" min="0" max="1000" value="7">
</label>
<div class="modal-actions">
<button type="submit" class="admin-btn admin-btn-primary">Save schedule</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="module" src="{{ static_url('/static/js/BackupMonitor.js') }}"></script>
<script type="module">
new window.BackupMonitor().start();
</script>
{% endblock %}
+3
View File
@@ -35,6 +35,9 @@
<a href="/admin/audit-log" class="sidebar-link {% if admin_section == 'audit-log' %}active{% endif %}">
<span class="sidebar-icon">&#x1F4CB;</span> Audit Log
</a>
<a href="/admin/backups" class="sidebar-link {% if admin_section == 'backups' %}active{% endif %}">
<span class="sidebar-icon">&#x1F4BE;</span> Backups
</a>
<a href="/admin/notifications" class="sidebar-link {% if admin_section == 'notifications' %}active{% endif %}">
<span class="sidebar-icon">&#x1F514;</span> Notifications
</a>
+1 -1
View File
@@ -23,7 +23,7 @@
{% endif %}
<div class="media-tile-meta">
<a href="/profile/{{ item['uploader'] }}" class="media-uploader">{{ item['uploader'] }}</a>
<span class="media-deleted-at">{{ format_date(item['deleted_at']) }}</span>
<span class="media-deleted-at">{{ local_dt(item['deleted_at']) }}</span>
</div>
<div class="media-tile-actions">
<form method="POST" action="/media/{{ item['uid'] }}/restore" class="admin-inline-form">
+2 -2
View File
@@ -59,9 +59,9 @@
</td>
<td style="white-space: nowrap; font-size: 0.75rem; color: var(--text-muted);">
{% if item.synced_at %}
{{ format_date(item.synced_at) }}
{{ local_dt(item.synced_at) }}
{% else %}
{{ item.time_ago }}
{{ dt_ago(item.synced_at) if item.synced_at else item.time_ago }}
{% endif %}
</td>
<td>
+1 -1
View File
@@ -32,7 +32,7 @@
{{ item['label'] }}
{% endif %}
</td>
<td>{{ format_date(item['deleted_at']) }}</td>
<td>{{ local_dt(item['deleted_at']) }}</td>
<td>{{ item['deleted_by'] or 'system' }}</td>
<td class="admin-table-actions">
<form method="POST" action="/admin/trash/{{ item['table'] }}/{{ item['uid'] }}/restore" class="admin-inline-form">
+1 -1
View File
@@ -52,7 +52,7 @@
</script>
{% endif %}
</head>
<body>
<body data-user-uid="{{ user['uid'] if user else '' }}">
<nav class="topnav">
<div class="topnav-inner">
<a href="/feed" class="topnav-logo">Dev<span>Place</span></a>
@@ -93,7 +93,7 @@
<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>
<span class="ci-event-time">{{ local_dt(ev['created_at']) }}</span>
</li>
{% else %}
<li class="cm-muted">No events yet.</li>
@@ -0,0 +1,83 @@
{% set uname = user.get('username') if user else 'YOUR_USERNAME' %}
<div class="docs-content" data-render>
# AI content correction
AI content correction automatically rewrites the prose you author so it reads the way you want, using
an instruction you choose. It is **opt-in** and **off by default**. When you turn it on, your post,
comment, project, gist, message, or bio is corrected according to your instruction whenever you create
or edit it.
## What it does
When enabled, the following prose fields are corrected:
| Where | Fields corrected |
|-------|------------------|
| Posts | title and body |
| Projects | title and description |
| Gists | title and description |
| Comments | body |
| Direct messages | body |
| Your profile | bio |
Code and source files are **never** touched: a gist's source code, project files, and any code block
are left exactly as written. Correction is for prose only.
In direct messages the correction is delivered live: the corrected message appears in the chat for both
participants without a reload.
## How it works
Each correction is a single call to the platform AI gateway, authenticated with **your own API key**,
so the usage is attributed to you. The correction is fail-soft: if the AI call fails or returns
something unexpected, your original text is kept unchanged. The rewrite preserves your meaning,
language, line breaks, and markdown.
The correction applies everywhere you create or edit prose: the web interface, the REST and devRant
APIs, and the Devii assistant. New content and edits are both corrected.
## Background or synchronous
You choose **when** the correction is applied with the **Apply mode** setting:
| Mode | Behaviour |
|------|-----------|
| In background (default) | Your content is saved instantly exactly as written, then corrected a moment later. Nothing you do is slowed down; the corrected text appears the next time the content is loaded. |
| Synchronously | The save waits for the correction to finish, so the result is already corrected when the page reloads. The correction is usually fast, so this is a reasonable choice if you prefer to see the corrected text immediately. |
Both modes use your own API key and are fail-soft. The only difference is whether saving waits for the
rewrite or lets it happen just afterwards.
## Usage totals
Each successful correction is metered, and your running totals are shown on your profile: how many
corrections were made and how many tokens they used. Beyond those counts and tokens, the profile also
shows performance figures: average latency, average speed (tokens per second), and total processing
time. The dollar figures - total cost and average cost per call - are visible to administrators only;
you see the corrections, token counts, and performance figures. Totals only increase when a correction
call actually succeeds.
## Turning it on
Open your profile at `/profile/{{ uname }}` and find the **AI content correction** card. Tick
**Enable correction**, choose an **Apply mode** (background or synchronous), adjust the correction
instruction if you like, and press **Save**. The default instruction is:
> Leave literary as is, only do punctuation and casing
Replace it with any instruction up to 2000 characters, for example "fix spelling and grammar but keep
my tone" or "translate to formal English". The setting is private: only you (and administrators) can
see or change it.
## Ask Devii
You can configure it in plain language through the Devii assistant:
> Enable AI correction and only fix my punctuation.
> Apply my corrections synchronously.
> Turn AI content correction off.
Devii reads your current setting, mode, and prompt, then changes them for you.
</div>
+114
View File
@@ -0,0 +1,114 @@
{% set uname = user.get('username') if user else 'YOUR_USERNAME' %}
<div class="docs-content" data-render>
# AI modifier
The AI modifier lets you embed an instruction directly inside the prose you write and have the
platform execute it in place. It works like AI content correction, except it runs **only** where your
text contains an inline `@ai <instruction>` directive. When that marker is present, the configured
prompt tells the model to carry out the instruction behind `@ai` and replace that marked part,
removing the `@ai` marker.
It is **enabled by default**, and by default it applies **synchronously** (the save waits for the
result). You can turn it off or switch to background mode on your profile.
## What it does
When you write something like:
> Here are the release notes. @ai summarize this in one sentence
the modifier executes that instruction and replaces the marked part, so the stored text becomes the
one-sentence summary with the `@ai` marker gone. Text with no `@ai ...` directive is left exactly as
written, so the modifier is invisible until you ask for it.
The following prose fields are processed:
| Where | Fields processed |
|-------|------------------|
| Posts | title and body |
| Projects | title and description |
| Gists | title and description |
| Comments | body |
| Direct messages | body |
| Your profile | bio |
Code and source files are **never** touched: a gist's source code, project files, and any code block
are left exactly as written. The modifier is for prose only.
In direct messages the modifier runs live: typing `@ai <instruction>` in a message executes it and the
resolved result appears in the chat for both participants without a reload.
## How it works
Each modification is a single call to the platform AI gateway, authenticated with **your own API
key**, so the usage is attributed to you. It is fail-soft: if the AI call fails or returns something
unexpected, your original text is kept unchanged. It applies everywhere you create or edit prose: the
web interface, the REST and devRant APIs, and the Devii assistant. New content and edits are both
processed.
## What the model knows (context)
So that an instruction like "answer the question above" or "reply to this" actually works, the modifier
gives the model a short, read-only **context block** describing who is asking and where the directive
sits. It always includes:
- **The date** and that you are on the DevPlace developer network.
- **You**: your username, role, level, stars, post count, leaderboard rank, follower count, member-since
date, and your bio.
- **Where the directive is**, depending on what you are writing:
- In a **comment**: the post, project, gist, or news item it is on (title and an excerpt), and the
comment you are replying to.
- In a **post**: its topic and any project it is attached to.
- In a **project or gist**: the title and description, and for a gist its language and source code (as
reference only - your code is never rewritten).
- In a **direct message**: who you are messaging and the recent conversation, so a reply can follow
the thread.
This lets you write `@ai answer the question above`, `@ai write my bio from my stats`, or `@ai reply to
this`. The context is summarised and length-limited, and the extra context tokens count toward your
usage totals like any other tokens. The model is told to use the context but never to repeat it back.
## Synchronous or background
You choose **when** the modification is applied with the **Apply mode** setting:
| Mode | Behaviour |
|------|-----------|
| Synchronously (default) | The save waits for the modification to finish, so the result is already applied when the page reloads. |
| In background | Your content is saved instantly exactly as written, then modified a moment later; the result appears the next time the content is loaded. Nothing you do is slowed down. |
Both modes use your own API key and are fail-soft. The only difference is whether saving waits for the
rewrite or lets it happen just afterwards.
## Usage totals
Each successful modification is metered, and your running totals are shown on your profile: how many
modifications were made and how many tokens they used. Beyond those counts and tokens, the profile also
shows performance figures: average latency, average speed (tokens per second), and total processing
time. The dollar figures - total cost and average cost per call - are visible to administrators only;
you see the modification and token counts and the performance figures. Totals only increase when a
modification call actually succeeds.
## Configuring it
Open your profile at `/profile/{{ uname }}` and find the **AI modifier** card. Toggle **Enable
modifier**, choose an **Apply mode** (synchronous or background), adjust the instruction prompt if you
like, and press **Save**. The default prompt is:
> Execute what is behind `@ai` (the prompt) and replace that part including `@ai`
Replace it with any instruction up to 2000 characters. The setting is private: only you (and
administrators) can see or change it.
## Ask Devii
You can configure it in plain language through the Devii assistant:
> Turn the AI modifier off.
> Apply my AI modifier changes in the background.
> Enable the AI modifier.
Devii reads your current setting, mode, and prompt, then changes them for you.
</div>
@@ -53,7 +53,8 @@ Every component **renders into the light DOM (no shadow root)** so the global CS
A few plain modules own the cross-cutting patterns. Reach for these instead of hand-rolling a fetch, an interval, or a click handler:
- **`Http`** (`static/js/Http.js`) is the single fetch helper: `getJson`, `sendForm` (form-encoded POST that follows the login redirect), and `send` (POST that throws the server's `error.message` on a non-2xx response or a `200 {ok: false}` body). The container, admin, and project-files UIs all go through it rather than calling `fetch` directly.
- **`Poller`** (`static/js/Poller.js`) wraps the universal live-update loop: `new Poller(fn, intervalMs, { pauseHidden })` with `start`/`stop`/`tick`, swallowing per-tick errors so one failed poll never kills the loop. Every polling controller (notification counts, the service monitor, AI usage, the container manager and instance pages) uses it.
- **`Poller`** (`static/js/Poller.js`) wraps the universal live-update loop: `new Poller(fn, intervalMs, { pauseHidden })` with `start`/`stop`/`tick`, swallowing per-tick errors so one failed poll never kills the loop. The live-update controllers (notification counts, the service monitor, AI usage, the container manager and instance pages, the bot fleet) now take their fast updates from the pub/sub bus and keep a `Poller` only as a slow initial-load and fallback loop. See [Pub/Sub service](/docs/services-pubsub.html) for the live view relay that drives them.
- **`PubSubClient`** (`static/js/PubSubClient.js`, `app.pubsub`) is the single websocket client for the pub/sub bus: `subscribe(topic, cb)` returns an unsubscribe function and `publish(topic, data)` sends a frame. It connects lazily, reconnects with backoff (with a fast retry on the lock-owner bounce), and re-subscribes after a reconnect. The live notification toasts, badge counts, and the admin live views all subscribe through it instead of polling.
- **`JobPoller`** (`static/js/JobPoller.js`) is the frontend half of the async job framework: `JobPoller.run(statusUrl, { onDone, onFailed, onTimeout })` polls the status URL and fires one callback. The zip download and project fork flows share it.
- **`OptimisticAction`** (`static/js/OptimisticAction.js`) is the base for click-to-POST engagement controllers. `VoteManager`, `ReactionBar`, `BookmarkManager`, and `PollManager` extend it and call `submit(url, params, errorTarget, render)` for the shared POST -> render -> error path, keeping only their own event wiring and render.
- **`FloatingWindow`** (`static/js/components/FloatingWindow.js`) is the draggable, resizable window base shared by the container terminals and the Devii terminal; `WindowManager` (`app.windows`) gives every window a shared last-clicked-on-top z-order.
+88
View File
@@ -0,0 +1,88 @@
<div class="docs-content" data-render>
# Backups
> Audience: administrators. This page is hidden from members and guests in the sidebar, the search
> index, and the documentation export. It describes the backup service, where archives live, and how
> to schedule and rotate them.
DevPlace ships an enterprise-grade backup service that captures point-in-time copies of platform
data as compressed `tar.gz` archives. Every backup runs as an asynchronous job in a subprocess off
the request path, so creating a backup never blocks the server or slows requests.
## Targets
A backup captures one of four targets:
- **Database** - a consistent SQLite snapshot of the main database plus the Devii task and lesson
databases. The snapshot uses SQLite's online backup API, so it is consistent even while the server
is writing under WAL.
- **Uploads** - every attachment and project file under the uploads directory. This is normally the
largest target.
- **Keys and config** - the VAPID notification keys and other small config artifacts.
- **Full data directory** - the database snapshot, uploads, and keys in one archive. Regenerable and
volatile data (job staging, locks, zip archives, container workspaces, search caches) is excluded
because it can be rebuilt and would only bloat the archive.
## Where backups live
Archives are written under the runtime data directory at `data/backups/`, sharded into a two-level
`xx/yy` tree taken from the random tail of each backup's identifier so no single directory grows
without bound. Each archive is named `target-YYYYMMDD-HHMMSS-id.tar.gz` and recorded with its size,
file count, and a SHA-256 checksum for integrity verification. Staging happens under
`data/backup_staging/` and is removed as soon as the archive is built.
Because everything lives under the single data directory, pointing `DEVPLACE_DATA_DIR` at a mounted
volume in production places backups on that volume automatically.
## Creating a backup
Open **Admin -> Backups**, choose a target, and select **Create backup**. The dashboard shows the
job moving from pending to running to done; when it completes a download link appears. Backups can
also be created from the command line with `devplace backups run <target>` (the running server
processes the enqueued job), or by asking Devii to back up a target.
## Scheduling and rotation
A backup schedule fires backups automatically. Each schedule has:
- a **target** (database, uploads, keys, or full),
- a **trigger** - either an interval in seconds or a 5-field cron expression
(`minute hour dom month dow`),
- a **keep_last** retention count - after each scheduled backup completes, older backups created by
the same schedule beyond this count are deleted to reclaim space (0 keeps them all).
Schedules are evaluated by the backup service, which runs only on the worker holding the service
lock, so each scheduled backup fires exactly once. Create, edit, enable, disable, run, and delete
schedules from the dashboard.
## Storage visibility
The dashboard reports the size and file count of every major data area (database, uploads,
attachments, project files, keys, zip archives, search data, container workspaces, and the backups
themselves), the total data-directory footprint, the total size and count of stored backups, and the
underlying disk usage (total, used, free, and percent). The scan runs in a worker thread and is
cached briefly so it never blocks page rendering.
## Retention and deletion
Backup archives are permanent operational artifacts: unlike the async job rows that track them, an
archive is never swept away by job retention. It is removed only when an administrator deletes it,
when a schedule rotates it out via keep_last, or by the `devplace backups clear` command. Deleting a
backup is a hard delete that unlinks the archive file and reclaims its disk space immediately, and is
recorded in the audit log.
## Restore
This service creates and stores backups; it does not restore them into a live server, because
overwriting the database or uploads while the application is running risks corruption. To restore,
stop the server, unpack the relevant archive over the data directory (each archive is rooted at
`database/`, `uploads/`, or `keys/`), verify the SHA-256 checksum recorded for the backup, and start
the server again.
## Command line
- `devplace backups list` - list recorded backups with target, status, size, and filename.
- `devplace backups run <database|uploads|keys|full>` - enqueue a backup for the running server.
- `devplace backups prune` - remove backup records whose archive file is missing.
- `devplace backups clear` - delete every backup archive and record.
</div>
+274
View File
@@ -0,0 +1,274 @@
<div class="docs-content" data-render>
# Gamification: XP, levels, badges, and achievements
> Audience: administrators. This page is hidden from members and guests in the sidebar, the search
> index, and the documentation export. It is the complete reference for the progression system:
> experience points, levels, the full badge catalogue, the activity-tracking engine behind
> first-use and usage-tier badges, every place a badge is awarded, and how to extend it.
DevPlace rewards participation with three layered mechanics, all centralised in `devplacepy/utils.py`
and wired into the existing content, engagement, and feature hooks. Nothing writes badges or XP
directly - every award goes through the helpers below so milestone checks and notifications are never
skipped.
- **XP and levels** measure overall contribution.
- **Badges** are one-time, never-revoked awards grouped into themes and shown on the profile.
- **Achievements** is the umbrella term for the badge catalogue plus the per-user progress shown in
the profile showcase.
All reward work is deferred to the background task queue, so it never sits on the request path; in
the test suite the queue runs inline so awards are deterministic.
## XP and levels
Members earn XP at the existing content and engagement hook points. The amounts are constants in
`utils.py`:
| Action | Constant | XP |
|--------|----------|----|
| Publish a post | `XP_POST` | 10 |
| Write a comment | `XP_COMMENT` | 2 |
| Publish a project | `XP_PROJECT` | 15 |
| Publish a gist | `XP_GIST` | 5 |
| Receive an upvote | `XP_UPVOTE` | 5 |
| Gain a follower | `XP_FOLLOW` | 5 |
Awards for received upvotes and followers go to the content owner or the followed user. The level
formula is `level = 1 + xp // 100` (`LEVEL_XP = 100`). `award_xp(user_uid, amount)` clamps XP at zero,
recomputes the level, invalidates the user cache, and on a level-up fires a `level` notification and
any level badge. `award_rewards(user_uid, amount, first_badge=None)` is the single entry point that
chains the optional first-time badge, `award_xp`, and `check_milestone_badges`.
## The badge system: two complementary mechanisms
Badges come from two sources, chosen by whether the achievement can be recomputed from existing data:
1. **Source-derived milestones** - counts that already live in a table (posts, comments, projects,
gists, stars, followers, following, activity streak). These need no tracking: `check_milestone_badges`
recomputes them and is accurate even retroactively. Defined in the `_COUNT_MILESTONES` list and the
`LEVEL_BADGES` map.
2. **Activity-tracked badges** - first-time feature use and usage tiers that are *not* derivable from
any table (read N documentation pages, ran a DeepSearch, sent a message, used Devii). These are
counted in a dedicated activity table and awarded by `track_action`.
The four original first-content badges (First Post, First Comment, First Project, First Gist) are a
third, simplest case: they are passed as the `first_badge` argument of `award_rewards` at the moment
the content is created (in `content.py`).
## Badge catalogue
Every badge, its icon, the group it belongs to, and how it is earned. Metadata lives in
`BADGE_CATALOG`; the group order is `BADGE_GROUPS`.
### First steps
| Icon | Badge | How to earn |
|------|-------|-------------|
| ✎ | First Post | Publish your first post (`award_rewards` first_badge) |
| ❝ | First Comment | Write your first comment (`award_rewards` first_badge) |
| ⬢ | First Project | Share your first project (`award_rewards` first_badge) |
| ❡ | First Gist | Share your first gist (`award_rewards` first_badge) |
| 📎 | First Upload | Upload your first attachment (`upload`, 1) |
| 📄 | File Creator | Create a file in a project (`project_file`, 1) |
| 📇 | Profiled | Fill in any profile field (`profile`, 1) |
### Explorer
| Icon | Badge | How to earn |
|------|-------|-------------|
| 📖 | Curious | Read 1 documentation page (`docs.read`, distinct) |
| 📚 | Studious | Read 5 documentation pages (`docs.read`, distinct) |
| 🎓 | Scholar | Read 15 documentation pages (`docs.read`, distinct) |
| 🤖 | AI Curious | Talk to Devii for the first time (`devii`, 1) |
| ✨ | AI Whisperer | Hold 25 conversations with Devii (`devii`, 25) |
| 🍴 | First Fork | Fork a project (`fork`, 1) |
| 🌿 | Forker | Fork 5 projects (`fork`, 5) |
| 🗜 | Archivist | Download a project archive (`zip`, 1) |
| 🔍 | SEO Auditor | Run an SEO diagnostics audit (`seo`, 1) |
| 🔬 | Researcher | Run a DeepSearch investigation (`deepsearch`, 1) |
| 🌊 | Deep Diver | Run 10 DeepSearch investigations (`deepsearch`, 10) |
| 📦 | Container Captain | Create a container instance (`container`, 1) |
### Engagement
| Icon | Badge | How to earn |
|------|-------|-------------|
| 🔖 | Bookmarker | Bookmark your first item (`bookmark`, 1) |
| 📌 | Curator | Bookmark 25 items (`bookmark`, 25) |
| 💛 | First Reaction | React to content for the first time (`reaction`, 1) |
| 🎉 | Cheerleader | React 50 times (`reaction`, 50) |
| ⭐ | First Star Given | Star someone's work for the first time (`vote`, 1) |
| 👍 | Supporter | Give 50 stars (`vote`, 50) |
| 🤝 | Patron | Give 250 stars (`vote`, 250) |
| 🗳 | Pollster | Vote in a poll for the first time (`poll`, 1) |
### Content
| Icon | Badge | How to earn |
|------|-------|-------------|
| ✺ | Prolific | Publish 10 posts |
| 🖋 | Wordsmith | Publish 50 posts |
| 🏆 | Veteran | Publish 100 posts |
| 💬 | Conversationalist | Write 25 comments |
| 📣 | Debater | Write 100 comments |
| 🔧 | Builder | Share 5 projects |
| 🏛 | Architect | Share 10 projects |
| 📑 | Snippet Collector | Share 5 gists |
### Community
| Icon | Badge | How to earn |
|------|-------|-------------|
| 🔗 | Connector | Follow 10 people |
| ✉ | Messenger | Send your first direct message (`message`, 1) |
| 📨 | Chatterbox | Send 100 direct messages (`message`, 100) |
| 👋 | Friendly | Follow someone for the first time (`follow`, 1) |
| 🐛 | Bug Reporter | File your first issue (`issue`, 1) |
### Reputation
| Icon | Badge | How to earn |
|------|-------|-------------|
| ☆ | Rising Star | Earn 25 stars |
| ★ | Star Author | Earn 100 stars |
| 🌟 | Superstar | Earn 500 stars |
| ◎ | Popular | Reach 10 followers |
| 📢 | Influencer | Reach 50 followers |
| 👑 | Celebrity | Reach 100 followers |
### Dedication
| Icon | Badge | How to earn |
|------|-------|-------------|
| 🔥 | On Fire | Maintain a 7-day activity streak |
| 📅 | Dedicated | Maintain a 30-day activity streak |
| 🚀 | Unstoppable | Maintain a 100-day activity streak |
### Levels and membership
| Icon | Badge | How to earn |
|------|-------|-------------|
| ❖ | Level 5 / 10 / 25 / 50 / 100 | Reach the matching level (`LEVEL_BADGES`) |
| ✦ | Member | Join DevPlace (granted at registration) |
## The activity-tracking engine
Activity-tracked badges are backed by two tables, both created and indexed in `init_db()`:
- `user_activity` - a per-user, per-action counter (`count`, `first_at`, `last_at`), unique on
`(user_uid, action)`. Used for repeatable actions (votes given, messages sent, forks).
- `user_activity_seen` - a per-user set of distinct targets, unique on `(user_uid, action, target)`.
Used for "N distinct things" achievements, currently only `docs.read` (distinct documentation
slugs), so refreshing the same page does not inflate the count.
`database.record_activity(user_uid, action)` upserts the counter and returns the new count;
`database.record_unique_activity(user_uid, action, target)` records a distinct target and returns the
new distinct count, or `None` if the target was already seen.
The single hook the rest of the codebase calls is `utils.track_action(user_uid, action, target=None)`.
It returns immediately for an unknown action, otherwise it submits the work to the background queue,
which records the activity (counter or distinct, per `UNIQUE_ACTIONS`) and then awards and notifies
every threshold crossed in the `ACHIEVEMENTS` registry. It is fully deferred and idempotent, so it is
safe to call inline at any feature's success point.
The `ACHIEVEMENTS` registry maps an action to its `(threshold, badge)` tiers (threshold 1 is a
first-use badge):
| Action | Counting | Tiers |
|--------|----------|-------|
| `docs.read` | distinct | 1 Curious, 5 Studious, 15 Scholar |
| `devii` | count | 1 AI Curious, 25 AI Whisperer |
| `fork` | count | 1 First Fork, 5 Forker |
| `zip` | count | 1 Archivist |
| `seo` | count | 1 SEO Auditor |
| `deepsearch` | count | 1 Researcher, 10 Deep Diver |
| `container` | count | 1 Container Captain |
| `message` | count | 1 Messenger, 100 Chatterbox |
| `bookmark` | count | 1 Bookmarker, 25 Curator |
| `reaction` | count | 1 First Reaction, 50 Cheerleader |
| `vote` | count | 1 First Star Given, 50 Supporter, 250 Patron |
| `follow` | count | 1 Friendly |
| `upload` | count | 1 First Upload |
| `project_file` | count | 1 File Creator |
| `issue` | count | 1 Bug Reporter |
| `poll` | count | 1 Pollster |
| `profile` | count | 1 Profiled |
## Where each badge is awarded
`track_action` is called at the success point of each feature:
| Action | Source location | Trigger |
|--------|-----------------|---------|
| `vote` | `content.apply_vote` | an upvote (not a toggle-off) |
| `bookmark` | `content.set_bookmark` | a save (not a removal) |
| `reaction` | `routers/reactions.py` | a reaction added |
| `follow` | `routers/follow.py` | following someone (follower side) |
| `message` | `services/messaging/persist.py` | a message sent (sender) |
| `docs.read` | `routers/docs/views.py` | an authenticated doc page view, per slug |
| `fork` / `zip` | `routers/projects/index.py` | fork or project-zip enqueue |
| `zip` / `project_file` | `routers/projects/files.py` | subtree-zip enqueue, file create |
| `seo` | `routers/tools/seo.py` | SEO audit enqueue (signed-in owner) |
| `deepsearch` | `routers/tools/deepsearch.py` | DeepSearch enqueue (signed-in owner) |
| `container` | `services/containers/api.create_instance` | instance created by a user actor |
| `upload` | `routers/uploads.py` | attachment upload |
| `issue` | `routers/issues/create.py` | issue filing enqueue |
| `poll` | `routers/polls.py` | a poll vote cast |
| `profile` | `routers/profile/index.py` | profile saved with a non-empty field |
| `devii` | `services/devii/session.py` | a completed Devii turn (user owner, non-docs channel) |
Guests are never tracked: the hooks only fire for an authenticated user. The Docii documentation
channel is excluded from the `devii` badge.
## Milestone badges (`check_milestone_badges`)
`check_milestone_badges(user_uid)` is called by `award_rewards` after every post, comment, project,
gist, upvote, and follow. It loads the badges the user already holds, then evaluates only the unheld
entries of `_COUNT_MILESTONES`, resolving each metric lazily through a memoised resolver so a metric
is queried only when an unheld badge needs it. Metric keys: `posts`, `comments`, `projects`, `gists`,
`stars`, `followers`, `following`, `streak`. Streak badges additionally emit a
`reward.streak.milestone` audit event.
## Notifications and audit
- Earning any badge fires a `badge` notification (`notify_badge`), and a level-up fires a `level`
notification. Both carry `related_uid == user_uid`, so they render with the recipient's own avatar.
- Every grant records an audit event: `reward.badge.award`, `reward.xp.grant`, `reward.level.up`, and
`reward.streak.milestone`. These appear in the admin Audit Log.
## The profile achievements showcase
`utils.build_achievements(held_names)` returns the full catalogue grouped by theme, each badge marked
earned or locked, with per-group `earned` and `total` counts. The profile route passes it as
`achievements` (plus `badge_earned` and `badge_total`) and `profile.html` renders a collapsible
**Achievements** panel above the profile tabs: earned badges are highlighted, locked ones are dimmed
and show their unlock condition, and the summary shows the overall earned-of-total count. This makes
every badge discoverable so there is always a next goal.
## Existing accounts and backfill
`_backfill_gamification()` runs at the end of `init_db()`. For accounts still at the default `xp = 0`
it computes XP from prior activity, sets the level, and runs `check_milestone_badges`, so historic
contributors are not stranded at level 1. Source-derived milestone badges are also awarded on a
user's next rewarded action. Activity-tracked badges accrue from the next time each feature is used;
they are intentionally not backfilled, because the activity counters do not exist for past actions.
## Extending the system
To add a **first-use or usage-tier** badge:
1. Add the badge to `BADGE_CATALOG` with an `icon`, `description`, and `group`.
2. Add an `ACHIEVEMENTS` entry mapping the action to its `(threshold, badge)` tiers. Add the action to
`UNIQUE_ACTIONS` if it counts distinct targets.
3. Call `track_action(user_uid, action[, target])` inline at the feature's success point. Do not wrap
it in `background.submit` - it defers itself.
To add a **source-derived milestone** badge:
1. Add the badge to `BADGE_CATALOG`.
2. Add a row to `_COUNT_MILESTONES` with an existing metric key (extend `_milestone_metrics` if a new
metric is needed). It is awarded the next time `check_milestone_badges` runs.
Keep all badge logic in these helpers; never insert into the `badges` table or compute thresholds
inline elsewhere.
</div>
@@ -9,10 +9,14 @@ on an issue you filed. You decide which reach you, and how.
Each notification type is delivered on two independent channels:
- **In-app** - the notification appears on DevPlace (the bell in the top navigation and the
`/notifications` page).
`/notifications` page). While you have DevPlace open, an enabled in-app notification also raises a
live, click-through toast in real time, and the unread bell and message badges update instantly,
so you see it without reloading.
- **Push** - a native web push notification is sent to the devices where you enabled push.
The channels are independent: you can keep a type in-app but silence its push, or the reverse.
The channels are independent: you can keep a type in-app but silence its push, or the reverse. The
live toast rides the in-app channel: silence a type's in-app box and it neither lands in your bell nor
pops a toast.
## Where to find it
@@ -48,6 +48,14 @@ Chat requests are augmented (vision), forwarded, retried, priced, and ledgered.
If the upstream response carries a numeric `cost` field, that value is used verbatim and split proportionally into input and output (the row is flagged `native_cost`). Otherwise cost is computed from the per-1M pricing: for chat, `cache_hit_tokens` and `cache_miss_tokens` are priced separately on input plus `completion_tokens` on output; for vision, input and output use the vision rates. Prompt-cache hit rate directly drives cost: at the default rates cache hits are roughly fifty times cheaper than misses.
## Response headers
Every gateway response (chat, embeddings, and passthrough; success and error) carries `X-Gateway-*` headers built from the same computed ledger row, so any caller can read the token usage and dollar cost of its own call straight from the response without querying the admin analytics. `GatewayUsageLedger.record()` returns the computed row and each handler maps it through `usage.usage_response_headers()` onto the response:
`X-Gateway-Model`, `X-Gateway-Backend`, `X-Gateway-Prompt-Tokens`, `X-Gateway-Completion-Tokens`, `X-Gateway-Total-Tokens`, `X-Gateway-Cache-Hit-Tokens`, `X-Gateway-Cache-Miss-Tokens`, `X-Gateway-Reasoning-Tokens`, `X-Gateway-Cost-USD`, `X-Gateway-Input-Cost-USD`, `X-Gateway-Output-Cost-USD`, `X-Gateway-Cost-Native` (`1` when the dollar cost is the upstream's own value, `0` when computed from the per-1M pricing), `X-Gateway-Tokens-Per-Second`, `X-Gateway-Upstream-Latency-Ms`, `X-Gateway-Total-Latency-Ms` (full end-to-end gateway time), `X-Gateway-Gateway-Overhead-Ms` (gateway processing minus upstream and queue wait), `X-Gateway-Queue-Wait-Ms` (time waiting on the concurrency semaphore), `X-Gateway-Connect-Ms` (upstream connection establishment), and, when the model's window is known, `X-Gateway-Context-Window` and `X-Gateway-Context-Utilization`. The denied embeddings-disabled path makes no upstream call and returns no usage headers.
The AI content correction worker reads `X-Gateway-Cost-USD` and the token headers off its own correction call to accumulate per-user totals (see [AI content correction](/docs/ai-correction.html)).
## Reliability layer
`retry_send()` retries on connection errors, timeouts, and upstream 5xx, up to `max_retries`, with a linear backoff of `backoff_ms * attempt`. The `CircuitBreaker` counts consecutive failures: at `threshold` it opens and new requests are rejected immediately with `503` and error type `circuit_open`; after `cooldown_seconds` it half-opens to test recovery and closes on the next success. Setting the threshold to 0 disables the breaker.
@@ -62,6 +62,24 @@ Administrators and internal services can also publish over plain HTTP, which is
Both are admin/internal only, and a publish is recorded in the audit log as `pubsub.publish`.
## Live notification toasts and badge counts
The bus powers live notification toasts. `NotificationRelayService` runs on the lock owner, polls the `notifications` table for new rows, and publishes each one to the recipient's `user.<uid>.notifications` topic. The frontend (`static/js/LiveNotifications.js`, wired as `app.liveNotifications`) subscribes to that topic for the signed-in user and raises a click-through toast. Because the relay runs on the same lock owner where every subscriber converges, the in-process publish reaches the recipient, and because a `notifications` row exists only when the in-app channel is enabled, a toast fires exactly for the notifications that user has enabled. See [Notification settings](/docs/notification-settings.html).
The same relay tick also publishes the recipient's fresh unread counts `{ notifications, messages }` to `user.<uid>.counts`. A single publish point covers both new notifications and new direct messages, because a direct message also inserts a `message`-type notification row. The header badge controller (`static/js/CounterManager.js`, wired as `app.counters`) subscribes to that topic and updates the badges instantly, and keeps a 60-second poll of `GET /notifications/counts` only as a fallback that reconciles decrements on read and any missed message.
## Live view relay (admin views without polling)
`LiveViewRelayService` is a second bridge on the lock owner that replaces the per-client HTTP polling on the admin live views with server push. On each tick it inspects the live subscriptions and, **only for topics that currently have a subscriber**, computes a fresh snapshot and publishes it. Each snapshot is the same JSON the matching admin endpoint already returns, so the page renders it unchanged. The views and their refresh cadence are:
- `container.list` (4s) and `project.<slug>.containers` (3s): container instance lists.
- `container.<uid>.detail` (4s) and `container.<uid>.logs` (3s): a single instance's status, metrics, schedules, and log tail.
- `fleet.bots` (2s): the bot fleet monitor frames.
- `admin.services` (5s) and `admin.services.<name>` (5s): background service status, logs, and metrics.
- `admin.ai-usage.<hours>` (15s): the AI gateway usage analytics for the selected window.
All of these are administrator-only by the topic rules above. The admin pages subscribe to their topic for live updates and keep a slow (15 to 20 second) HTTP poll as the initial load and as a fallback if the bus is unavailable. Computing a snapshot only when someone is subscribed means expensive views (such as AI usage analytics) cost nothing while no administrator is watching. To add a new admin live view, register one entry in the relay's view table that returns the endpoint's payload and have the page subscribe to the topic.
## Frontend client
The browser client is created once as `app.pubsub` (`static/js/PubSubClient.js`). It connects lazily on first use, reconnects with backoff (and a fast retry on the `4013` owner bounce), and re-subscribes to all topics after a reconnect.
@@ -52,7 +52,7 @@ seo_ctx = list_page_seo(
)
```
This is not only for SEO. The `.breadcrumb` element carries `padding-top: calc(var(--nav-height) + 0.5rem)`, the padding that **pushes content clear of the fixed top nav**. A page with no breadcrumbs (or only one) has its first content hidden behind the header. Always supply two or more.
This is primarily for SEO and orientation. Clearing the fixed top nav is handled once, globally, by `body { padding-top: var(--nav-height) }`, so a page is never hidden behind the header whether or not it has a breadcrumb. Still supply two or more crumbs on any nested page for navigation and breadcrumb structured data.
### 4. All content lives in the single content root
+1 -1
View File
@@ -9,7 +9,7 @@ Layout uses CSS Grid and Flexbox: one shared page shell with a small set of appr
Every page is wrapped by `base.html` in the same four-part shell, from top to bottom:
1. **Fixed top nav** (`.topnav`, height `--nav-height` = `56px`, `position: fixed`, `z-index: 1000`). Always on screen, never re-implemented per page.
2. **Breadcrumb** (`.breadcrumb`, optional but expected). It renders only when the page supplies two or more crumbs. It carries `padding-top: calc(var(--nav-height) + 0.5rem)`, which pushes content clear of the fixed nav; a page that omits breadcrumbs slides under the header. See [Consistency rules](/docs/styles-consistency.html).
2. **Breadcrumb** (`.breadcrumb`, optional but expected). It renders only when the page supplies two or more crumbs. Clearing the fixed nav is handled once by `body { padding-top: var(--nav-height) }`, so every page sits below the header whether or not it has a breadcrumb. See [Consistency rules](/docs/styles-consistency.html).
3. **Content root** (`<main class="page">`, `max-width: var(--max-content)` = `1200px`, centred, `padding: 1rem`). All page content lives inside this one element.
4. **Footer** (`.site-footer`), rendered from the `footer` block.
+43
View File
@@ -0,0 +1,43 @@
<div class="docs-content" data-render>
{% raw %}
# Timezone-aware dates
Every date and time shown on DevPlace is displayed in **your own timezone**, automatically. You do not set a timezone anywhere: the page works it out from your browser and converts every timestamp to your local time. Two people in different parts of the world looking at the same post see the same moment, each in their own clock.
## What you see
- **Relative times** such as `5m ago`, `3h ago`, or `2d ago` are shown for anything recent and update on their own while the page is open.
- After about a month, a relative time switches to an absolute **DD/MM/YYYY** date, shown in your local timezone.
- Hover over any time to see the **full local date and time** in a tooltip.
Because everything is anchored to a single universal instant, the conversion is exact: a message sent at 14:30 UTC shows as 15:30 for a reader in Central Europe and 09:30 for a reader on the US East Coast, with no configuration on either side.
## How it works
DevPlace stores and sends every timestamp as **UTC** (Coordinated Universal Time) in the standard ISO 8601 format. The server never guesses your timezone. Instead, each timestamp is delivered to your browser as a machine-readable value, and a small piece of JavaScript reformats it to your local timezone using the browser's built-in internationalization support. The browser already knows its own timezone, so this needs no cookie, no header, and no account setting.
If JavaScript is disabled, you still see a sensible fallback date (the server-rendered text inside the element), so the page is never blank. With JavaScript on, that text is replaced by your local time the moment the page loads, and any times added later (a new message arriving, more posts loading as you scroll) are localized automatically.
## For contributors
The mechanism is two Jinja globals plus one frontend module.
**Server side.** Use `local_dt(iso, mode)` or the relative-time shortcut `dt_ago(iso)` in templates. They emit a `<time>` element carrying the normalized UTC value and a server-rendered fallback:
```
<time datetime="2026-06-16T14:30:00+00:00" data-dt data-dt-mode="ago">3h ago</time>
```
`mode` is one of `date` (DD/MM/YYYY), `datetime` (DD/MM/YYYY HH:MM), or `ago` (relative). `local_dt` normalizes a naive timestamp to UTC, returns an empty string for empty input, and returns the raw value unchanged if it cannot be parsed.
**Client side.** `static/js/LocalTime.js` (reachable as `app.localTime`) finds every element marked `data-dt`, reformats its text to the browser's local timezone, and sets a full-datetime tooltip. A `MutationObserver` localizes any date inserted after load, and relative times refresh every 60 seconds. Frontend code that builds dates itself (for example the live message bubbles) creates the same `data-dt` element and the observer handles the rest.
When converting an existing display that used a precomputed relative string, keep a fallback so a missing field never blanks the date:
```
{{ dt_ago(item.created_at) if item.created_at else item.time_ago }}
```
**What stays as plain text.** The `format_date()` and `time_ago()` helpers remain plain-text functions for JSON API responses, the no-JavaScript fallback, and the few dates that must not be localized: user-entered calendar dates such as a project's release or demo date (which are plain DD/MM/YYYY values, not a specific instant), and any date placed inside an HTML attribute. Do not wrap those in `local_dt`.
{% endraw %}
</div>
+1 -1
View File
@@ -23,7 +23,7 @@
{% if is_admin(user) and author and author.get('role') %}
<span style="font-size: 0.75rem; color: var(--text-muted);">&middot; {{ author['role'] }}</span>
{% endif %}
<span style="font-size: 0.75rem; color: var(--text-muted); margin-left: 0.5rem;">&middot; {{ time_ago }}</span>
<span style="font-size: 0.75rem; color: var(--text-muted); margin-left: 0.5rem;">&middot; {{ dt_ago(gist.created_at) if gist.get('created_at') else time_ago }}</span>
</div>
</div>
+1 -1
View File
@@ -86,7 +86,7 @@
{% set _size = 20 %}{% set _size_class = "sm" %}{% set _user = item.author %}{% include "_avatar_link.html" %}
<span>{{ item.author['username'] if item.author else 'Unknown' }}</span>
</div>
<span>{{ item.time_ago }}</span>
<span>{{ dt_ago(item.created_at) if item.created_at else item.time_ago }}</span>
</div>
</div>
{% else %}
+2 -2
View File
@@ -21,7 +21,7 @@
{% endif %}
<span class="issue-author">{{ issue.author_username }}</span>
<span class="issue-dot">&middot;</span>
<span class="issue-date">{{ format_date(issue.created_at, true) }}</span>
<span class="issue-date">{{ local_dt(issue.created_at) }}</span>
</div>
{% if viewer_is_admin %}
@@ -55,7 +55,7 @@
{% endif %}
<span class="issue-author">{{ comment.author_username }}</span>
<span class="issue-dot">&middot;</span>
<span class="issue-date">{{ format_date(comment.created_at, true) }}</span>
<span class="issue-date">{{ local_dt(comment.created_at) }}</span>
</div>
<div class="issue-comment-body rendered-content" data-render>{{ comment.body }}</div>
</div>

Some files were not shown because too many files have changed in this diff Show More