Add upstream caching infrastructure and refactor CI pipeline
This commit is contained in:
580
frontend/src/pages/AdminCachePage.tsx
Normal file
580
frontend/src/pages/AdminCachePage.tsx
Normal file
@@ -0,0 +1,580 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
listUpstreamSources,
|
||||
createUpstreamSource,
|
||||
updateUpstreamSource,
|
||||
deleteUpstreamSource,
|
||||
testUpstreamSource,
|
||||
getCacheSettings,
|
||||
updateCacheSettings,
|
||||
} from '../api';
|
||||
import { UpstreamSource, CacheSettings, 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);
|
||||
|
||||
// Cache settings state
|
||||
const [settings, setSettings] = useState<CacheSettings | null>(null);
|
||||
const [loadingSettings, setLoadingSettings] = useState(true);
|
||||
const [settingsError, setSettingsError] = 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,
|
||||
is_public: 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);
|
||||
|
||||
// Settings update state
|
||||
const [updatingSettings, setUpdatingSettings] = useState(false);
|
||||
|
||||
// Success message
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
navigate('/login', { state: { from: '/admin/cache' } });
|
||||
}
|
||||
}, [user, authLoading, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.is_admin) {
|
||||
loadSources();
|
||||
loadSettings();
|
||||
}
|
||||
}, [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);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
setLoadingSettings(true);
|
||||
setSettingsError(null);
|
||||
try {
|
||||
const data = await getCacheSettings();
|
||||
setSettings(data);
|
||||
} catch (err) {
|
||||
setSettingsError(err instanceof Error ? err.message : 'Failed to load settings');
|
||||
} finally {
|
||||
setLoadingSettings(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
setEditingSource(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
source_type: 'generic',
|
||||
url: '',
|
||||
enabled: true,
|
||||
is_public: 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,
|
||||
is_public: source.is_public,
|
||||
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 {
|
||||
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,
|
||||
is_public: formData.is_public,
|
||||
auth_type: formData.auth_type,
|
||||
username: formData.username.trim() || undefined,
|
||||
password: formData.password || undefined,
|
||||
priority: formData.priority,
|
||||
});
|
||||
setSuccessMessage('Source updated successfully');
|
||||
} else {
|
||||
// Create new source
|
||||
await createUpstreamSource({
|
||||
name: formData.name.trim(),
|
||||
source_type: formData.source_type,
|
||||
url: formData.url.trim(),
|
||||
enabled: formData.enabled,
|
||||
is_public: formData.is_public,
|
||||
auth_type: formData.auth_type,
|
||||
username: formData.username.trim() || undefined,
|
||||
password: formData.password || undefined,
|
||||
priority: formData.priority,
|
||||
});
|
||||
setSuccessMessage('Source created successfully');
|
||||
}
|
||||
setShowForm(false);
|
||||
await loadSources();
|
||||
} 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) {
|
||||
setTestingId(source.id);
|
||||
setTestResults((prev) => ({ ...prev, [source.id]: { success: true, message: 'Testing...' } }));
|
||||
|
||||
try {
|
||||
const result = await testUpstreamSource(source.id);
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[source.id]: {
|
||||
success: result.success,
|
||||
message: result.success
|
||||
? `Connected (${result.elapsed_ms}ms)`
|
||||
: result.error || `HTTP ${result.status_code}`,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[source.id]: {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : 'Test failed',
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSettingsToggle(field: 'allow_public_internet' | 'auto_create_system_projects') {
|
||||
if (!settings) return;
|
||||
|
||||
// Check if env override is active
|
||||
const isOverridden =
|
||||
(field === 'allow_public_internet' && settings.allow_public_internet_env_override !== null) ||
|
||||
(field === 'auto_create_system_projects' && settings.auto_create_system_projects_env_override !== null);
|
||||
|
||||
if (isOverridden) {
|
||||
alert('This setting is overridden by an environment variable and cannot be changed via UI.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdatingSettings(true);
|
||||
try {
|
||||
const update = { [field]: !settings[field] };
|
||||
const newSettings = await updateCacheSettings(update);
|
||||
setSettings(newSettings);
|
||||
setSuccessMessage(`Setting "${field}" updated`);
|
||||
} catch (err) {
|
||||
setSettingsError(err instanceof Error ? err.message : 'Failed to update settings');
|
||||
} finally {
|
||||
setUpdatingSettings(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>Cache Management</h1>
|
||||
|
||||
{successMessage && <div className="success-message">{successMessage}</div>}
|
||||
|
||||
{/* Cache Settings Section */}
|
||||
<section className="settings-section">
|
||||
<h2>Global Settings</h2>
|
||||
{loadingSettings ? (
|
||||
<p>Loading settings...</p>
|
||||
) : settingsError ? (
|
||||
<div className="error-message">{settingsError}</div>
|
||||
) : settings ? (
|
||||
<div className="settings-grid">
|
||||
<div className="setting-item">
|
||||
<label className="toggle-label">
|
||||
<span className="setting-name">
|
||||
Allow Public Internet
|
||||
{settings.allow_public_internet_env_override !== null && (
|
||||
<span className="env-badge" title="Overridden by environment variable">
|
||||
ENV
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="setting-description">
|
||||
When disabled (air-gap mode), requests to public sources are blocked.
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className={`toggle-button ${settings.allow_public_internet ? 'on' : 'off'}`}
|
||||
onClick={() => handleSettingsToggle('allow_public_internet')}
|
||||
disabled={updatingSettings || settings.allow_public_internet_env_override !== null}
|
||||
>
|
||||
{settings.allow_public_internet ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<label className="toggle-label">
|
||||
<span className="setting-name">
|
||||
Auto-create System Projects
|
||||
{settings.auto_create_system_projects_env_override !== null && (
|
||||
<span className="env-badge" title="Overridden by environment variable">
|
||||
ENV
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="setting-description">
|
||||
Automatically create system projects (_npm, _pypi, etc.) on first cache request.
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className={`toggle-button ${settings.auto_create_system_projects ? 'on' : 'off'}`}
|
||||
onClick={() => handleSettingsToggle('auto_create_system_projects')}
|
||||
disabled={updatingSettings || settings.auto_create_system_projects_env_override !== null}
|
||||
>
|
||||
{settings.auto_create_system_projects ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Upstream Sources Section */}
|
||||
<section className="sources-section">
|
||||
<div className="section-header">
|
||||
<h2>Upstream Sources</h2>
|
||||
<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>Source</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.is_public && <span className="public-badge">Public</span>}
|
||||
</td>
|
||||
<td>{source.source_type}</td>
|
||||
<td className="url-cell">{source.url}</td>
|
||||
<td>{source.priority}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${source.enabled ? 'enabled' : 'disabled'}`}>
|
||||
{source.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{source.source === 'env' ? (
|
||||
<span className="env-badge" title="Defined via environment variable">
|
||||
ENV
|
||||
</span>
|
||||
) : (
|
||||
'Database'
|
||||
)}
|
||||
</td>
|
||||
<td className="actions-cell">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleTest(source)}
|
||||
disabled={testingId === source.id}
|
||||
>
|
||||
{testingId === source.id ? 'Testing...' : 'Test'}
|
||||
</button>
|
||||
{source.source !== 'env' && (
|
||||
<>
|
||||
<button className="btn btn-sm" onClick={() => openEditForm(source)}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(source)}
|
||||
disabled={deletingId === source.id}
|
||||
>
|
||||
{deletingId === source.id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{testResults[source.id] && (
|
||||
<span className={`test-result ${testResults[source.id].success ? 'success' : 'failure'}`}>
|
||||
{testResults[source.id].message}
|
||||
</span>
|
||||
)}
|
||||
</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 className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_public}
|
||||
onChange={(e) => setFormData({ ...formData, is_public: e.target.checked })}
|
||||
/>
|
||||
Public Internet Source
|
||||
</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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminCachePage;
|
||||
Reference in New Issue
Block a user