feat: add backup CLI commands, AI correction/modifier services, and timezone-aware date display
DevPlace CI / test (push) Failing after 25m18s
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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"},
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"})
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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']}")
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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}"})
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.backup.service import BackupService
|
||||
|
||||
__all__ = ["BackupService"]
|
||||
@@ -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})")
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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)}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,10 @@ dp-upload[hidden] {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dp-toast-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dp-toast-success {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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 ----------------
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') %}
|
||||
|
||||
@@ -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">💾</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">🕑</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">×</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 %}
|
||||
@@ -35,6 +35,9 @@
|
||||
<a href="/admin/audit-log" class="sidebar-link {% if admin_section == 'audit-log' %}active{% endif %}">
|
||||
<span class="sidebar-icon">📋</span> Audit Log
|
||||
</a>
|
||||
<a href="/admin/backups" class="sidebar-link {% if admin_section == 'backups' %}active{% endif %}">
|
||||
<span class="sidebar-icon">💾</span> Backups
|
||||
</a>
|
||||
<a href="/admin/notifications" class="sidebar-link {% if admin_section == 'notifications' %}active{% endif %}">
|
||||
<span class="sidebar-icon">🔔</span> Notifications
|
||||
</a>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -23,7 +23,7 @@
|
||||
{% if is_admin(user) and author and author.get('role') %}
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted);">· {{ author['role'] }}</span>
|
||||
{% endif %}
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted); margin-left: 0.5rem;">· {{ time_ago }}</span>
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted); margin-left: 0.5rem;">· {{ dt_ago(gist.created_at) if gist.get('created_at') else time_ago }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{% endif %}
|
||||
<span class="issue-author">{{ issue.author_username }}</span>
|
||||
<span class="issue-dot">·</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">·</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
Reference in New Issue
Block a user