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:
parent
096c0739d8
commit
1ac3054aaa
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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())
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue