Add upstream caching infrastructure and refactor CI pipeline
This commit is contained in:
372
frontend/src/pages/AdminCachePage.css
Normal file
372
frontend/src/pages/AdminCachePage.css
Normal file
@@ -0,0 +1,372 @@
|
||||
.admin-cache-page {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin-cache-page h1 {
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-cache-page h2 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Success/Error Messages */
|
||||
.success-message {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
border-radius: 4px;
|
||||
color: #155724;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
color: #721c24;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Settings Section */
|
||||
.settings-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.setting-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.toggle-button.on {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toggle-button.off {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toggle-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sources Section */
|
||||
.sources-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Sources Table */
|
||||
.sources-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sources-table th,
|
||||
.sources-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sources-table th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sources-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sources-table tr.disabled-row {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.public-badge,
|
||||
.env-badge,
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.public-badge {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.env-badge {
|
||||
background-color: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.status-badge.enabled {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-badge.disabled {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions-cell .btn {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.actions-cell .btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.test-result.success {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.test-result.failure {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
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;
|
||||
@@ -493,3 +493,16 @@
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Cell name styles */
|
||||
.cell-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* System project badge */
|
||||
.system-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
@@ -224,6 +224,9 @@ function Home() {
|
||||
<span className="cell-name">
|
||||
{!project.is_public && <LockIcon />}
|
||||
{project.name}
|
||||
{project.is_system && (
|
||||
<Badge variant="warning" className="system-badge">Cache</Badge>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -195,6 +195,9 @@ function ProjectPage() {
|
||||
<Badge variant={project.is_public ? 'public' : 'private'}>
|
||||
{project.is_public ? 'Public' : 'Private'}
|
||||
</Badge>
|
||||
{project.is_system && (
|
||||
<Badge variant="warning">System Cache</Badge>
|
||||
)}
|
||||
{accessLevel && (
|
||||
<Badge variant={accessLevel === 'admin' ? 'success' : accessLevel === 'write' ? 'info' : 'default'}>
|
||||
{isOwner ? 'Owner' : accessLevel.charAt(0).toUpperCase() + accessLevel.slice(1)}
|
||||
|
||||
Reference in New Issue
Block a user