Add admin configuration system and M3 content automation

Admin Configuration System:
- Add registry_settings table to database schema
- Create settings.py module with 17 configurable settings across 5 categories
  (vetting, similarity, sync, moderation, rate_limits)
- Add admin API endpoints for settings management (list/get/set/reset)
- Create admin settings web UI page with tabbed categories
- Add CLI commands: cmdforge registry config list/get/set
- Wire settings page link into admin index

M3 Content Automation Scripts:
- scrutiny.py: Quality scoring with 5 weighted checks
- similarity.py: TF-IDF based duplicate detection
- vet_pipeline.py: Combined vetting with auto-decision logic
- fabric_sync.py: Scheduled Fabric pattern synchronization
- fabric-sync.service/timer: systemd units for scheduled sync

Settings available:
- Vetting thresholds (approve/reject) and check weights
- Similarity detection thresholds (duplicate/similar/related)
- Fabric sync configuration (enabled, interval, auto-approve)
- Moderation settings (require review, auto-approve private)
- Rate limiting configuration

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-15 15:20:21 -04:00
co-authored by Claude Opus 4.5
parent d1eb4231a7
commit a5e0948881
14 changed files with 2958 additions and 0 deletions
+10
View File
@@ -190,6 +190,16 @@ def main():
p_reg_browse = registry_sub.add_parser("browse", help="Browse tools (TUI)")
p_reg_browse.set_defaults(func=cmd_registry)
# registry config (admin settings management)
p_reg_config = registry_sub.add_parser("config", help="Manage registry settings (admin)")
p_reg_config.add_argument("action", nargs="?", choices=["list", "get", "set"], default="list",
help="Action to perform (default: list)")
p_reg_config.add_argument("key", nargs="?", help="Setting key (for get/set)")
p_reg_config.add_argument("value", nargs="?", help="Setting value (for set)")
p_reg_config.add_argument("--json", action="store_true", help="Output as JSON")
p_reg_config.add_argument("--category", "-c", help="Filter by category (for list)")
p_reg_config.set_defaults(func=cmd_registry)
# Default for registry with no subcommand
p_registry.set_defaults(func=lambda args: cmd_registry(args) if args.registry_cmd else (setattr(args, 'registry_cmd', None) or cmd_registry(args)))
+158
View File
@@ -32,6 +32,8 @@ def cmd_registry(args):
return _cmd_registry_my_tools(args)
elif args.registry_cmd == "browse":
return _cmd_registry_browse(args)
elif args.registry_cmd == "config":
return _cmd_registry_config(args)
else:
# Default: show registry help
print("Registry commands:")
@@ -44,6 +46,7 @@ def cmd_registry(args):
print(" publish [path] Publish a tool")
print(" my-tools List your published tools")
print(" browse Browse tools (TUI)")
print(" config [action] Manage registry settings (admin)")
return 0
@@ -505,3 +508,158 @@ def _cmd_registry_browse(args):
from ..gui import run_gui
# Launch GUI - it will open to Registry page
return run_gui()
def _cmd_registry_config(args):
"""Manage registry settings (admin only)."""
from ..registry_client import RegistryError, get_client
action = getattr(args, 'action', 'list')
key = getattr(args, 'key', None)
value = getattr(args, 'value', None)
as_json = getattr(args, 'json', False)
category = getattr(args, 'category', None)
try:
client = get_client()
if action == "list":
return _config_list(client, as_json, category)
elif action == "get":
if not key:
print("Error: key is required for 'get' action", file=sys.stderr)
print("Usage: cmdforge registry config get <key>", file=sys.stderr)
return 1
return _config_get(client, key, as_json)
elif action == "set":
if not key or value is None:
print("Error: key and value are required for 'set' action", file=sys.stderr)
print("Usage: cmdforge registry config set <key> <value>", file=sys.stderr)
return 1
return _config_set(client, key, value)
except RegistryError as e:
if e.code == "UNAUTHORIZED":
print("Authentication failed.", file=sys.stderr)
print("This command requires admin privileges.", file=sys.stderr)
print("Set your admin token with: cmdforge config set-token <token>", file=sys.stderr)
elif e.code == "FORBIDDEN":
print("Access denied. Admin privileges required.", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print("Could not connect to the registry.", file=sys.stderr)
else:
print(f"Error: {e.message}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
return 0
def _config_list(client, as_json, category=None):
"""List all settings."""
# Use the admin settings endpoint
response = client._request("GET", "/admin/settings")
settings = response.get("settings", [])
# Filter by category if specified
if category:
settings = [s for s in settings if s.get("category") == category]
if as_json:
print(json.dumps({"settings": settings}, indent=2))
return 0
if not settings:
print("No settings found.")
return 0
# Group by category
by_category = {}
for s in settings:
cat = s.get("category", "general")
if cat not in by_category:
by_category[cat] = []
by_category[cat].append(s)
print("Registry Settings")
print("=" * 60)
for cat, cat_settings in sorted(by_category.items()):
print(f"\n[{cat.upper()}]")
for s in cat_settings:
key = s.get("key", "")
value = s.get("value")
value_type = s.get("value_type", "string")
desc = s.get("description", "")
is_default = s.get("is_default", True)
# Format value display
if value_type == "bool":
value_str = "true" if value else "false"
else:
value_str = str(value)
status = "" if is_default else " (modified)"
print(f" {key}")
print(f" Value: {value_str}{status}")
if desc:
print(f" {desc}")
print()
print("Use 'cmdforge registry config get <key>' to see a setting's value")
print("Use 'cmdforge registry config set <key> <value>' to change a setting")
return 0
def _config_get(client, key, as_json):
"""Get a specific setting."""
response = client._request("GET", f"/admin/settings/{key}")
if as_json:
print(json.dumps(response, indent=2))
return 0
setting = response.get("setting", {})
print(f"Key: {setting.get('key', key)}")
print(f"Value: {setting.get('value')}")
print(f"Type: {setting.get('value_type', 'string')}")
print(f"Category: {setting.get('category', 'general')}")
if setting.get('description'):
print(f"Description: {setting['description']}")
if setting.get('updated_at'):
print(f"Last updated: {setting['updated_at'][:19]} by {setting.get('updated_by', 'system')}")
return 0
def _config_set(client, key, value):
"""Set a setting value."""
# Try to parse value as appropriate type
parsed_value = value
# Try to parse as bool
if value.lower() in ("true", "false"):
parsed_value = value.lower() == "true"
# Try to parse as number
else:
try:
if "." in value:
parsed_value = float(value)
else:
parsed_value = int(value)
except ValueError:
# Keep as string
pass
response = client._request("PUT", f"/admin/settings/{key}", json={"value": parsed_value})
if response.get("success"):
print(f"Setting '{key}' updated successfully.")
print(f"New value: {response.get('setting', {}).get('value', parsed_value)}")
else:
print(f"Failed to update setting: {response.get('error', 'Unknown error')}", file=sys.stderr)
return 1
return 0
+157
View File
@@ -2932,6 +2932,163 @@ def create_app() -> Flask:
"meta": paginate(page, per_page, total),
})
# ─── Admin Settings API ───────────────────────────────────────────────────────
@app.route("/api/v1/admin/settings", methods=["GET"])
@require_admin
def admin_list_settings() -> Response:
"""List all configurable settings with current values."""
from .settings import get_all_settings
category = request.args.get("category")
settings = get_all_settings(g.db)
if category:
settings = [s for s in settings if s["category"] == category]
# Group by category
categories = {}
for s in settings:
cat = s["category"]
if cat not in categories:
categories[cat] = []
categories[cat].append(s)
return jsonify({
"data": settings,
"categories": categories,
"available_categories": list(categories.keys()),
})
@app.route("/api/v1/admin/settings/<path:key>", methods=["GET"])
@require_admin
def admin_get_setting(key: str) -> Response:
"""Get a single setting value."""
from .settings import get_setting, DEFAULT_SETTINGS
value = get_setting(g.db, key)
if value is None:
return error_response("NOT_FOUND", f"Setting '{key}' not found", 404)
# Find metadata
setting_meta = None
for s in DEFAULT_SETTINGS:
if s.key == key:
setting_meta = s
break
return jsonify({
"key": key,
"value": value,
"value_type": setting_meta.value_type if setting_meta else "string",
"description": setting_meta.description if setting_meta else "",
"category": setting_meta.category if setting_meta else "general",
})
@app.route("/api/v1/admin/settings/<path:key>", methods=["PUT"])
@require_admin
def admin_update_setting(key: str) -> Response:
"""Update a setting value."""
from .settings import set_setting, get_setting
data = request.get_json()
if not data or "value" not in data:
return error_response("VALIDATION_ERROR", "Missing 'value' in request body")
value = data["value"]
success = set_setting(g.db, key, value, updated_by=g.user_slug)
if not success:
return error_response(
"VALIDATION_ERROR",
f"Invalid setting key '{key}' or invalid value",
400,
)
# Log the change
log_audit(
"update_setting",
"setting",
key,
{"old_value": get_setting(g.db, key), "new_value": value},
)
return jsonify({
"success": True,
"key": key,
"value": get_setting(g.db, key),
})
@app.route("/api/v1/admin/settings/<path:key>", methods=["DELETE"])
@require_admin
def admin_reset_setting(key: str) -> Response:
"""Reset a setting to its default value."""
from .settings import reset_setting, get_setting, DEFAULT_SETTINGS
# Check if it's a valid setting
valid = any(s.key == key for s in DEFAULT_SETTINGS)
if not valid:
return error_response("NOT_FOUND", f"Setting '{key}' not found", 404)
reset_setting(g.db, key)
log_audit("reset_setting", "setting", key, {})
return jsonify({
"success": True,
"key": key,
"value": get_setting(g.db, key),
"message": "Setting reset to default",
})
@app.route("/api/v1/admin/settings/reset-all", methods=["POST"])
@require_admin
def admin_reset_all_settings() -> Response:
"""Reset all settings to defaults."""
from .settings import reset_all_settings
count = reset_all_settings(g.db)
log_audit("reset_all_settings", "settings", "all", {"count": count})
return jsonify({
"success": True,
"reset_count": count,
"message": f"Reset {count} settings to defaults",
})
@app.route("/api/v1/admin/settings/vetting", methods=["GET"])
@require_moderator
def admin_get_vetting_config() -> Response:
"""Get current vetting configuration (for moderators)."""
from .settings import get_vetting_config
return jsonify(get_vetting_config(g.db))
@app.route("/api/v1/admin/settings/similarity", methods=["GET"])
@require_moderator
def admin_get_similarity_config() -> Response:
"""Get current similarity detection configuration."""
from .settings import get_similarity_config
return jsonify(get_similarity_config(g.db))
@app.route("/api/v1/admin/settings/sync", methods=["GET"])
@require_admin
def admin_get_sync_config() -> Response:
"""Get Fabric sync configuration."""
from .settings import get_sync_config
return jsonify(get_sync_config(g.db))
@app.route("/api/v1/admin/settings/moderation", methods=["GET"])
@require_moderator
def admin_get_moderation_config() -> Response:
"""Get moderation configuration."""
from .settings import get_moderation_config
return jsonify(get_moderation_config(g.db))
# ─── Reviews & Ratings API ────────────────────────────────────────────────────
@app.route("/api/v1/tools/<owner>/<name>/reviews", methods=["POST"])
+13
View File
@@ -383,6 +383,19 @@ CREATE TABLE IF NOT EXISTS tool_usage (
);
CREATE INDEX IF NOT EXISTS idx_usage_tool ON tool_usage(tool_id);
-- Admin Configuration Settings
CREATE TABLE IF NOT EXISTS registry_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
value_type TEXT DEFAULT 'string',
description TEXT,
category TEXT DEFAULT 'general',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_settings_category ON registry_settings(category);
"""
+399
View File
@@ -0,0 +1,399 @@
"""Registry settings management.
Provides configurable settings for vetting, moderation, and sync.
Settings are stored in the database and can be modified via admin API.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from .db import query_one, query_all
@dataclass
class Setting:
"""A single configuration setting."""
key: str
value: Any
value_type: str # string, int, float, bool, json
description: str
category: str
updated_at: Optional[str] = None
updated_by: Optional[str] = None
# Default settings with descriptions
DEFAULT_SETTINGS: List[Setting] = [
# Vetting thresholds
Setting(
key="vetting.approve_threshold",
value=0.8,
value_type="float",
description="Quality score threshold for auto-approval (0.0-1.0)",
category="vetting",
),
Setting(
key="vetting.reject_threshold",
value=0.3,
value_type="float",
description="Quality score threshold for auto-rejection (0.0-1.0)",
category="vetting",
),
# Vetting weights
Setting(
key="vetting.weight.honesty",
value=0.25,
value_type="float",
description="Weight for honesty check (description matches behavior)",
category="vetting",
),
Setting(
key="vetting.weight.transparency",
value=0.20,
value_type="float",
description="Weight for transparency check (no obfuscation)",
category="vetting",
),
Setting(
key="vetting.weight.scope",
value=0.15,
value_type="float",
description="Weight for scope check (appropriate boundaries)",
category="vetting",
),
Setting(
key="vetting.weight.efficiency",
value=0.15,
value_type="float",
description="Weight for efficiency check (prompt quality)",
category="vetting",
),
Setting(
key="vetting.weight.safety",
value=0.25,
value_type="float",
description="Weight for safety check (no dangerous patterns)",
category="vetting",
),
# Similarity detection
Setting(
key="similarity.duplicate_threshold",
value=0.9,
value_type="float",
description="Similarity score to consider tools as duplicates (0.0-1.0)",
category="similarity",
),
Setting(
key="similarity.similar_threshold",
value=0.7,
value_type="float",
description="Similarity score to flag tools as similar (0.0-1.0)",
category="similarity",
),
Setting(
key="similarity.related_threshold",
value=0.5,
value_type="float",
description="Similarity score to flag tools as related (0.0-1.0)",
category="similarity",
),
# Fabric sync
Setting(
key="sync.enabled",
value=False,
value_type="bool",
description="Enable scheduled Fabric pattern sync",
category="sync",
),
Setting(
key="sync.interval_hours",
value=24,
value_type="int",
description="Hours between Fabric sync runs",
category="sync",
),
Setting(
key="sync.auto_approve",
value=False,
value_type="bool",
description="Auto-approve synced Fabric patterns (skip moderation)",
category="sync",
),
Setting(
key="sync.default_provider",
value="claude",
value_type="string",
description="Default AI provider for synced patterns",
category="sync",
),
# Moderation
Setting(
key="moderation.require_review",
value=True,
value_type="bool",
description="Require manual review for public tools",
category="moderation",
),
Setting(
key="moderation.auto_approve_private",
value=True,
value_type="bool",
description="Auto-approve private/unlisted tools",
category="moderation",
),
Setting(
key="moderation.max_pending_per_user",
value=10,
value_type="int",
description="Maximum pending tools per user before rate limiting",
category="moderation",
),
# Rate limits (can override defaults)
Setting(
key="rate_limit.publish.limit",
value=20,
value_type="int",
description="Max publish requests per window",
category="rate_limits",
),
Setting(
key="rate_limit.publish.window",
value=3600,
value_type="int",
description="Publish rate limit window in seconds",
category="rate_limits",
),
]
def _convert_value(value: str, value_type: str) -> Any:
"""Convert stored string value to appropriate type."""
if value_type == "int":
return int(value)
elif value_type == "float":
return float(value)
elif value_type == "bool":
return value.lower() in ("true", "1", "yes")
elif value_type == "json":
return json.loads(value)
return value
def _serialize_value(value: Any, value_type: str) -> str:
"""Serialize value for storage."""
if value_type == "bool":
return "true" if value else "false"
elif value_type == "json":
return json.dumps(value)
return str(value)
def get_setting(conn: sqlite3.Connection, key: str) -> Optional[Any]:
"""Get a single setting value.
Returns the default if not set in database.
"""
row = query_one(
conn,
"SELECT value, value_type FROM registry_settings WHERE key = ?",
[key],
)
if row:
return _convert_value(row["value"], row["value_type"])
# Return default if exists
for setting in DEFAULT_SETTINGS:
if setting.key == key:
return setting.value
return None
def get_settings_by_category(conn: sqlite3.Connection, category: str) -> Dict[str, Any]:
"""Get all settings for a category as a dict."""
result = {}
# Start with defaults
for setting in DEFAULT_SETTINGS:
if setting.category == category:
result[setting.key] = setting.value
# Override with DB values
rows = query_all(
conn,
"SELECT key, value, value_type FROM registry_settings WHERE category = ?",
[category],
)
for row in rows:
result[row["key"]] = _convert_value(row["value"], row["value_type"])
return result
def get_all_settings(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
"""Get all settings with their current values and metadata."""
result = []
# Get all DB settings
db_settings = {}
rows = query_all(conn, "SELECT * FROM registry_settings")
for row in rows:
db_settings[row["key"]] = dict(row)
# Build combined list
for default in DEFAULT_SETTINGS:
if default.key in db_settings:
row = db_settings[default.key]
result.append({
"key": default.key,
"value": _convert_value(row["value"], row["value_type"]),
"value_type": row["value_type"],
"description": default.description,
"category": default.category,
"updated_at": row["updated_at"],
"updated_by": row["updated_by"],
"is_default": False,
})
else:
result.append({
"key": default.key,
"value": default.value,
"value_type": default.value_type,
"description": default.description,
"category": default.category,
"updated_at": None,
"updated_by": None,
"is_default": True,
})
return result
def set_setting(
conn: sqlite3.Connection,
key: str,
value: Any,
updated_by: Optional[str] = None,
) -> bool:
"""Set a setting value.
Returns True if successful, False if key is not a valid setting.
"""
# Find the setting definition
setting_def = None
for s in DEFAULT_SETTINGS:
if s.key == key:
setting_def = s
break
if not setting_def:
return False
# Validate and convert value
try:
if setting_def.value_type == "int":
value = int(value)
elif setting_def.value_type == "float":
value = float(value)
# Validate range for thresholds
if "threshold" in key or "weight" in key:
if not 0.0 <= value <= 1.0:
return False
elif setting_def.value_type == "bool":
if isinstance(value, str):
value = value.lower() in ("true", "1", "yes")
else:
value = bool(value)
except (ValueError, TypeError):
return False
serialized = _serialize_value(value, setting_def.value_type)
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"""
INSERT INTO registry_settings (key, value, value_type, description, category, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
""",
[key, serialized, setting_def.value_type, setting_def.description,
setting_def.category, now, updated_by],
)
conn.commit()
return True
def reset_setting(conn: sqlite3.Connection, key: str) -> bool:
"""Reset a setting to its default value (remove from DB)."""
conn.execute("DELETE FROM registry_settings WHERE key = ?", [key])
conn.commit()
return True
def reset_all_settings(conn: sqlite3.Connection) -> int:
"""Reset all settings to defaults. Returns count of reset settings."""
cursor = conn.execute("SELECT COUNT(*) FROM registry_settings")
count = cursor.fetchone()[0]
conn.execute("DELETE FROM registry_settings")
conn.commit()
return count
def get_vetting_config(conn: sqlite3.Connection) -> Dict[str, Any]:
"""Get vetting configuration as a structured dict.
This is the main entry point for the scrutiny system to get config.
"""
return {
"approve_threshold": get_setting(conn, "vetting.approve_threshold"),
"reject_threshold": get_setting(conn, "vetting.reject_threshold"),
"weights": {
"honesty": get_setting(conn, "vetting.weight.honesty"),
"transparency": get_setting(conn, "vetting.weight.transparency"),
"scope": get_setting(conn, "vetting.weight.scope"),
"efficiency": get_setting(conn, "vetting.weight.efficiency"),
"safety": get_setting(conn, "vetting.weight.safety"),
},
}
def get_similarity_config(conn: sqlite3.Connection) -> Dict[str, float]:
"""Get similarity detection configuration."""
return {
"duplicate_threshold": get_setting(conn, "similarity.duplicate_threshold"),
"similar_threshold": get_setting(conn, "similarity.similar_threshold"),
"related_threshold": get_setting(conn, "similarity.related_threshold"),
}
def get_sync_config(conn: sqlite3.Connection) -> Dict[str, Any]:
"""Get Fabric sync configuration."""
return {
"enabled": get_setting(conn, "sync.enabled"),
"interval_hours": get_setting(conn, "sync.interval_hours"),
"auto_approve": get_setting(conn, "sync.auto_approve"),
"default_provider": get_setting(conn, "sync.default_provider"),
}
def get_moderation_config(conn: sqlite3.Connection) -> Dict[str, Any]:
"""Get moderation configuration."""
return {
"require_review": get_setting(conn, "moderation.require_review"),
"auto_approve_private": get_setting(conn, "moderation.auto_approve_private"),
"max_pending_per_user": get_setting(conn, "moderation.max_pending_per_user"),
}
+55
View File
@@ -973,6 +973,17 @@ def _require_moderator_role():
return None
def _require_admin_role():
"""Check if current user has admin role."""
redirect_response = _require_login()
if redirect_response:
return redirect_response
user = _load_current_publisher()
if not user or user.get("role") != "admin":
return render_template("errors/403.html"), 403
return None
@web_bp.route("/dashboard/admin", endpoint="admin_dashboard")
def admin_dashboard():
"""Admin dashboard overview."""
@@ -1077,3 +1088,47 @@ def admin_reports():
meta=meta,
status_filter=status_filter,
)
@web_bp.route("/dashboard/admin/settings", endpoint="admin_settings")
def admin_settings():
"""Admin settings configuration page."""
forbidden = _require_admin_role()
if forbidden:
return forbidden
user = _load_current_publisher()
token = session.get("auth_token")
status, payload = _api_get("/api/v1/admin/settings", token=token)
if status != 200:
return render_template(
"admin/settings.html",
user=user,
active_page="admin_settings",
settings_by_category={},
categories=[],
token=token,
error=payload.get("error", "Failed to load settings"),
)
settings = payload.get("data", [])
categories = payload.get("available_categories", [])
# Group settings by category
settings_by_category = {}
for s in settings:
cat = s.get("category", "general")
if cat not in settings_by_category:
settings_by_category[cat] = []
settings_by_category[cat].append(s)
return render_template(
"admin/settings.html",
user=user,
active_page="admin_settings",
settings_by_category=settings_by_category,
categories=categories,
token=token,
)
@@ -83,6 +83,15 @@
</svg>
<span class="text-sm font-medium text-gray-700">Manage publishers</span>
</a>
{% if user.role == 'admin' %}
<a href="{{ url_for('web.admin_settings') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span class="text-sm font-medium text-gray-700">Registry settings</span>
</a>
{% endif %}
<a href="{{ url_for('web.dashboard') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"/>
@@ -0,0 +1,231 @@
{% extends "dashboard/base.html" %}
{% block dashboard_header %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Registry Settings</h1>
<p class="mt-1 text-gray-600">Configure vetting, moderation, and sync settings</p>
</div>
<div class="flex space-x-3">
<button onclick="resetAllSettings()" class="px-4 py-2 text-sm font-medium text-red-700 bg-red-100 rounded-lg hover:bg-red-200">
Reset All to Defaults
</button>
</div>
</div>
{% endblock %}
{% block dashboard_content %}
<div class="space-y-6">
<!-- Category Tabs -->
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8" aria-label="Settings categories">
{% for cat in categories %}
<button onclick="showCategory('{{ cat }}')"
id="tab-{{ cat }}"
class="category-tab {% if loop.first %}border-indigo-500 text-indigo-600{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm capitalize">
{{ cat.replace('_', ' ') }}
</button>
{% endfor %}
</nav>
</div>
<!-- Settings Panels -->
{% for cat, cat_settings in settings_by_category.items() %}
<div id="panel-{{ cat }}" class="category-panel {% if not loop.first %}hidden{% endif %}">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 class="text-lg font-medium text-gray-900 capitalize">{{ cat.replace('_', ' ') }} Settings</h3>
</div>
<div class="divide-y divide-gray-200">
{% for setting in cat_settings %}
<div class="px-6 py-4 flex items-center justify-between">
<div class="flex-1 min-w-0 pr-4">
<p class="text-sm font-medium text-gray-900">{{ setting.key.split('.')[-1].replace('_', ' ').title() }}</p>
<p class="text-sm text-gray-500">{{ setting.description }}</p>
<p class="text-xs text-gray-400 mt-1">
Key: <code class="bg-gray-100 px-1 rounded">{{ setting.key }}</code>
{% if setting.updated_at %}
<span class="ml-2">Last updated: {{ setting.updated_at[:10] }} by {{ setting.updated_by or 'system' }}</span>
{% endif %}
</p>
</div>
<div class="flex items-center space-x-3">
{% if setting.value_type == 'bool' %}
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox"
id="setting-{{ setting.key|replace('.', '-') }}"
{% if setting.value %}checked{% endif %}
onchange="updateSetting('{{ setting.key }}', this.checked)"
class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
{% elif setting.value_type == 'float' %}
<div class="flex items-center space-x-2">
<input type="range"
id="range-{{ setting.key|replace('.', '-') }}"
min="0" max="1" step="0.05"
value="{{ setting.value }}"
oninput="document.getElementById('input-{{ setting.key|replace('.', '-') }}').value = this.value"
onchange="updateSetting('{{ setting.key }}', parseFloat(this.value))"
class="w-24 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<input type="number"
id="input-{{ setting.key|replace('.', '-') }}"
min="0" max="1" step="0.01"
value="{{ setting.value }}"
onchange="updateSetting('{{ setting.key }}', parseFloat(this.value)); document.getElementById('range-{{ setting.key|replace('.', '-') }}').value = this.value"
class="w-20 px-2 py-1 text-sm border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500">
</div>
{% elif setting.value_type == 'int' %}
<input type="number"
id="input-{{ setting.key|replace('.', '-') }}"
value="{{ setting.value }}"
onchange="updateSetting('{{ setting.key }}', parseInt(this.value))"
class="w-24 px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500">
{% else %}
<input type="text"
id="input-{{ setting.key|replace('.', '-') }}"
value="{{ setting.value }}"
onchange="updateSetting('{{ setting.key }}', this.value)"
class="w-48 px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500">
{% endif %}
<button onclick="resetSetting('{{ setting.key }}')"
title="Reset to default"
class="p-1 text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
<!-- Info Panel -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">About these settings</h3>
<div class="mt-2 text-sm text-blue-700">
<ul class="list-disc list-inside space-y-1">
<li><strong>Vetting:</strong> Controls how tools are automatically scored and approved/rejected</li>
<li><strong>Similarity:</strong> Thresholds for detecting duplicate or similar tools</li>
<li><strong>Sync:</strong> Fabric pattern synchronization settings</li>
<li><strong>Moderation:</strong> Rules for tool review workflow</li>
<li><strong>Rate Limits:</strong> API rate limiting configuration</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
const token = '{{ token }}';
function showCategory(cat) {
// Hide all panels
document.querySelectorAll('.category-panel').forEach(p => p.classList.add('hidden'));
// Show selected panel
document.getElementById('panel-' + cat).classList.remove('hidden');
// Update tab styles
document.querySelectorAll('.category-tab').forEach(t => {
t.classList.remove('border-indigo-500', 'text-indigo-600');
t.classList.add('border-transparent', 'text-gray-500');
});
document.getElementById('tab-' + cat).classList.remove('border-transparent', 'text-gray-500');
document.getElementById('tab-' + cat).classList.add('border-indigo-500', 'text-indigo-600');
}
async function updateSetting(key, value) {
try {
const response = await fetch('/api/v1/admin/settings/' + key, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({ value: value })
});
const data = await response.json();
if (response.ok) {
showToast('Setting updated successfully', 'success');
} else {
showToast(data.error || 'Failed to update setting', 'error');
}
} catch (err) {
showToast('Network error: ' + err.message, 'error');
}
}
async function resetSetting(key) {
if (!confirm('Reset this setting to its default value?')) return;
try {
const response = await fetch('/api/v1/admin/settings/' + key, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + token
}
});
const data = await response.json();
if (response.ok) {
showToast('Setting reset to default', 'success');
location.reload();
} else {
showToast(data.error || 'Failed to reset setting', 'error');
}
} catch (err) {
showToast('Network error: ' + err.message, 'error');
}
}
async function resetAllSettings() {
if (!confirm('Reset ALL settings to their default values? This cannot be undone.')) return;
try {
const response = await fetch('/api/v1/admin/settings/reset-all', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token
}
});
const data = await response.json();
if (response.ok) {
showToast('All settings reset to defaults', 'success');
location.reload();
} else {
showToast(data.error || 'Failed to reset settings', 'error');
}
} catch (err) {
showToast('Network error: ' + err.message, 'error');
}
}
function showToast(message, type) {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white ${type === 'success' ? 'bg-green-600' : 'bg-red-600'} transition-opacity duration-300`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
</script>
{% endblock %}