Add fork display, version selector, and admin cleanup features
Registry Features: - Fork tracking with forked_from/forked_version metadata - Forked tools show "Forked from" notice on detail page - Original tools display "Forks" section listing all forks - Fork count in tool stats - API: GET /api/v1/tools/<owner>/<name>/forks GUI Improvements: - Version selector dropdown for registry installs - Fetch available versions via GET /api/v1/tools/<owner>/<name>/versions - "Latest" option plus all available versions listed Admin Features: - POST /api/v1/admin/cleanup/rejected endpoint - Maintenance section in admin dashboard - "Dry Run" and "Run Cleanup" buttons for rejected version cleanup - Configurable grace period (default 7 days) Documentation: - Updated CHANGELOG.md with all recent changes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
74b5124360
commit
adefe909c2
54
CHANGELOG.md
54
CHANGELOG.md
|
|
@ -1 +1,53 @@
|
|||
# Auto-deploy test Tue 13 Jan 2026 03:09:44 AM AST
|
||||
# Changelog
|
||||
|
||||
All notable changes to CmdForge will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
#### Registry Features
|
||||
- **Fork tracking and display**: Tools now track their fork origin with `forked_from` and `forked_version` metadata
|
||||
- Forked tools show a "Forked from" notice on the tool detail page
|
||||
- Original tools display a "Forks" section listing all forks
|
||||
- Fork count displayed in tool stats
|
||||
- API endpoint: `GET /api/v1/tools/<owner>/<name>/forks`
|
||||
|
||||
- **Version selector for installs**: Users can select specific versions when installing tools from the registry
|
||||
- Version dropdown in registry page populated via `GET /api/v1/tools/<owner>/<name>/versions`
|
||||
- "Latest" option plus all available versions listed
|
||||
- Selected version passed to install worker
|
||||
|
||||
- **Auto-cleanup rejected versions**: Admin maintenance feature to purge rejected tool submissions
|
||||
- API endpoint: `POST /api/v1/admin/cleanup/rejected`
|
||||
- Parameters:
|
||||
- `days` (default: 7) - grace period before deletion
|
||||
- `dry_run` (default: false) - preview mode without actual deletion
|
||||
- Admin dashboard UI with "Dry Run" and "Run Cleanup" buttons
|
||||
- Shows count of rejected versions pending cleanup
|
||||
|
||||
#### GUI Improvements
|
||||
- Version display and bump buttons in publish dialog
|
||||
- Auto-fetch registry version when opening publish dialog
|
||||
- Fork detection during publish workflow
|
||||
- Always refresh tools page after publish dialog closes
|
||||
|
||||
#### Admin Features
|
||||
- Maintenance section in admin dashboard
|
||||
- Rejected version count display
|
||||
- Cleanup result modal with detailed output
|
||||
|
||||
### Fixed
|
||||
- VERSION_EXISTS error showing after successful publish (made endpoint idempotent by checking config_hash)
|
||||
- My Tools page listing every version separately (now consolidated by tool name)
|
||||
- GUI not refreshing after publish dialog closes
|
||||
|
||||
### Changed
|
||||
- Publish endpoint now returns success if same config_hash already exists (idempotent)
|
||||
- My Tools page groups versions by tool name, showing version count and list
|
||||
|
||||
---
|
||||
|
||||
## Previous Changes
|
||||
|
||||
See git history for changes prior to this changelog.
|
||||
|
|
|
|||
|
|
@ -57,9 +57,9 @@ class SearchWorker(QThread):
|
|||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class InstallWorker(QThread):
|
||||
"""Background worker for tool installation."""
|
||||
finished = Signal(str)
|
||||
class VersionsWorker(QThread):
|
||||
"""Background worker to fetch available versions."""
|
||||
finished = Signal(list) # List of version strings
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, owner: str, name: str):
|
||||
|
|
@ -70,8 +70,29 @@ class InstallWorker(QThread):
|
|||
def run(self):
|
||||
try:
|
||||
client = RegistryClient()
|
||||
client.install_tool(self.owner, self.name)
|
||||
self.finished.emit(f"{self.owner}/{self.name}")
|
||||
versions = client.get_tool_versions(self.owner, self.name)
|
||||
self.finished.emit(versions)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class InstallWorker(QThread):
|
||||
"""Background worker for tool installation."""
|
||||
finished = Signal(str)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, owner: str, name: str, version: str = None):
|
||||
super().__init__()
|
||||
self.owner = owner
|
||||
self.name = name
|
||||
self.version = version
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
client = RegistryClient()
|
||||
client.install_tool(self.owner, self.name, version=self.version)
|
||||
version_str = f"@{self.version}" if self.version else ""
|
||||
self.finished.emit(f"{self.owner}/{self.name}{version_str}")
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
|
@ -84,7 +105,9 @@ class RegistryPage(QWidget):
|
|||
self.main_window = main_window
|
||||
self._search_worker = None
|
||||
self._install_worker = None
|
||||
self._versions_worker = None
|
||||
self._selected_tool = None
|
||||
self._available_versions = []
|
||||
self._current_page = 1
|
||||
self._total_pages = 1
|
||||
self._current_tags = []
|
||||
|
|
@ -228,6 +251,23 @@ class RegistryPage(QWidget):
|
|||
|
||||
right_layout.addWidget(details_box, 1)
|
||||
|
||||
# Version selector row
|
||||
version_row = QHBoxLayout()
|
||||
version_row.setSpacing(8)
|
||||
|
||||
version_label = QLabel("Version:")
|
||||
version_label.setStyleSheet("color: #4a5568;")
|
||||
version_row.addWidget(version_label)
|
||||
|
||||
self.version_combo = QComboBox()
|
||||
self.version_combo.setMinimumWidth(120)
|
||||
self.version_combo.addItem("Latest", None)
|
||||
self.version_combo.setEnabled(False)
|
||||
version_row.addWidget(self.version_combo)
|
||||
|
||||
version_row.addStretch()
|
||||
right_layout.addLayout(version_row)
|
||||
|
||||
# Action buttons
|
||||
actions = QHBoxLayout()
|
||||
|
||||
|
|
@ -405,6 +445,9 @@ class RegistryPage(QWidget):
|
|||
self.details_text.clear()
|
||||
self.btn_install.setEnabled(False)
|
||||
self.btn_update.hide()
|
||||
self.version_combo.clear()
|
||||
self.version_combo.addItem("Latest", None)
|
||||
self.version_combo.setEnabled(False)
|
||||
return
|
||||
|
||||
row = items[0].row()
|
||||
|
|
@ -413,11 +456,47 @@ class RegistryPage(QWidget):
|
|||
self._selected_tool = tool
|
||||
self._show_tool_details(tool)
|
||||
|
||||
# Reset and fetch versions
|
||||
self.version_combo.clear()
|
||||
self.version_combo.addItem("Loading...", None)
|
||||
self.version_combo.setEnabled(False)
|
||||
self._fetch_versions(tool.get("owner", ""), tool.get("name", ""))
|
||||
|
||||
# Check if installed / update available
|
||||
tool_name = tool.get("name", "")
|
||||
self._update_install_buttons()
|
||||
|
||||
def _fetch_versions(self, owner: str, name: str):
|
||||
"""Fetch available versions for a tool."""
|
||||
self._versions_worker = VersionsWorker(owner, name)
|
||||
self._versions_worker.finished.connect(self._on_versions_fetched)
|
||||
self._versions_worker.error.connect(self._on_versions_error)
|
||||
self._versions_worker.start()
|
||||
|
||||
def _on_versions_fetched(self, versions: list):
|
||||
"""Handle versions fetch completion."""
|
||||
self._available_versions = versions
|
||||
self.version_combo.clear()
|
||||
self.version_combo.addItem("Latest", None)
|
||||
for version in versions:
|
||||
self.version_combo.addItem(f"v{version}", version)
|
||||
self.version_combo.setEnabled(len(versions) > 1)
|
||||
|
||||
def _on_versions_error(self, error: str):
|
||||
"""Handle versions fetch error."""
|
||||
self._available_versions = []
|
||||
self.version_combo.clear()
|
||||
self.version_combo.addItem("Latest", None)
|
||||
self.version_combo.setEnabled(False)
|
||||
|
||||
def _update_install_buttons(self):
|
||||
"""Update install/update buttons based on selection."""
|
||||
if not self._selected_tool:
|
||||
return
|
||||
|
||||
tool_name = self._selected_tool.get("name", "")
|
||||
if tool_name in self._installed_tools:
|
||||
installed_version = self._installed_tools[tool_name]
|
||||
registry_version = tool.get("version", "1.0.0")
|
||||
registry_version = self._selected_tool.get("version", "1.0.0")
|
||||
if installed_version != registry_version:
|
||||
self.btn_install.hide()
|
||||
self.btn_update.show()
|
||||
|
|
@ -546,12 +625,16 @@ class RegistryPage(QWidget):
|
|||
|
||||
owner = self._selected_tool.get("owner", "")
|
||||
name = self._selected_tool.get("name", "")
|
||||
version = self.version_combo.currentData() # None for "Latest"
|
||||
|
||||
self.btn_install.setEnabled(False)
|
||||
self.btn_update.setEnabled(False)
|
||||
self.status_label.setText(f"Installing {owner}/{name}...")
|
||||
self.version_combo.setEnabled(False)
|
||||
|
||||
self._install_worker = InstallWorker(owner, name)
|
||||
version_str = f"@{version}" if version else ""
|
||||
self.status_label.setText(f"Installing {owner}/{name}{version_str}...")
|
||||
|
||||
self._install_worker = InstallWorker(owner, name, version)
|
||||
self._install_worker.finished.connect(self._on_install_complete)
|
||||
self._install_worker.error.connect(self._on_install_error)
|
||||
self._install_worker.start()
|
||||
|
|
|
|||
|
|
@ -972,6 +972,17 @@ def create_app() -> Flask:
|
|||
"url": row["source_url"],
|
||||
}
|
||||
|
||||
# Count forks of this tool
|
||||
fork_count = 0
|
||||
fork_pattern = f"{row['owner']}/{row['name']}"
|
||||
fork_row = query_one(
|
||||
g.db,
|
||||
"SELECT COUNT(DISTINCT owner || '/' || name) as cnt FROM tools WHERE forked_from = ? AND moderation_status = 'approved'",
|
||||
[fork_pattern]
|
||||
)
|
||||
if fork_row:
|
||||
fork_count = fork_row["cnt"]
|
||||
|
||||
payload = {
|
||||
"owner": row["owner"],
|
||||
"name": row["name"],
|
||||
|
|
@ -987,6 +998,9 @@ def create_app() -> Flask:
|
|||
"config": row["config_yaml"],
|
||||
"readme": row["readme"],
|
||||
"source": source_obj,
|
||||
"forked_from": row.get("forked_from"),
|
||||
"forked_version": row.get("forked_version"),
|
||||
"fork_count": fork_count,
|
||||
}
|
||||
response = jsonify({"data": payload})
|
||||
response.headers["Cache-Control"] = "max-age=60"
|
||||
|
|
@ -1015,6 +1029,55 @@ def create_app() -> Flask:
|
|||
versions = [row["version"] for row in rows]
|
||||
return jsonify({"data": {"versions": versions}})
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/forks", methods=["GET"])
|
||||
def list_tool_forks(owner: str, name: str) -> Response:
|
||||
"""List all forks of a tool."""
|
||||
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
||||
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
|
||||
|
||||
# Check if the original tool exists
|
||||
original = query_one(
|
||||
g.db,
|
||||
"SELECT id FROM tools WHERE owner = ? AND name = ? LIMIT 1",
|
||||
[owner, name]
|
||||
)
|
||||
if not original:
|
||||
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' does not exist", 404)
|
||||
|
||||
# Find all tools that were forked from this one
|
||||
fork_pattern = f"{owner}/{name}"
|
||||
rows = query_all(
|
||||
g.db,
|
||||
"""
|
||||
SELECT DISTINCT owner, name, description, forked_version,
|
||||
(SELECT MAX(version) FROM tools t2 WHERE t2.owner = t.owner AND t2.name = t.name) as latest_version,
|
||||
(SELECT SUM(downloads) FROM tools t2 WHERE t2.owner = t.owner AND t2.name = t.name) as total_downloads
|
||||
FROM tools t
|
||||
WHERE forked_from = ? AND moderation_status = 'approved'
|
||||
ORDER BY total_downloads DESC
|
||||
""",
|
||||
[fork_pattern]
|
||||
)
|
||||
|
||||
forks = [
|
||||
{
|
||||
"owner": row["owner"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"forked_version": row["forked_version"],
|
||||
"latest_version": row["latest_version"],
|
||||
"downloads": row["total_downloads"] or 0,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
"data": {
|
||||
"forks": forks,
|
||||
"fork_count": len(forks),
|
||||
}
|
||||
})
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/download", methods=["GET"])
|
||||
def download_tool(owner: str, name: str) -> Response:
|
||||
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
||||
|
|
@ -2849,6 +2912,85 @@ def create_app() -> Flask:
|
|||
|
||||
return jsonify({"data": {"status": "removed", "tool_id": tool_id}})
|
||||
|
||||
@app.route("/api/v1/admin/cleanup/rejected", methods=["POST"])
|
||||
@require_admin
|
||||
def admin_cleanup_rejected() -> Response:
|
||||
"""
|
||||
Delete rejected tool versions older than N days.
|
||||
|
||||
This endpoint permanently deletes tools that were rejected during
|
||||
moderation after a grace period, allowing users time to see the
|
||||
rejection reason and fix issues.
|
||||
|
||||
Query params:
|
||||
days: Number of days to retain rejected versions (default: 7)
|
||||
dry_run: If "true", only report what would be deleted without deleting
|
||||
"""
|
||||
days = request.args.get("days", 7, type=int)
|
||||
dry_run = request.args.get("dry_run", "false").lower() == "true"
|
||||
|
||||
if days < 0:
|
||||
return error_response("VALIDATION_ERROR", "Days must be non-negative", 400)
|
||||
|
||||
# Calculate cutoff date
|
||||
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
|
||||
|
||||
# Find rejected tools older than cutoff
|
||||
rejected_tools = query_all(
|
||||
g.db,
|
||||
"""
|
||||
SELECT id, owner, name, version, moderated_at
|
||||
FROM tools
|
||||
WHERE moderation_status = 'rejected'
|
||||
AND moderated_at < ?
|
||||
""",
|
||||
[cutoff],
|
||||
)
|
||||
|
||||
if not rejected_tools:
|
||||
return jsonify({
|
||||
"data": {
|
||||
"deleted_count": 0,
|
||||
"dry_run": dry_run,
|
||||
"cutoff_date": cutoff,
|
||||
"deleted_tools": [],
|
||||
}
|
||||
})
|
||||
|
||||
deleted_tools = []
|
||||
for tool in rejected_tools:
|
||||
deleted_tools.append({
|
||||
"id": tool["id"],
|
||||
"name": f"{tool['owner']}/{tool['name']}",
|
||||
"version": tool["version"],
|
||||
"moderated_at": tool["moderated_at"],
|
||||
})
|
||||
|
||||
if not dry_run:
|
||||
tool_id = tool["id"]
|
||||
# Delete associated records
|
||||
g.db.execute("DELETE FROM download_stats WHERE tool_id = ?", [tool_id])
|
||||
g.db.execute("DELETE FROM reports WHERE tool_id = ?", [tool_id])
|
||||
g.db.execute("DELETE FROM featured_tools WHERE tool_id = ?", [tool_id])
|
||||
g.db.execute("DELETE FROM tools WHERE id = ?", [tool_id])
|
||||
|
||||
if not dry_run:
|
||||
g.db.commit()
|
||||
log_audit("cleanup_rejected", "system", "cleanup", {
|
||||
"days": days,
|
||||
"deleted_count": len(deleted_tools),
|
||||
"tool_ids": [t["id"] for t in deleted_tools],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"data": {
|
||||
"deleted_count": len(deleted_tools),
|
||||
"dry_run": dry_run,
|
||||
"cutoff_date": cutoff,
|
||||
"deleted_tools": deleted_tools,
|
||||
}
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/tools/<int:tool_id>", methods=["DELETE"])
|
||||
@require_admin
|
||||
def admin_delete_tool(tool_id: int) -> Response:
|
||||
|
|
|
|||
|
|
@ -418,6 +418,10 @@ def tool_detail(owner: str, name: str):
|
|||
issues = issues_payload.get("data", [])
|
||||
issues_meta = issues_payload.get("meta", {})
|
||||
|
||||
# Load forks list
|
||||
_, forks_payload = _api_get(f"/api/v1/tools/{owner}/{name}/forks")
|
||||
forks = forks_payload.get("data", {}).get("forks", [])
|
||||
|
||||
return render_template(
|
||||
"pages/tool_detail.html",
|
||||
tool=tool,
|
||||
|
|
@ -428,6 +432,7 @@ def tool_detail(owner: str, name: str):
|
|||
reviews_total=reviews_meta.get("total", 0),
|
||||
issues=issues,
|
||||
issues_total=issues_meta.get("total", 0),
|
||||
forks=forks,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -460,6 +465,10 @@ def tool_version(owner: str, name: str, version: str):
|
|||
issues = issues_payload.get("data", [])
|
||||
issues_meta = issues_payload.get("meta", {})
|
||||
|
||||
# Load forks list
|
||||
_, forks_payload = _api_get(f"/api/v1/tools/{owner}/{name}/forks")
|
||||
forks = forks_payload.get("data", {}).get("forks", [])
|
||||
|
||||
return render_template(
|
||||
"pages/tool_detail.html",
|
||||
tool=tool,
|
||||
|
|
@ -470,6 +479,7 @@ def tool_version(owner: str, name: str, version: str):
|
|||
reviews_total=reviews_meta.get("total", 0),
|
||||
issues=issues,
|
||||
issues_total=issues_meta.get("total", 0),
|
||||
forks=forks,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1114,6 +1124,10 @@ def admin_dashboard():
|
|||
status, payload = _api_get("/api/v1/admin/publishers?per_page=1", token=token)
|
||||
publishers_count = payload.get("meta", {}).get("total", 0) if status == 200 else 0
|
||||
|
||||
# Get rejected tools count for cleanup
|
||||
status, payload = _api_get("/api/v1/admin/scrutiny?moderation_status=rejected&per_page=1", token=token)
|
||||
rejected_count = payload.get("meta", {}).get("total", 0) if status == 200 else 0
|
||||
|
||||
return render_template(
|
||||
"admin/index.html",
|
||||
user=user,
|
||||
|
|
@ -1121,6 +1135,7 @@ def admin_dashboard():
|
|||
pending_count=pending_count,
|
||||
reports_count=reports_count,
|
||||
publishers_count=publishers_count,
|
||||
rejected_count=rejected_count,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -106,5 +106,135 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Section (Admin only) -->
|
||||
{% if user.role == 'admin' %}
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Maintenance</h3>
|
||||
<div class="space-y-4">
|
||||
<!-- Cleanup Rejected Versions -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-red-100 rounded-lg p-2 mr-4">
|
||||
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700">Cleanup Rejected Versions</p>
|
||||
<p class="text-xs text-gray-500">Delete rejected tool versions older than 7 days</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<span id="rejected-count">{{ rejected_count }}</span> rejected version(s) in registry
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="runCleanup(true)" class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Dry Run
|
||||
</button>
|
||||
<button onclick="runCleanup(false)" class="px-3 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
|
||||
Run Cleanup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Cleanup Result Modal -->
|
||||
<div id="cleanup-modal" class="fixed inset-0 bg-gray-900 bg-opacity-60 hidden overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto border-2 border-gray-300 w-[32rem] shadow-2xl rounded-lg bg-white">
|
||||
<div class="bg-gray-50 px-5 py-4 rounded-t-lg border-b border-gray-200">
|
||||
<h3 id="cleanup-modal-title" class="text-lg font-medium text-gray-900">Cleanup Results</h3>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div id="cleanup-loading" class="text-center py-4">
|
||||
<svg class="animate-spin h-8 w-8 text-indigo-600 mx-auto" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-600">Running cleanup...</p>
|
||||
</div>
|
||||
<div id="cleanup-result" class="hidden">
|
||||
<div id="cleanup-summary" class="mb-4 p-3 rounded-lg bg-gray-50"></div>
|
||||
<div id="cleanup-list" class="max-h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-4 rounded-b-lg flex justify-end">
|
||||
<button onclick="closeCleanupModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function runCleanup(dryRun) {
|
||||
document.getElementById('cleanup-modal').classList.remove('hidden');
|
||||
document.getElementById('cleanup-loading').classList.remove('hidden');
|
||||
document.getElementById('cleanup-result').classList.add('hidden');
|
||||
document.getElementById('cleanup-modal-title').textContent = dryRun ? 'Cleanup Preview (Dry Run)' : 'Cleanup Results';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/cleanup/rejected?days=7&dry_run=${dryRun}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer {{ session.get("auth_token") }}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error?.message || 'Cleanup failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const data = result.data;
|
||||
|
||||
document.getElementById('cleanup-loading').classList.add('hidden');
|
||||
document.getElementById('cleanup-result').classList.remove('hidden');
|
||||
|
||||
// Summary
|
||||
const summaryHtml = `
|
||||
<p class="text-sm">
|
||||
<span class="font-medium">${data.deleted_count}</span> rejected version(s)
|
||||
${data.dry_run ? 'would be' : 'were'} deleted
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Cutoff: ${data.cutoff_date.split('T')[0]}</p>
|
||||
${data.dry_run ? '<p class="text-xs text-yellow-600 mt-2">This was a dry run. No tools were actually deleted.</p>' : ''}
|
||||
`;
|
||||
document.getElementById('cleanup-summary').innerHTML = summaryHtml;
|
||||
|
||||
// List of deleted tools
|
||||
if (data.deleted_tools && data.deleted_tools.length > 0) {
|
||||
const listHtml = '<ul class="space-y-2">' + data.deleted_tools.map(tool => `
|
||||
<li class="text-sm p-2 bg-white rounded border">
|
||||
<span class="font-medium">${tool.name}</span>
|
||||
<span class="text-gray-500">v${tool.version}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">rejected ${tool.moderated_at?.split('T')[0] || 'unknown'}</span>
|
||||
</li>
|
||||
`).join('') + '</ul>';
|
||||
document.getElementById('cleanup-list').innerHTML = listHtml;
|
||||
} else {
|
||||
document.getElementById('cleanup-list').innerHTML = '<p class="text-sm text-gray-500">No rejected versions found older than 7 days.</p>';
|
||||
}
|
||||
|
||||
// Update the count display if not dry run
|
||||
if (!data.dry_run) {
|
||||
const newCount = Math.max(0, parseInt(document.getElementById('rejected-count').textContent) - data.deleted_count);
|
||||
document.getElementById('rejected-count').textContent = newCount;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('cleanup-loading').classList.add('hidden');
|
||||
document.getElementById('cleanup-result').classList.remove('hidden');
|
||||
document.getElementById('cleanup-summary').innerHTML = `<p class="text-sm text-red-600">Error: ${error.message}</p>`;
|
||||
document.getElementById('cleanup-list').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function closeCleanupModal() {
|
||||
document.getElementById('cleanup-modal').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,24 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tool.source %}
|
||||
{% if tool.forked_from %}
|
||||
<div class="mt-4 p-3 bg-purple-50 border border-purple-200 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-purple-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<div class="text-sm text-purple-800">
|
||||
<span class="font-medium">Forked from</span>
|
||||
{% set fork_parts = tool.forked_from.split('/') %}
|
||||
<a href="{{ url_for('web.tool_detail', owner=fork_parts[0], name=fork_parts[1]) }}"
|
||||
class="font-medium underline hover:text-purple-900">{{ tool.forked_from }}</a>
|
||||
{% if tool.forked_version %}
|
||||
<span class="text-purple-600">v{{ tool.forked_version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif tool.source %}
|
||||
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-amber-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -330,6 +347,44 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Forks -->
|
||||
{% if forks %}
|
||||
<div class="bg-white rounded-lg border border-purple-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-5 h-5 text-purple-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
Forks
|
||||
<span class="ml-2 px-2 py-0.5 text-xs font-medium text-purple-700 bg-purple-100 rounded-full">{{ forks|length }}</span>
|
||||
</span>
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
{% for fork in forks[:5] %}
|
||||
<li>
|
||||
<a href="{{ url_for('web.tool_detail', owner=fork.owner, name=fork.name) }}"
|
||||
class="block hover:bg-purple-50 -mx-2 px-2 py-1 rounded">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900 hover:text-purple-600">
|
||||
{{ fork.owner }}/{{ fork.name }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">v{{ fork.latest_version }}</span>
|
||||
</div>
|
||||
{% if fork.description %}
|
||||
<p class="text-xs text-gray-500 truncate mt-0.5">{{ fork.description|truncate(60) }}</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if forks|length > 5 %}
|
||||
<p class="mt-3 text-sm text-center text-purple-600">
|
||||
+ {{ forks|length - 5 }} more fork{{ 's' if forks|length - 5 != 1 else '' }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Stats</h3>
|
||||
|
|
@ -359,6 +414,12 @@
|
|||
<dd class="text-sm font-medium text-gray-900">{{ rating.unique_users|format_number }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if tool.fork_count %}
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-sm text-gray-500">Forks</dt>
|
||||
<dd class="text-sm font-medium text-purple-600">{{ tool.fork_count }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue