Add Phase 8 features: analytics, reporting, health check

- Add pageviews table and /api/v1/analytics/pageview endpoint
- Abuse reporting API already existed at /api/v1/reports
- Add health_check.py script for monitoring
- ChatGPT additions: report modal UI, analytics tracking JS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2025-12-31 22:49:58 -04:00
parent 096c0739d8
commit 1ac3054aaa
13 changed files with 453 additions and 42 deletions

View File

@ -0,0 +1,21 @@
# code-review
Review code for issues, risks, and improvements.
## Usage
```bash
code-review --language python --focus bugs,security --severity medium < app.py
```
## Arguments
- `--language`: Programming language or `auto`.
- `--focus`: Comma-separated focus areas (e.g., `bugs,security,performance`).
- `--severity`: Minimum severity level (`low`, `medium`, `high`).
## Examples
```bash
cat main.go | code-review --language go --focus performance --severity high
```

View File

@ -0,0 +1,37 @@
name: code-review
version: "1.0.0"
description: "Review code for issues and improvements"
category: code-analysis
tags:
- code
- review
- quality
arguments:
- name: language
flag: "--language"
default: "auto"
description: "Programming language (e.g., python, javascript, go)"
- name: focus
flag: "--focus"
default: "bugs,security,performance"
description: "Comma-separated focus areas"
- name: severity
flag: "--severity"
default: "medium"
description: "Minimum severity: low, medium, high"
steps:
- type: prompt
provider: "claude"
template: |
You are a senior code reviewer.
Language: {language}
Focus areas: {focus}
Minimum severity: {severity}
Review the following code. Identify issues, risks, and improvements.
Provide a concise, prioritized list with suggestions.
Code:
{input}
output_var: review
output: "{review}"

View File

@ -0,0 +1,20 @@
# explain
Explain code or concepts clearly with adjustable audience and format.
## Usage
```bash
explain --audience beginner --format step-by-step < snippet.txt
```
## Arguments
- `--audience`: Audience level (`beginner`, `general`, `expert`).
- `--format`: Output format (`paragraph`, `bullet`, `step-by-step`).
## Examples
```bash
echo "Explain recursion" | explain --audience general --format bullet
```

View File

@ -0,0 +1,29 @@
name: explain
version: "1.0.0"
description: "Explain code or concepts clearly"
category: education
tags:
- explanation
- learning
- code
arguments:
- name: audience
flag: "--audience"
default: "general"
description: "Audience level: beginner, general, expert"
- name: format
flag: "--format"
default: "paragraph"
description: "Output format: paragraph, bullet, or step-by-step"
steps:
- type: prompt
provider: "claude"
template: |
You are a clear and concise explainer.
Audience: {audience}
Format: {format}
Explain the following content:
{input}
output_var: explanation
output: "{explanation}"

View File

@ -0,0 +1,20 @@
# summarize
Summarize text using AI. Designed for quick overviews, briefs, or executive summaries.
## Usage
```bash
summarize --max-length 200 --style neutral < input.txt
```
## Arguments
- `--max-length`: Maximum length of the summary in words. Default: `200`.
- `--style`: Summary style. Options: `neutral`, `bullet`, `executive`.
## Examples
```bash
cat report.txt | summarize --max-length 120 --style executive
```

View File

@ -0,0 +1,29 @@
name: summarize
version: "1.0.0"
description: "Summarize text using AI"
category: text-processing
tags:
- summarization
- text
- ai
arguments:
- name: max_length
flag: "--max-length"
default: "200"
description: "Maximum length of the summary in words"
- name: style
flag: "--style"
default: "neutral"
description: "Summary style: neutral, bullet, or executive"
steps:
- type: prompt
provider: "claude"
template: |
You are a helpful assistant that summarizes text.
Style: {style}
Max length: {max_length} words.
Summarize the following text:
{input}
output_var: summary
output: "{summary}"

View File

@ -0,0 +1,21 @@
# translate
Translate text between languages using AI.
## Usage
```bash
translate --from auto --to en --tone neutral < input.txt
```
## Arguments
- `--from`: Source language (e.g., `en`, `es`, `fr`) or `auto`.
- `--to`: Target language (e.g., `en`, `es`, `fr`).
- `--tone`: Translation tone: `neutral`, `formal`, `casual`.
## Examples
```bash
echo "Hola mundo" | translate --from es --to en --tone formal
```

View File

@ -0,0 +1,34 @@
name: translate
version: "1.0.0"
description: "Translate text between languages using AI"
category: text-processing
tags:
- translation
- localization
- text
arguments:
- name: from_language
flag: "--from"
default: "auto"
description: "Source language (e.g., en, es, fr) or auto"
- name: to_language
flag: "--to"
default: "en"
description: "Target language (e.g., en, es, fr)"
- name: tone
flag: "--tone"
default: "neutral"
description: "Tone: neutral, formal, or casual"
steps:
- type: prompt
provider: "claude"
template: |
You are a translation assistant.
Translate from: {from_language}
Translate to: {to_language}
Tone: {tone}
Translate the following text:
{input}
output_var: translation
output: "{translation}"

116
scripts/health_check.py Normal file
View File

@ -0,0 +1,116 @@
"""Basic health check for the SmartTools Registry API and DB."""
from __future__ import annotations
import json
import sys
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
def _add_src_to_path() -> None:
repo_root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(repo_root / "src"))
def _get_registry_url() -> str:
from smarttools.config import get_registry_url
return get_registry_url().rstrip("/")
def _get_db_path() -> Path:
from smarttools.registry.db import get_db_path
return get_db_path()
def check_api(url: str) -> str | None:
endpoint = f"{url}/tools?limit=1"
try:
req = Request(endpoint, headers={"Accept": "application/json"})
with urlopen(req, timeout=5) as resp:
if resp.status != 200:
return f"API unhealthy: status {resp.status}"
payload = json.loads(resp.read().decode("utf-8"))
if "data" not in payload:
return "API unhealthy: missing data field"
except HTTPError as exc:
return f"API unhealthy: HTTP {exc.code}"
except URLError as exc:
return f"API unhealthy: {exc.reason}"
except Exception as exc:
return f"API unhealthy: {exc}"
return None
def check_db(db_path: Path) -> str | None:
if not db_path.exists():
return f"DB missing: {db_path}"
if not db_path.is_file():
return f"DB path is not a file: {db_path}"
try:
with db_path.open("rb"):
pass
except OSError as exc:
return f"DB not readable: {exc}"
return None
def check_last_sync(db_path: Path) -> str | None:
try:
conn = sqlite3.connect(db_path)
try:
row = conn.execute("SELECT MAX(processed_at) FROM webhook_log").fetchone()
finally:
conn.close()
except sqlite3.Error as exc:
return f"DB error: {exc}"
if not row or not row[0]:
return "No webhook sync recorded in webhook_log"
raw = str(row[0]).replace("Z", "+00:00")
try:
last_sync = datetime.fromisoformat(raw)
except ValueError:
return f"Invalid sync timestamp: {row[0]}"
if datetime.utcnow() - last_sync > timedelta(hours=1):
return f"Last sync too old: {last_sync.isoformat()}"
return None
def main() -> int:
_add_src_to_path()
errors = []
registry_url = _get_registry_url()
db_path = _get_db_path()
api_error = check_api(registry_url)
if api_error:
errors.append(api_error)
db_error = check_db(db_path)
if db_error:
errors.append(db_error)
else:
sync_error = check_last_sync(db_path)
if sync_error:
errors.append(sync_error)
if errors:
for err in errors:
print(err, file=sys.stderr)
return 1
print("Health check OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1394,6 +1394,36 @@ def create_app() -> Flask:
}
})
@app.route("/api/v1/analytics/pageview", methods=["POST"])
def track_pageview() -> Response:
"""Track a page view (privacy-friendly, no cookies)."""
data = request.get_json() or {}
path = data.get("path", "").strip()
if not path or len(path) > 500:
return jsonify({"data": {"tracked": False}})
# Hash the IP for privacy (don't store raw IP)
ip_hash = hashlib.sha256(
(request.remote_addr or "unknown").encode()
).hexdigest()[:16]
referrer = request.headers.get("Referer", "")[:500] if request.headers.get("Referer") else None
user_agent = request.headers.get("User-Agent", "")[:500] if request.headers.get("User-Agent") else None
try:
g.db.execute(
"""
INSERT INTO pageviews (path, referrer, user_agent, ip_hash)
VALUES (?, ?, ?, ?)
""",
[path, referrer, user_agent, ip_hash],
)
g.db.commit()
return jsonify({"data": {"tracked": True}})
except Exception:
return jsonify({"data": {"tracked": False}})
@app.route("/api/v1/webhook/gitea", methods=["POST"])
def webhook_gitea() -> Response:
if request.content_length and request.content_length > MAX_BODY_BYTES:

View File

@ -214,6 +214,18 @@ CREATE INDEX IF NOT EXISTS idx_featured_tools_placement ON featured_tools(placem
CREATE INDEX IF NOT EXISTS idx_featured_contributors_placement ON featured_contributors(placement, status);
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_content_pages_type ON content_pages(content_type, published);
CREATE TABLE IF NOT EXISTS pageviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
referrer TEXT,
user_agent TEXT,
ip_hash TEXT,
viewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_pageviews_path ON pageviews(path, viewed_at DESC);
CREATE INDEX IF NOT EXISTS idx_pageviews_date ON pageviews(date(viewed_at), path);
"""

View File

@ -135,6 +135,73 @@ function closeSearch() {
closeSearchModal();
}
// Report modal helpers
function openReportModal() {
const modal = document.getElementById('report-modal');
if (modal) {
modal.classList.remove('hidden');
}
}
function closeReportModal() {
const modal = document.getElementById('report-modal');
if (modal) {
modal.classList.add('hidden');
}
}
function submitReport(owner, name) {
const form = document.getElementById('report-form');
if (!form) return;
const ownerField = document.getElementById('report-owner');
const nameField = document.getElementById('report-name');
if (ownerField) ownerField.value = owner;
if (nameField) nameField.value = name;
if (!form.dataset.bound) {
form.addEventListener('submit', async function (e) {
e.preventDefault();
const payload = {
tool_owner: ownerField ? ownerField.value : owner,
tool_name: nameField ? nameField.value : name,
reason: form.querySelector('[name="reason"]')?.value || '',
description: form.querySelector('[name="description"]')?.value || ''
};
try {
const response = await fetch('/api/v1/reports', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (response.ok) {
closeReportModal();
alert('Thank you for your report. We will review it shortly.');
} else {
const error = await response.json();
alert((error.error && error.error.message) || 'Failed to submit report. Please try again.');
}
} catch (err) {
alert('Failed to submit report. Please try again.');
}
});
form.dataset.bound = 'true';
}
openReportModal();
}
// Analytics
function trackPageView() {
fetch('/api/v1/analytics/pageview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: window.location.pathname})
}).catch(() => {});
}
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// ESC to close modals
@ -157,6 +224,8 @@ document.addEventListener('DOMContentLoaded', function() {
// Initialize any dynamic components
initializeDropdowns();
trackPageView();
});
// Dropdown initialization

View File

@ -121,6 +121,11 @@
</svg>
</button>
</div>
<button type="button"
onclick="submitReport('{{ tool.owner }}', '{{ tool.name }}')"
class="mt-4 w-full inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
Report Tool
</button>
</div>
<!-- Versions -->
@ -195,7 +200,7 @@
<!-- Report Tool -->
<div class="text-center">
<button type="button"
onclick="openReportModal()"
onclick="submitReport('{{ tool.owner }}', '{{ tool.name }}')"
class="text-sm text-gray-500 hover:text-red-600 transition-colors">
Report this tool
</button>
@ -218,7 +223,8 @@
Help us maintain a safe registry by reporting tools that violate our guidelines.
</p>
<input type="hidden" name="tool_id" value="{{ tool.id }}">
<input type="hidden" name="tool_owner" id="report-owner" value="{{ tool.owner }}">
<input type="hidden" name="tool_name" id="report-name" value="{{ tool.name }}">
<div class="mt-6 space-y-4">
<div>
@ -226,19 +232,18 @@
<select name="reason" id="reason" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500">
<option value="">Select a reason...</option>
<option value="malicious">Malicious code</option>
<option value="spam">Spam or advertising</option>
<option value="copyright">Copyright violation</option>
<option value="inappropriate">Inappropriate content</option>
<option value="broken">Broken or non-functional</option>
<option value="spam">Spam</option>
<option value="malicious">Malicious</option>
<option value="duplicate">Duplicate</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label for="details" class="block text-sm font-medium text-gray-700 mb-1">Details (optional)</label>
<textarea name="details" id="details" rows="3"
placeholder="Provide additional context..."
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
<textarea name="description" id="description" rows="3"
placeholder="Provide additional context"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 resize-none"></textarea>
</div>
</div>
@ -275,37 +280,5 @@ function copyInstall() {
});
}
function openReportModal() {
document.getElementById('report-modal').classList.remove('hidden');
}
function closeReportModal() {
document.getElementById('report-modal').classList.add('hidden');
}
// Handle report form submission
document.getElementById('report-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData);
try {
const response = await fetch('/api/v1/reports', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (response.ok) {
closeReportModal();
alert('Thank you for your report. We will review it shortly.');
} else {
const error = await response.json();
alert(error.error || 'Failed to submit report. Please try again.');
}
} catch (err) {
alert('Failed to submit report. Please try again.');
}
});
</script>
{% endblock %}