Add tool detail view to admin pending page
- Click tool name to view full details in modal - Shows description, arguments, steps (prompt/code), and README - Approve/Reject buttons in detail modal - New API endpoint GET /api/v1/admin/tools/<id> returns full tool config Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
706accc008
commit
3914ca74b3
|
|
@ -2459,6 +2459,59 @@ def create_app() -> Flask:
|
||||||
"meta": paginate(page, per_page, total),
|
"meta": paginate(page, per_page, total),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@app.route("/api/v1/admin/tools/<int:tool_id>", methods=["GET"])
|
||||||
|
@require_moderator
|
||||||
|
def admin_get_tool(tool_id: int) -> Response:
|
||||||
|
"""Get full tool details for admin review."""
|
||||||
|
tool = query_one(
|
||||||
|
g.db,
|
||||||
|
"""
|
||||||
|
SELECT t.*, p.display_name as publisher_name
|
||||||
|
FROM tools t
|
||||||
|
JOIN publishers p ON t.publisher_id = p.id
|
||||||
|
WHERE t.id = ?
|
||||||
|
""",
|
||||||
|
[tool_id],
|
||||||
|
)
|
||||||
|
if not tool:
|
||||||
|
return error_response("TOOL_NOT_FOUND", "Tool not found", 404)
|
||||||
|
|
||||||
|
# Parse config YAML to extract steps
|
||||||
|
config = {}
|
||||||
|
if tool["config_yaml"]:
|
||||||
|
try:
|
||||||
|
config = yaml.safe_load(tool["config_yaml"]) or {}
|
||||||
|
except yaml.YAMLError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Parse scrutiny report
|
||||||
|
scrutiny_report = None
|
||||||
|
if tool["scrutiny_report"]:
|
||||||
|
try:
|
||||||
|
scrutiny_report = json.loads(tool["scrutiny_report"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"data": {
|
||||||
|
"id": tool["id"],
|
||||||
|
"owner": tool["owner"],
|
||||||
|
"name": tool["name"],
|
||||||
|
"version": tool["version"],
|
||||||
|
"description": tool["description"],
|
||||||
|
"category": tool["category"],
|
||||||
|
"tags": tool["tags"],
|
||||||
|
"published_at": tool["published_at"],
|
||||||
|
"publisher_name": tool["publisher_name"],
|
||||||
|
"visibility": tool["visibility"],
|
||||||
|
"moderation_status": tool["moderation_status"],
|
||||||
|
"scrutiny_status": tool["scrutiny_status"],
|
||||||
|
"scrutiny_report": scrutiny_report,
|
||||||
|
"config": config,
|
||||||
|
"readme": tool["readme"] or "",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
@app.route("/api/v1/admin/tools/<int:tool_id>/approve", methods=["POST"])
|
@app.route("/api/v1/admin/tools/<int:tool_id>/approve", methods=["POST"])
|
||||||
@require_moderator
|
@require_moderator
|
||||||
def admin_approve_tool(tool_id: int) -> Response:
|
def admin_approve_tool(tool_id: int) -> Response:
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,9 @@
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-medium text-gray-900">{{ tool.owner }}/{{ tool.name }}</div>
|
<button onclick="viewTool({{ tool.id }})" class="text-sm font-medium text-indigo-600 hover:text-indigo-800 hover:underline">
|
||||||
|
{{ tool.owner }}/{{ tool.name }}
|
||||||
|
</button>
|
||||||
<div class="text-sm text-gray-500">v{{ tool.version }}</div>
|
<div class="text-sm text-gray-500">v{{ tool.version }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,6 +139,51 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tool Detail Modal -->
|
||||||
|
<div id="detail-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
|
||||||
|
<div class="relative top-10 mx-auto p-6 border max-w-4xl shadow-lg rounded-md bg-white mb-10">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 id="detail-title" class="text-lg font-medium text-gray-900">Tool Details</h3>
|
||||||
|
<button onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="detail-loading" class="py-8 text-center text-gray-500">Loading...</div>
|
||||||
|
<div id="detail-content" class="hidden">
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Description</h4>
|
||||||
|
<p id="detail-description" class="text-sm text-gray-600"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arguments -->
|
||||||
|
<div id="detail-args-section" class="mb-6">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Arguments</h4>
|
||||||
|
<div id="detail-args" class="bg-gray-50 rounded-md p-3 text-sm font-mono overflow-x-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<div id="detail-steps-section" class="mb-6">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Steps</h4>
|
||||||
|
<div id="detail-steps" class="space-y-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- README -->
|
||||||
|
<div id="detail-readme-section" class="mb-6">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">README</h4>
|
||||||
|
<div id="detail-readme" class="bg-gray-50 rounded-md p-4 text-sm prose prose-sm max-w-none overflow-auto max-h-64"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end space-x-3 border-t pt-4">
|
||||||
|
<button onclick="closeDetailModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Close</button>
|
||||||
|
<button id="detail-approve-btn" class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700">Approve</button>
|
||||||
|
<button id="detail-reject-btn" class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">Reject</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Reject Modal -->
|
<!-- Reject Modal -->
|
||||||
<div id="reject-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
|
<div id="reject-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
|
||||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||||
|
|
@ -153,12 +200,114 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let currentToolId = null;
|
let currentToolId = null;
|
||||||
|
let currentDetailToolId = null;
|
||||||
|
|
||||||
function toggleFindings(toolId) {
|
function toggleFindings(toolId) {
|
||||||
const el = document.getElementById(`findings-${toolId}`);
|
const el = document.getElementById(`findings-${toolId}`);
|
||||||
el.classList.toggle('hidden');
|
el.classList.toggle('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function viewTool(toolId) {
|
||||||
|
currentDetailToolId = toolId;
|
||||||
|
document.getElementById('detail-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('detail-loading').classList.remove('hidden');
|
||||||
|
document.getElementById('detail-content').classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/admin/tools/${toolId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer {{ session.get("auth_token") }}'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load tool details');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
const tool = result.data;
|
||||||
|
|
||||||
|
// Update title
|
||||||
|
document.getElementById('detail-title').textContent = `${tool.owner}/${tool.name} v${tool.version}`;
|
||||||
|
|
||||||
|
// Description
|
||||||
|
document.getElementById('detail-description').textContent = tool.description || 'No description';
|
||||||
|
|
||||||
|
// Arguments
|
||||||
|
const argsSection = document.getElementById('detail-args-section');
|
||||||
|
const argsDiv = document.getElementById('detail-args');
|
||||||
|
if (tool.config.arguments && tool.config.arguments.length > 0) {
|
||||||
|
argsSection.classList.remove('hidden');
|
||||||
|
argsDiv.innerHTML = tool.config.arguments.map(arg => {
|
||||||
|
const required = arg.required ? '<span class="text-red-600">*</span>' : '';
|
||||||
|
return `<div class="mb-2"><span class="text-indigo-600">${arg.flag || arg.variable}</span>${required} - ${arg.description || 'No description'}</div>`;
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
argsSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
const stepsSection = document.getElementById('detail-steps-section');
|
||||||
|
const stepsDiv = document.getElementById('detail-steps');
|
||||||
|
if (tool.config.steps && tool.config.steps.length > 0) {
|
||||||
|
stepsSection.classList.remove('hidden');
|
||||||
|
stepsDiv.innerHTML = tool.config.steps.map((step, idx) => {
|
||||||
|
let content = '';
|
||||||
|
if (step.type === 'prompt') {
|
||||||
|
content = `<div class="text-xs text-gray-500 mb-1">Prompt Step</div>
|
||||||
|
<pre class="bg-white p-2 rounded border text-xs overflow-x-auto whitespace-pre-wrap">${escapeHtml(step.content || '')}</pre>`;
|
||||||
|
} else if (step.type === 'code') {
|
||||||
|
content = `<div class="text-xs text-gray-500 mb-1">Code Step${step.output_var ? ` → ${step.output_var}` : ''}</div>
|
||||||
|
<pre class="bg-gray-900 text-green-400 p-2 rounded text-xs overflow-x-auto"><code>${escapeHtml(step.code || '')}</code></pre>`;
|
||||||
|
} else {
|
||||||
|
content = `<div class="text-xs text-gray-500">${step.type} step</div>`;
|
||||||
|
}
|
||||||
|
return `<div class="bg-gray-50 p-3 rounded-md">
|
||||||
|
<div class="text-xs font-medium text-gray-700 mb-2">Step ${idx + 1}</div>
|
||||||
|
${content}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
stepsSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// README
|
||||||
|
const readmeSection = document.getElementById('detail-readme-section');
|
||||||
|
const readmeDiv = document.getElementById('detail-readme');
|
||||||
|
if (tool.readme) {
|
||||||
|
readmeSection.classList.remove('hidden');
|
||||||
|
readmeDiv.innerHTML = `<pre class="whitespace-pre-wrap text-xs">${escapeHtml(tool.readme)}</pre>`;
|
||||||
|
} else {
|
||||||
|
readmeSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up approve/reject buttons
|
||||||
|
document.getElementById('detail-approve-btn').onclick = () => {
|
||||||
|
closeDetailModal();
|
||||||
|
approveTool(toolId);
|
||||||
|
};
|
||||||
|
document.getElementById('detail-reject-btn').onclick = () => {
|
||||||
|
closeDetailModal();
|
||||||
|
rejectTool(toolId);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('detail-loading').classList.add('hidden');
|
||||||
|
document.getElementById('detail-content').classList.remove('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading tool:', error);
|
||||||
|
document.getElementById('detail-loading').textContent = 'Error loading tool details';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetailModal() {
|
||||||
|
document.getElementById('detail-modal').classList.add('hidden');
|
||||||
|
currentDetailToolId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
async function approveTool(toolId) {
|
async function approveTool(toolId) {
|
||||||
if (!confirm('Approve this tool?')) return;
|
if (!confirm('Approve this tool?')) return;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue