- Implement PEP 503 Simple API endpoints for pip compatibility:
- GET /pypi/simple/ - package index
- GET /pypi/simple/{package}/ - version list with rewritten links
- GET /pypi/simple/{package}/{filename} - download with auto-caching
- Improve upstream sources table UI:
- Center text under column headers
- Remove separate Source column, show ENV badge inline with name
- Make Test/Edit buttons more prominent with secondary button style
504 lines
17 KiB
TypeScript
504 lines
17 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import {
|
|
listUpstreamSources,
|
|
createUpstreamSource,
|
|
updateUpstreamSource,
|
|
deleteUpstreamSource,
|
|
testUpstreamSource,
|
|
} from '../api';
|
|
import { UpstreamSource, SourceType, AuthType } from '../types';
|
|
import './AdminCachePage.css';
|
|
|
|
const SOURCE_TYPES: SourceType[] = ['npm', 'pypi', 'maven', 'docker', 'helm', 'nuget', 'deb', 'rpm', 'generic'];
|
|
const AUTH_TYPES: AuthType[] = ['none', 'basic', 'bearer', 'api_key'];
|
|
|
|
function AdminCachePage() {
|
|
const { user, loading: authLoading } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
// Upstream sources state
|
|
const [sources, setSources] = useState<UpstreamSource[]>([]);
|
|
const [loadingSources, setLoadingSources] = useState(true);
|
|
const [sourcesError, setSourcesError] = useState<string | null>(null);
|
|
|
|
// Create/Edit form state
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingSource, setEditingSource] = useState<UpstreamSource | null>(null);
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
source_type: 'generic' as SourceType,
|
|
url: '',
|
|
enabled: true,
|
|
auth_type: 'none' as AuthType,
|
|
username: '',
|
|
password: '',
|
|
priority: 100,
|
|
});
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
// Test result state
|
|
const [testingId, setTestingId] = useState<string | null>(null);
|
|
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({});
|
|
|
|
// Delete confirmation state
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
|
|
// Success message
|
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
|
|
// Error modal state
|
|
const [showErrorModal, setShowErrorModal] = useState(false);
|
|
const [selectedError, setSelectedError] = useState<{ sourceName: string; error: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !user) {
|
|
navigate('/login', { state: { from: '/admin/cache' } });
|
|
}
|
|
}, [user, authLoading, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (user && user.is_admin) {
|
|
loadSources();
|
|
}
|
|
}, [user]);
|
|
|
|
useEffect(() => {
|
|
if (successMessage) {
|
|
const timer = setTimeout(() => setSuccessMessage(null), 3000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [successMessage]);
|
|
|
|
async function loadSources() {
|
|
setLoadingSources(true);
|
|
setSourcesError(null);
|
|
try {
|
|
const data = await listUpstreamSources();
|
|
setSources(data);
|
|
} catch (err) {
|
|
setSourcesError(err instanceof Error ? err.message : 'Failed to load sources');
|
|
} finally {
|
|
setLoadingSources(false);
|
|
}
|
|
}
|
|
|
|
function openCreateForm() {
|
|
setEditingSource(null);
|
|
setFormData({
|
|
name: '',
|
|
source_type: 'generic',
|
|
url: '',
|
|
enabled: true,
|
|
auth_type: 'none',
|
|
username: '',
|
|
password: '',
|
|
priority: 100,
|
|
});
|
|
setFormError(null);
|
|
setShowForm(true);
|
|
}
|
|
|
|
function openEditForm(source: UpstreamSource) {
|
|
setEditingSource(source);
|
|
setFormData({
|
|
name: source.name,
|
|
source_type: source.source_type,
|
|
url: source.url,
|
|
enabled: source.enabled,
|
|
auth_type: source.auth_type,
|
|
username: source.username || '',
|
|
password: '',
|
|
priority: source.priority,
|
|
});
|
|
setFormError(null);
|
|
setShowForm(true);
|
|
}
|
|
|
|
async function handleFormSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!formData.name.trim()) {
|
|
setFormError('Name is required');
|
|
return;
|
|
}
|
|
if (!formData.url.trim()) {
|
|
setFormError('URL is required');
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
setFormError(null);
|
|
|
|
try {
|
|
let savedSourceId: string | null = null;
|
|
|
|
if (editingSource) {
|
|
// Update existing source
|
|
await updateUpstreamSource(editingSource.id, {
|
|
name: formData.name.trim(),
|
|
source_type: formData.source_type,
|
|
url: formData.url.trim(),
|
|
enabled: formData.enabled,
|
|
auth_type: formData.auth_type,
|
|
username: formData.username.trim() || undefined,
|
|
password: formData.password || undefined,
|
|
priority: formData.priority,
|
|
});
|
|
savedSourceId = editingSource.id;
|
|
setSuccessMessage('Source updated successfully');
|
|
} else {
|
|
// Create new source
|
|
const newSource = await createUpstreamSource({
|
|
name: formData.name.trim(),
|
|
source_type: formData.source_type,
|
|
url: formData.url.trim(),
|
|
enabled: formData.enabled,
|
|
auth_type: formData.auth_type,
|
|
username: formData.username.trim() || undefined,
|
|
password: formData.password || undefined,
|
|
priority: formData.priority,
|
|
});
|
|
savedSourceId = newSource.id;
|
|
setSuccessMessage('Source created successfully');
|
|
}
|
|
setShowForm(false);
|
|
await loadSources();
|
|
|
|
// Auto-test the source after save
|
|
if (savedSourceId) {
|
|
testSourceById(savedSourceId);
|
|
}
|
|
} catch (err) {
|
|
setFormError(err instanceof Error ? err.message : 'Failed to save source');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(source: UpstreamSource) {
|
|
if (!window.confirm(`Delete upstream source "${source.name}"? This cannot be undone.`)) {
|
|
return;
|
|
}
|
|
|
|
setDeletingId(source.id);
|
|
try {
|
|
await deleteUpstreamSource(source.id);
|
|
setSuccessMessage(`Source "${source.name}" deleted`);
|
|
await loadSources();
|
|
} catch (err) {
|
|
setSourcesError(err instanceof Error ? err.message : 'Failed to delete source');
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
}
|
|
|
|
async function handleTest(source: UpstreamSource) {
|
|
testSourceById(source.id);
|
|
}
|
|
|
|
async function testSourceById(sourceId: string) {
|
|
setTestingId(sourceId);
|
|
setTestResults((prev) => ({ ...prev, [sourceId]: { success: true, message: 'Testing...' } }));
|
|
|
|
try {
|
|
const result = await testUpstreamSource(sourceId);
|
|
setTestResults((prev) => ({
|
|
...prev,
|
|
[sourceId]: {
|
|
success: result.success,
|
|
message: result.success
|
|
? `OK (${result.elapsed_ms}ms)`
|
|
: result.error || `HTTP ${result.status_code}`,
|
|
},
|
|
}));
|
|
} catch (err) {
|
|
setTestResults((prev) => ({
|
|
...prev,
|
|
[sourceId]: {
|
|
success: false,
|
|
message: err instanceof Error ? err.message : 'Test failed',
|
|
},
|
|
}));
|
|
} finally {
|
|
setTestingId(null);
|
|
}
|
|
}
|
|
|
|
function showError(sourceName: string, error: string) {
|
|
setSelectedError({ sourceName, error });
|
|
setShowErrorModal(true);
|
|
}
|
|
|
|
if (authLoading) {
|
|
return <div className="admin-cache-page">Loading...</div>;
|
|
}
|
|
|
|
if (!user?.is_admin) {
|
|
return (
|
|
<div className="admin-cache-page">
|
|
<div className="error-message">Access denied. Admin privileges required.</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="admin-cache-page">
|
|
<h1>Upstream Sources</h1>
|
|
|
|
{successMessage && <div className="success-message">{successMessage}</div>}
|
|
|
|
{/* Upstream Sources Section */}
|
|
<section className="sources-section">
|
|
<div className="section-header">
|
|
<button className="btn btn-primary" onClick={openCreateForm}>
|
|
Add Source
|
|
</button>
|
|
</div>
|
|
|
|
{loadingSources ? (
|
|
<p>Loading sources...</p>
|
|
) : sourcesError ? (
|
|
<div className="error-message">{sourcesError}</div>
|
|
) : sources.length === 0 ? (
|
|
<p className="empty-message">No upstream sources configured.</p>
|
|
) : (
|
|
<table className="sources-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>URL</th>
|
|
<th>Priority</th>
|
|
<th>Status</th>
|
|
<th>Test</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sources.map((source) => (
|
|
<tr key={source.id} className={source.enabled ? '' : 'disabled-row'}>
|
|
<td>
|
|
<span className="source-name">{source.name}</span>
|
|
{source.source === 'env' && (
|
|
<span className="env-badge" title="Defined via environment variable">ENV</span>
|
|
)}
|
|
</td>
|
|
<td>{source.source_type}</td>
|
|
<td className="url-cell" title={source.url}>{source.url}</td>
|
|
<td>{source.priority}</td>
|
|
<td>
|
|
<span className={`status-badge ${source.enabled ? 'enabled' : 'disabled'}`}>
|
|
{source.enabled ? 'Enabled' : 'Disabled'}
|
|
</span>
|
|
</td>
|
|
<td className="test-cell">
|
|
{testingId === source.id ? (
|
|
<span className="test-dot testing" title="Testing...">●</span>
|
|
) : testResults[source.id] ? (
|
|
testResults[source.id].success ? (
|
|
<span className="test-dot success" title={testResults[source.id].message}>●</span>
|
|
) : (
|
|
<span
|
|
className="test-dot failure"
|
|
title="Click to see error"
|
|
onClick={() => showError(source.name, testResults[source.id].message)}
|
|
>●</span>
|
|
)
|
|
) : null}
|
|
</td>
|
|
<td className="actions-cell">
|
|
<button
|
|
className="btn btn-sm btn-secondary"
|
|
onClick={() => handleTest(source)}
|
|
disabled={testingId === source.id}
|
|
>
|
|
Test
|
|
</button>
|
|
{source.source !== 'env' && (
|
|
<button className="btn btn-sm btn-secondary" onClick={() => openEditForm(source)}>
|
|
Edit
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</section>
|
|
|
|
{/* Create/Edit Modal */}
|
|
{showForm && (
|
|
<div className="modal-overlay" onClick={() => setShowForm(false)}>
|
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
|
<h2>{editingSource ? 'Edit Upstream Source' : 'Add Upstream Source'}</h2>
|
|
<form onSubmit={handleFormSubmit}>
|
|
{formError && <div className="error-message">{formError}</div>}
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="name">Name</label>
|
|
<input
|
|
type="text"
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="e.g., npm-private"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label htmlFor="source_type">Type</label>
|
|
<select
|
|
id="source_type"
|
|
value={formData.source_type}
|
|
onChange={(e) => setFormData({ ...formData, source_type: e.target.value as SourceType })}
|
|
>
|
|
{SOURCE_TYPES.map((type) => (
|
|
<option key={type} value={type}>
|
|
{type}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="priority">Priority</label>
|
|
<input
|
|
type="number"
|
|
id="priority"
|
|
value={formData.priority}
|
|
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 100 })}
|
|
min="1"
|
|
/>
|
|
<span className="help-text">Lower = higher priority</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="url">URL</label>
|
|
<input
|
|
type="url"
|
|
id="url"
|
|
value={formData.url}
|
|
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
|
placeholder="https://registry.example.com"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<div className="form-group checkbox-group">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.enabled}
|
|
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
|
|
/>
|
|
Enabled
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="auth_type">Authentication</label>
|
|
<select
|
|
id="auth_type"
|
|
value={formData.auth_type}
|
|
onChange={(e) => setFormData({ ...formData, auth_type: e.target.value as AuthType })}
|
|
>
|
|
{AUTH_TYPES.map((type) => (
|
|
<option key={type} value={type}>
|
|
{type === 'none' ? 'None' : type === 'api_key' ? 'API Key' : type.charAt(0).toUpperCase() + type.slice(1)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{formData.auth_type !== 'none' && (
|
|
<div className="form-row">
|
|
{(formData.auth_type === 'basic' || formData.auth_type === 'api_key') && (
|
|
<div className="form-group">
|
|
<label htmlFor="username">{formData.auth_type === 'api_key' ? 'Header Name' : 'Username'}</label>
|
|
<input
|
|
type="text"
|
|
id="username"
|
|
value={formData.username}
|
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
|
placeholder={formData.auth_type === 'api_key' ? 'X-API-Key' : 'username'}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="form-group">
|
|
<label htmlFor="password">
|
|
{formData.auth_type === 'bearer'
|
|
? 'Token'
|
|
: formData.auth_type === 'api_key'
|
|
? 'API Key Value'
|
|
: 'Password'}
|
|
</label>
|
|
<input
|
|
type="password"
|
|
id="password"
|
|
value={formData.password}
|
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
placeholder={editingSource ? '(unchanged)' : ''}
|
|
/>
|
|
{editingSource && (
|
|
<span className="help-text">Leave empty to keep existing {formData.auth_type === 'bearer' ? 'token' : 'credentials'}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="form-actions">
|
|
{editingSource && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-danger"
|
|
onClick={() => {
|
|
handleDelete(editingSource);
|
|
setShowForm(false);
|
|
}}
|
|
disabled={deletingId === editingSource.id}
|
|
>
|
|
{deletingId === editingSource.id ? 'Deleting...' : 'Delete'}
|
|
</button>
|
|
)}
|
|
<div className="form-actions-right">
|
|
<button type="button" className="btn" onClick={() => setShowForm(false)}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={isSaving}>
|
|
{isSaving ? 'Saving...' : editingSource ? 'Update' : 'Create'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Details Modal */}
|
|
{showErrorModal && selectedError && (
|
|
<div className="modal-overlay" onClick={() => setShowErrorModal(false)}>
|
|
<div className="error-modal-content" onClick={(e) => e.stopPropagation()}>
|
|
<h3>Connection Error: {selectedError.sourceName}</h3>
|
|
<div className="error-details">{selectedError.error}</div>
|
|
<div className="modal-actions">
|
|
<button className="btn" onClick={() => setShowErrorModal(false)}>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AdminCachePage;
|