0.4.1.0.a2

- Docs site changes
  - Details brief no longer opens automatically on narrower devices
  - Implemented a counter that displays the number of executions for each command
This commit is contained in:
Franz Rolfsvaag 2025-08-16 02:26:49 +02:00
parent fdd336fe91
commit 4e86eb43fc
6 changed files with 174 additions and 28 deletions

View File

@ -385,7 +385,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
<div class="meta"> <div class="meta">
${r.cog?`<span>cog: ${r.cog}</span>`:''} ${r.cog?`<span>cog: ${r.cog}</span>`:''}
${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''} ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
${r.required_permissions&&r.required_permissions.length?`<span>perms: ${r.required_permissions.join(', ')}</span>`:''} <span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
</div> </div>
${usageBlockHTML(r.usage_prefix)} ${usageBlockHTML(r.usage_prefix)}
${usageBlockHTML(r.usage_slash)} ${usageBlockHTML(r.usage_slash)}
@ -454,7 +454,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
<div class="meta"> <div class="meta">
${r.cog?`<span>cog: ${r.cog}</span>`:''} ${r.cog?`<span>cog: ${r.cog}</span>`:''}
${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''} ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
${r.required_permissions&&r.required_permissions.length?`<span>perms: ${r.required_permissions.join(', ')}</span>`:''} <span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
</div> </div>
${usageHTML} ${usageHTML}
${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''} ${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''}
@ -475,28 +475,56 @@ fdbtn?.addEventListener('click', closeFullDetails);
function render(target, rows){ target.innerHTML=''; rows.forEach(r=>target.appendChild(card(r))); } function render(target, rows){ target.innerHTML=''; rows.forEach(r=>target.appendChild(card(r))); }
function applyFilter(selectAnchorIfPresent=true){ function applyFilter(selectAnchorIfPresent=true){
if(!data) return; if(!data) return;
const all=data.all||[], mods=(data.sections&&data.sections.moderator)||[], users=(data.sections&&data.sections.user)||[]; const all=data.all||[], mods=(data.sections&&data.sections.moderator)||[], users=(data.sections&&data.sections.user)||[];
countsEl.textContent=`User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`; countsEl.textContent=`User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`;
const q=(qEl.value||'').toLowerCase(); const q=(qEl.value||'').toLowerCase();
const {filter, cmd}=getFilterFromHash(); const {filter, cmd}=getFilterFromHash();
const src=filter==='moderator'?mods:(filter==='all'?all:users); const src=filter==='moderator'?mods:(filter==='all'?all:users);
const rows=!q?src:src.filter(r=>([shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase().includes(q))); const rows=!q?src:src.filter(r=>([shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase().includes(q)));
render(listEl, rows); render(listEl, rows);
let sel=rows[0]; const isMobile = matchMedia('(max-width: 900px)').matches;
if(selectAnchorIfPresent && cmd){ const hit=rows.find(r=>rowAnchor(r)===cmd); if(hit) sel=hit;
const cardEl=document.getElementById('card-'+cmd); if(cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'}); } // Selection logic:
if(sel) openDetails(sel); // - If URL has an anchor, prefer that (scroll to it)
// - Otherwise, ONLY auto-open first row on non-mobile
let sel = null;
if (selectAnchorIfPresent && cmd) {
const hit = rows.find(r => rowAnchor(r) === cmd);
if (hit) sel = hit;
const cardEl = document.getElementById('card-'+cmd);
if (cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'});
} else if (!isMobile && rows.length) {
sel = rows[0];
} }
// Do not auto-open on mobile
if (sel && !isMobile) openDetails(sel);
}
async function boot(){ async function boot(){
computeStickyTop(); computeStickyTop();
try{ try{
if(!data){ const res=await fetch('/api/commands',{cache:'no-store'}); if(!res.ok) throw new Error('HTTP '+res.status); data=await res.json(); } async function loadData(){
try{
const res = await fetch('/api/commands', { cache:'no-store' });
if(!res.ok) throw new Error('HTTP '+res.status);
data = await res.json();
return true; // fetched fresh
}catch(e){
// fallback to inline bootstrap if fetch fails
if(window.__DATA__){ data = window.__DATA__; return false; }
throw e;
}
}
addEventListener('hashchange', ()=>applyFilter(false)); addEventListener('hashchange', ()=>applyFilter(false));
qEl.addEventListener('input', ()=>applyFilter(false)); qEl.addEventListener('input', ()=>applyFilter(false));
await loadData(); // always refresh from backend
applyFilter(true); applyFilter(true);
}catch{ document.getElementById('alerts').textContent='Failed to load.'; } }catch{ document.getElementById('alerts').textContent='Failed to load.'; }
@ -524,6 +552,12 @@ fdbtn?.addEventListener('click', closeFullDetails);
const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); const sec=s%60; const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); const sec=s%60;
const parts=[]; if(d) parts.push(d+'d'); if(h||d) parts.push(h+'h'); if(m||h||d) parts.push(m+'m'); parts.push(sec+'s'); return parts.join(' '); } const parts=[]; if(d) parts.push(d+'d'); if(h||d) parts.push(h+'h'); if(m||h||d) parts.push(m+'m'); parts.push(sec+'s'); return parts.join(' '); }
setInterval(async ()=>{
try{
const gotFresh = await loadData();
if(gotFresh) applyFilter(false);
}catch{}
}, 30000);
boot(); boot();
})(); })();
</script> </script>

2
bot.py
View File

@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
# Version consists of: # Version consists of:
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update) # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.4.1.0.a1" VERSION = "0.4.1.0.a2"
# ---------- Env loading ---------- # ---------- Env loading ----------

View File

@ -1,3 +1,4 @@
# data_manager.py
import json import json
import threading import threading
import shutil import shutil
@ -12,7 +13,7 @@ class DataManager:
def _load(self): def _load(self):
try: try:
with open(self.json_path, 'r') as f: with open(self.json_path, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
except FileNotFoundError: except FileNotFoundError:
default = { default = {
@ -34,17 +35,16 @@ class DataManager:
'nick_claim_pending': [], 'nick_claim_pending': [],
'nick_reviews': [], 'nick_reviews': [],
'rr_msg_channels': [], 'rr_msg_channels': [],
'_counters': {},
} }
self._save(default) self._save(default)
return default return default
def _safe_write(self, data: dict): def _safe_write(self, data: dict):
# ensure parent dir exists
os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True) os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True)
tmp = self.json_path + ".tmp" tmp = self.json_path + ".tmp"
with open(tmp, 'w') as f: with open(tmp, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4) json.dump(data, f, indent=4)
# backup current file (best-effort)
if os.path.exists(self.json_path): if os.path.exists(self.json_path):
try: try:
shutil.copy2(self.json_path, self.json_path + ".bak") shutil.copy2(self.json_path, self.json_path + ".bak")
@ -53,9 +53,9 @@ class DataManager:
os.replace(tmp, self.json_path) os.replace(tmp, self.json_path)
def _save(self, data: dict): def _save(self, data: dict):
# single place to write (atomic replace + rolling .bak)
self._safe_write(data) self._safe_write(data)
# ------------- existing list helpers -------------
def get(self, category: str): def get(self, category: str):
with self.lock: with self.lock:
return list(self._data.get(category, [])) return list(self._data.get(category, []))
@ -72,15 +72,11 @@ class DataManager:
self._save(self._data) self._save(self._data)
def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool: def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool:
""" """Atomically find one item in `category` and update it with `updater`."""
Atomically find one item in `category` matching predicate and update it with `updater`.
Returns True if an item was updated, else False.
"""
with self.lock: with self.lock:
arr = self._data.get(category, []) arr = self._data.get(category, [])
for idx, item in enumerate(arr): for idx, item in enumerate(arr):
if predicate(item): if predicate(item):
# Copy → mutate → save back
new_item = dict(item) new_item = dict(item)
new_item = updater(new_item) or new_item new_item = updater(new_item) or new_item
arr[idx] = new_item arr[idx] = new_item
@ -88,3 +84,20 @@ class DataManager:
self._save(self._data) self._save(self._data)
return True return True
return False return False
# ------------- NEW: tiny key→counter helpers -------------
def incr_counter(self, key: str, by: int = 1) -> int:
with self.lock:
c = self._data.setdefault('_counters', {})
c[key] = int(c.get(key, 0)) + int(by)
self._save(self._data)
return c[key]
def get_counter(self, key: str) -> int:
with self.lock:
return int(self._data.get('_counters', {}).get(key, 0))
def get_all_counters(self, prefix: str = "") -> dict[str, int]:
with self.lock:
c = dict(self._data.get('_counters', {}))
return {k: v for k, v in c.items() if (not prefix or k.startswith(prefix))}

View File

@ -276,11 +276,12 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
pass pass
usage_prefix = _command_usage_prefix(cmd, prefix) usage_prefix = _command_usage_prefix(cmd, prefix)
qn = cmd.qualified_name # single source of truth for counters
row = { row = {
"type": ctype, "type": ctype,
"name": cmd.qualified_name, "name": cmd.qualified_name,
"display_name": cmd.qualified_name, # bare name "display_name": cmd.qualified_name,
"help": (cmd.help or "").strip(), "help": (cmd.help or "").strip(),
"brief": (cmd.brief or "").strip(), "brief": (cmd.brief or "").strip(),
"usage": usage_prefix if not is_hybrid else None, "usage": usage_prefix if not is_hybrid else None,
@ -290,6 +291,9 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
"module": getattr(getattr(cmd, "callback", None), "__module__", None), "module": getattr(getattr(cmd, "callback", None), "__module__", None),
"moderator_only": bool(is_mod), "moderator_only": bool(is_mod),
"required_permissions": perms, "required_permissions": perms,
# NEW: counter fields
"counter_key": qn,
"exec_count": _cmd_counter(bot, qn),
} }
key = ("px", row["name"]) key = ("px", row["name"])
if key not in seen: if key not in seen:
@ -332,7 +336,7 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
seen_paths = set() seen_paths = set()
for scope, leaf, path in collected: for scope, leaf, path in collected:
try: try:
canon = path.lstrip("/") # power/restart canon = path.lstrip("/") # e.g., 'power/restart'
if canon in seen_paths: if canon in seen_paths:
continue continue
seen_paths.add(canon) seen_paths.add(canon)
@ -345,6 +349,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
options = getattr(leaf, "options", None) or getattr(leaf, "parameters", None) or getattr(leaf, "_params", None) options = getattr(leaf, "options", None) or getattr(leaf, "parameters", None) or getattr(leaf, "_params", None)
usage_full = _command_usage_slash_like(display, options) usage_full = _command_usage_slash_like(display, options)
# Use leaf.qualified_name when available, it matches listener keys
qn = getattr(leaf, "qualified_name", None) or display
row = { row = {
"type": "slash", "type": "slash",
"name": "/" + canon, "name": "/" + canon,
@ -360,6 +367,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
"required_permissions": perms, "required_permissions": perms,
"extras": _safe_extras(leaf), "extras": _safe_extras(leaf),
"dm_permission": getattr(leaf, "dm_permission", None), "dm_permission": getattr(leaf, "dm_permission", None),
# NEW: counter fields
"counter_key": qn,
"exec_count": _cmd_counter(bot, qn),
} }
rows.append(row) rows.append(row)
except Exception: except Exception:
@ -367,6 +377,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
continue continue
return rows return rows
def _cmd_counter(bot, qualified_name: str) -> int:
dm = getattr(bot, "data_manager", None)
return dm.get_counter(f"cmd::{qualified_name}") if dm else 0
# ============================= # =============================
# Details loader & master JSON # Details loader & master JSON
@ -562,6 +575,10 @@ def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
h["brief_html"] = r["brief_html"] h["brief_html"] = r["brief_html"]
if r.get("details_html") and not h.get("details_html"): if r.get("details_html") and not h.get("details_html"):
h["details_html"] = r["details_html"] h["details_html"] = r["details_html"]
# NEW: sum exec_count from slash twin into the hybrid row
h["exec_count"] = int(h.get("exec_count", 0) or 0) + int(r.get("exec_count", 0) or 0)
to_remove.append(i) to_remove.append(i)
for i in sorted(to_remove, reverse=True): for i in sorted(to_remove, reverse=True):

View File

View File

@ -0,0 +1,82 @@
# modules/usage/usage_stats.py
from __future__ import annotations
from discord.ext import commands
import discord
COUNTER_KEY_PREFIX = "cmd::"
def _key_from_app(cmd: discord.app_commands.Command) -> str:
# app command qualified_name is "group sub" or "name"
name = getattr(cmd, "qualified_name", None) or getattr(cmd, "name", "unknown")
return f"{COUNTER_KEY_PREFIX}{name}"
def _key_from_ctx(ctx: commands.Context) -> str:
# prefix/hybrid qualified_name is "group sub" or "name"
c = getattr(ctx, "command", None)
name = getattr(c, "qualified_name", None) or getattr(c, "name", "unknown")
return f"{COUNTER_KEY_PREFIX}{name}"
class UsageStatsCog(commands.Cog):
"""Lightweight command run/attempt counters persisted in DataManager."""
def __init__(self, bot: commands.Bot):
self.bot = bot
print("[usage] UsageStatsCog init")
# ---- successful completions ----
@commands.Cog.listener()
async def on_app_command_completion(self, interaction: discord.Interaction, command: discord.app_commands.Command):
dm = getattr(self.bot, "data_manager", None)
if not dm:
print("[usage] skip app (no data_manager)")
return
try:
key = _key_from_app(command)
newv = dm.incr_counter(key, 1)
print(f"[usage] app ++ {key} -> {newv}")
except Exception as e:
print("[usage] incr app failed:", repr(e))
@commands.Cog.listener()
async def on_command_completion(self, ctx: commands.Context):
dm = getattr(self.bot, "data_manager", None)
if not dm:
print("[usage] skip prefix (no data_manager)")
return
try:
key = _key_from_ctx(ctx)
newv = dm.incr_counter(key, 1)
print(f"[usage] px ++ {key} -> {newv}")
except Exception as e:
print("[usage] incr prefix failed:", repr(e))
# ---- attempts that error (optional but useful while testing) ----
@commands.Cog.listener()
async def on_app_command_error(self, interaction: discord.Interaction, error: Exception):
# Count attempts (separate key) so you can confirm the listener fires at all.
dm = getattr(self.bot, "data_manager", None)
cmd = getattr(interaction, "command", None)
if dm and isinstance(cmd, discord.app_commands.Command):
try:
key = _key_from_app(cmd).replace("cmd::", "cmd_attempt::")
newv = dm.incr_counter(key, 1)
print(f"[usage] app !! {key} -> {newv} ({type(error).__name__})")
except Exception as e:
print("[usage] app err incr failed:", repr(e))
@commands.Cog.listener()
async def on_command_error(self, ctx: commands.Context, error: Exception):
dm = getattr(self.bot, "data_manager", None)
if dm and getattr(ctx, "command", None):
try:
key = _key_from_ctx(ctx).replace("cmd::", "cmd_attempt::")
newv = dm.incr_counter(key, 1)
print(f"[usage] px !! {key} -> {newv} ({type(error).__name__})")
except Exception as e:
print("[usage] px err incr failed:", repr(e))
async def setup(bot: commands.Bot):
await bot.add_cog(UsageStatsCog(bot))
print("[usage] UsageStatsCog loaded")