Remove public internet features and fix upstream source UI (#107)

- Remove is_public field from upstream sources (all sources are internal)
- Remove allow_public_internet setting from cache settings
- Remove seeding of public registry URLs
- Fix connectivity test to not follow redirects (fixes Artifactory error)
- Add dedicated Test column with OK/Error status badges
- Auto-test sources after save
- Add error modal for viewing full error details
- Fix table layout (no-wrap on source name)
- Add ORCHARD_PURGE_SEED_DATA to stage helm values
This commit is contained in:
Mondo Diaz
2026-01-29 12:50:23 -06:00
parent e93e7e7021
commit 1f18bb4383
12 changed files with 188 additions and 171 deletions

View File

@@ -6,7 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Changed
- Upstream source connectivity test no longer follows redirects, fixing "Exceeded maximum allowed redirects" error with Artifactory proxies (#107)
- Upstream sources table now has dedicated "Test" column with OK/Error status badges (#107)
- Test runs automatically after saving a new or updated upstream source (#107)
- Error states in upstream sources table are now clickable to show full error details in a modal (#107)
- Source name column no longer wraps text for better table layout (#107)
### Removed
- Removed `is_public` field from upstream sources - all sources are now treated as internal/private (#107)
- Removed `allow_public_internet` (air-gap mode) setting from cache settings - not needed for enterprise proxy use case (#107)
- Removed seeding of public registry URLs (npm-public, pypi-public, maven-central, docker-hub) (#107)
- Removed "Public" badge and checkbox from upstream sources UI (#107)
- Removed "Allow Public Internet" toggle from cache settings UI (#107)
### Added ### Added
- Added `ORCHARD_PURGE_SEED_DATA` environment variable support to stage helm values to remove seed data from long-running deployments (#107)
- Added frontend system projects visual distinction (#105) - Added frontend system projects visual distinction (#105)
- "Cache" badge for system projects in project list - "Cache" badge for system projects in project list
- "System Cache" badge on project detail page - "System Cache" badge on project detail page

View File

@@ -61,8 +61,7 @@ class Settings(BaseSettings):
# Cache settings # Cache settings
cache_encryption_key: str = "" # Fernet key for encrypting upstream credentials (auto-generated if empty) cache_encryption_key: str = "" # Fernet key for encrypting upstream credentials (auto-generated if empty)
# Global cache settings overrides (None = use DB value, True/False = override DB) # Global cache settings override (None = use DB value, True/False = override DB)
cache_allow_public_internet: Optional[bool] = None # Override allow_public_internet (air-gap mode)
cache_auto_create_system_projects: Optional[bool] = None # Override auto_create_system_projects cache_auto_create_system_projects: Optional[bool] = None # Override auto_create_system_projects
# JWT Authentication settings (optional, for external identity providers) # JWT Authentication settings (optional, for external identity providers)
@@ -108,7 +107,6 @@ class EnvUpstreamSource:
url: str, url: str,
source_type: str = "generic", source_type: str = "generic",
enabled: bool = True, enabled: bool = True,
is_public: bool = True,
auth_type: str = "none", auth_type: str = "none",
username: Optional[str] = None, username: Optional[str] = None,
password: Optional[str] = None, password: Optional[str] = None,
@@ -118,7 +116,6 @@ class EnvUpstreamSource:
self.url = url self.url = url
self.source_type = source_type self.source_type = source_type
self.enabled = enabled self.enabled = enabled
self.is_public = is_public
self.auth_type = auth_type self.auth_type = auth_type
self.username = username self.username = username
self.password = password self.password = password
@@ -188,7 +185,6 @@ def parse_upstream_sources_from_env() -> list[EnvUpstreamSource]:
url=url, url=url,
source_type=data.get("TYPE", "generic").lower(), source_type=data.get("TYPE", "generic").lower(),
enabled=parse_bool(data.get("ENABLED"), True), enabled=parse_bool(data.get("ENABLED"), True),
is_public=parse_bool(data.get("IS_PUBLIC"), True),
auth_type=data.get("AUTH_TYPE", "none").lower(), auth_type=data.get("AUTH_TYPE", "none").lower(),
username=data.get("USERNAME"), username=data.get("USERNAME"),
password=data.get("PASSWORD"), password=data.get("PASSWORD"),

View File

@@ -531,6 +531,46 @@ def _run_migrations():
ON CONFLICT (name) DO NOTHING; ON CONFLICT (name) DO NOTHING;
""", """,
), ),
Migration(
name="021_remove_is_public_from_upstream_sources",
sql="""
DO $$
BEGIN
-- Drop the index if it exists
DROP INDEX IF EXISTS idx_upstream_sources_is_public;
-- Drop the column if it exists
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'upstream_sources' AND column_name = 'is_public'
) THEN
ALTER TABLE upstream_sources DROP COLUMN is_public;
END IF;
END $$;
""",
),
Migration(
name="022_remove_allow_public_internet_from_cache_settings",
sql="""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'cache_settings' AND column_name = 'allow_public_internet'
) THEN
ALTER TABLE cache_settings DROP COLUMN allow_public_internet;
END IF;
END $$;
""",
),
Migration(
name="023_delete_seeded_public_sources",
sql="""
-- Delete the seeded public sources that were added by migration 020
DELETE FROM upstream_sources
WHERE name IN ('npm-public', 'pypi-public', 'maven-central', 'docker-hub');
""",
),
] ]
with engine.connect() as conn: with engine.connect() as conn:

View File

@@ -667,7 +667,6 @@ class UpstreamSource(Base):
source_type = Column(String(50), default="generic", nullable=False) source_type = Column(String(50), default="generic", nullable=False)
url = Column(String(2048), nullable=False) url = Column(String(2048), nullable=False)
enabled = Column(Boolean, default=False, nullable=False) enabled = Column(Boolean, default=False, nullable=False)
is_public = Column(Boolean, default=True, nullable=False)
auth_type = Column(String(20), default="none", nullable=False) auth_type = Column(String(20), default="none", nullable=False)
username = Column(String(255)) username = Column(String(255))
password_encrypted = Column(LargeBinary) password_encrypted = Column(LargeBinary)
@@ -684,7 +683,6 @@ class UpstreamSource(Base):
__table_args__ = ( __table_args__ = (
Index("idx_upstream_sources_enabled", "enabled"), Index("idx_upstream_sources_enabled", "enabled"),
Index("idx_upstream_sources_source_type", "source_type"), Index("idx_upstream_sources_source_type", "source_type"),
Index("idx_upstream_sources_is_public", "is_public"),
Index("idx_upstream_sources_priority", "priority"), Index("idx_upstream_sources_priority", "priority"),
CheckConstraint( CheckConstraint(
"source_type IN ('npm', 'pypi', 'maven', 'docker', 'helm', 'nuget', 'deb', 'rpm', 'generic')", "source_type IN ('npm', 'pypi', 'maven', 'docker', 'helm', 'nuget', 'deb', 'rpm', 'generic')",
@@ -747,13 +745,12 @@ class UpstreamSource(Base):
class CacheSettings(Base): class CacheSettings(Base):
"""Global cache settings (singleton table). """Global cache settings (singleton table).
Controls behavior of the upstream caching system including air-gap mode. Controls behavior of the upstream caching system.
""" """
__tablename__ = "cache_settings" __tablename__ = "cache_settings"
id = Column(Integer, primary_key=True, default=1) id = Column(Integer, primary_key=True, default=1)
allow_public_internet = Column(Boolean, default=True, nullable=False)
auto_create_system_projects = Column(Boolean, default=True, nullable=False) auto_create_system_projects = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow) created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column( updated_at = Column(

View File

@@ -8021,10 +8021,6 @@ def cache_artifact(
- Optionally creates tag in user project - Optionally creates tag in user project
- Records URL mapping for provenance - Records URL mapping for provenance
**Air-Gap Mode:**
When `allow_public_internet` is false, only URLs matching private
(non-public) upstream sources are allowed.
**Example (curl):** **Example (curl):**
```bash ```bash
curl -X POST "http://localhost:8080/api/v1/cache" \\ curl -X POST "http://localhost:8080/api/v1/cache" \\
@@ -8333,7 +8329,6 @@ def _env_source_to_response(env_source) -> UpstreamSourceResponse:
source_type=env_source.source_type, source_type=env_source.source_type,
url=env_source.url, url=env_source.url,
enabled=env_source.enabled, enabled=env_source.enabled,
is_public=env_source.is_public,
auth_type=env_source.auth_type, auth_type=env_source.auth_type,
username=env_source.username, username=env_source.username,
has_password=bool(env_source.password), has_password=bool(env_source.password),
@@ -8417,7 +8412,6 @@ def list_upstream_sources(
source_type=s.source_type, source_type=s.source_type,
url=s.url, url=s.url,
enabled=s.enabled, enabled=s.enabled,
is_public=s.is_public,
auth_type=s.auth_type, auth_type=s.auth_type,
username=s.username, username=s.username,
has_password=s.has_password(), has_password=s.has_password(),
@@ -8466,7 +8460,6 @@ def create_upstream_source(
"source_type": "npm", "source_type": "npm",
"url": "https://npm.internal.corp", "url": "https://npm.internal.corp",
"enabled": true, "enabled": true,
"is_public": false,
"auth_type": "basic", "auth_type": "basic",
"username": "reader", "username": "reader",
"password": "secret123", "password": "secret123",
@@ -8488,7 +8481,6 @@ def create_upstream_source(
source_type=source_create.source_type, source_type=source_create.source_type,
url=source_create.url, url=source_create.url,
enabled=source_create.enabled, enabled=source_create.enabled,
is_public=source_create.is_public,
auth_type=source_create.auth_type, auth_type=source_create.auth_type,
username=source_create.username, username=source_create.username,
priority=source_create.priority, priority=source_create.priority,
@@ -8528,7 +8520,6 @@ def create_upstream_source(
source_type=source.source_type, source_type=source.source_type,
url=source.url, url=source.url,
enabled=source.enabled, enabled=source.enabled,
is_public=source.is_public,
auth_type=source.auth_type, auth_type=source.auth_type,
username=source.username, username=source.username,
has_password=source.has_password(), has_password=source.has_password(),
@@ -8576,7 +8567,6 @@ def get_upstream_source(
source_type=source.source_type, source_type=source.source_type,
url=source.url, url=source.url,
enabled=source.enabled, enabled=source.enabled,
is_public=source.is_public,
auth_type=source.auth_type, auth_type=source.auth_type,
username=source.username, username=source.username,
has_password=source.has_password(), has_password=source.has_password(),
@@ -8663,10 +8653,6 @@ def update_upstream_source(
changes["enabled"] = {"old": source.enabled, "new": source_update.enabled} changes["enabled"] = {"old": source.enabled, "new": source_update.enabled}
source.enabled = source_update.enabled source.enabled = source_update.enabled
if source_update.is_public is not None and source_update.is_public != source.is_public:
changes["is_public"] = {"old": source.is_public, "new": source_update.is_public}
source.is_public = source_update.is_public
if source_update.auth_type is not None and source_update.auth_type != source.auth_type: if source_update.auth_type is not None and source_update.auth_type != source.auth_type:
changes["auth_type"] = {"old": source.auth_type, "new": source_update.auth_type} changes["auth_type"] = {"old": source.auth_type, "new": source_update.auth_type}
source.auth_type = source_update.auth_type source.auth_type = source_update.auth_type
@@ -8719,7 +8705,6 @@ def update_upstream_source(
source_type=source.source_type, source_type=source.source_type,
url=source.url, url=source.url,
enabled=source.enabled, enabled=source.enabled,
is_public=source.is_public,
auth_type=source.auth_type, auth_type=source.auth_type,
username=source.username, username=source.username,
has_password=source.has_password(), has_password=source.has_password(),
@@ -8860,12 +8845,10 @@ def get_cache_settings(
Admin-only endpoint for viewing cache configuration. Admin-only endpoint for viewing cache configuration.
**Settings:** **Settings:**
- `allow_public_internet`: When false, blocks all requests to sources marked `is_public=true` (air-gap mode)
- `auto_create_system_projects`: When true, system projects (`_npm`, etc.) are created automatically on first cache - `auto_create_system_projects`: When true, system projects (`_npm`, etc.) are created automatically on first cache
**Environment variable overrides:** **Environment variable overrides:**
Settings can be overridden via environment variables: Settings can be overridden via environment variables:
- `ORCHARD_CACHE_ALLOW_PUBLIC_INTERNET`: Overrides `allow_public_internet`
- `ORCHARD_CACHE_AUTO_CREATE_SYSTEM_PROJECTS`: Overrides `auto_create_system_projects` - `ORCHARD_CACHE_AUTO_CREATE_SYSTEM_PROJECTS`: Overrides `auto_create_system_projects`
When an env var override is active, the `*_env_override` field will contain the override value. When an env var override is active, the `*_env_override` field will contain the override value.
@@ -8874,12 +8857,6 @@ def get_cache_settings(
db_settings = _get_cache_settings(db) db_settings = _get_cache_settings(db)
# Apply env var overrides # Apply env var overrides
allow_public_internet = db_settings.allow_public_internet
allow_public_internet_env_override = None
if app_settings.cache_allow_public_internet is not None:
allow_public_internet = app_settings.cache_allow_public_internet
allow_public_internet_env_override = app_settings.cache_allow_public_internet
auto_create_system_projects = db_settings.auto_create_system_projects auto_create_system_projects = db_settings.auto_create_system_projects
auto_create_system_projects_env_override = None auto_create_system_projects_env_override = None
if app_settings.cache_auto_create_system_projects is not None: if app_settings.cache_auto_create_system_projects is not None:
@@ -8887,9 +8864,7 @@ def get_cache_settings(
auto_create_system_projects_env_override = app_settings.cache_auto_create_system_projects auto_create_system_projects_env_override = app_settings.cache_auto_create_system_projects
return CacheSettingsResponse( return CacheSettingsResponse(
allow_public_internet=allow_public_internet,
auto_create_system_projects=auto_create_system_projects, auto_create_system_projects=auto_create_system_projects,
allow_public_internet_env_override=allow_public_internet_env_override,
auto_create_system_projects_env_override=auto_create_system_projects_env_override, auto_create_system_projects_env_override=auto_create_system_projects_env_override,
created_at=db_settings.created_at, created_at=db_settings.created_at,
updated_at=db_settings.updated_at, updated_at=db_settings.updated_at,
@@ -8915,16 +8890,11 @@ def update_cache_settings(
Supports partial updates - only provided fields are updated. Supports partial updates - only provided fields are updated.
**Settings:** **Settings:**
- `allow_public_internet`: When false, enables air-gap mode (blocks public sources)
- `auto_create_system_projects`: When false, system projects must be created manually - `auto_create_system_projects`: When false, system projects must be created manually
**Note:** Environment variables can override these settings. When overridden, **Note:** Environment variables can override these settings. When overridden,
the `*_env_override` fields in the response indicate the effective value. the `*_env_override` fields in the response indicate the effective value.
Updates to the database will be saved but won't take effect until the env var is removed. Updates to the database will be saved but won't take effect until the env var is removed.
**Warning:** Changing `allow_public_internet` to false will immediately block
all cache requests to public sources. This is a security-sensitive setting
and is logged prominently.
""" """
app_settings = get_settings() app_settings = get_settings()
settings = _get_cache_settings(db) settings = _get_cache_settings(db)
@@ -8932,26 +8902,6 @@ def update_cache_settings(
# Track changes for audit log # Track changes for audit log
changes = {} changes = {}
if settings_update.allow_public_internet is not None:
if settings_update.allow_public_internet != settings.allow_public_internet:
changes["allow_public_internet"] = {
"old": settings.allow_public_internet,
"new": settings_update.allow_public_internet,
}
settings.allow_public_internet = settings_update.allow_public_internet
# Log prominently for security audit
if not settings_update.allow_public_internet:
logger.warning(
f"AIR-GAP MODE ENABLED by {current_user.username} - "
f"all public internet access is now blocked"
)
else:
logger.warning(
f"AIR-GAP MODE DISABLED by {current_user.username} - "
f"public internet access is now allowed"
)
if settings_update.auto_create_system_projects is not None: if settings_update.auto_create_system_projects is not None:
if settings_update.auto_create_system_projects != settings.auto_create_system_projects: if settings_update.auto_create_system_projects != settings.auto_create_system_projects:
changes["auto_create_system_projects"] = { changes["auto_create_system_projects"] = {
@@ -8961,11 +8911,9 @@ def update_cache_settings(
settings.auto_create_system_projects = settings_update.auto_create_system_projects settings.auto_create_system_projects = settings_update.auto_create_system_projects
if changes: if changes:
# Audit log with security flag for air-gap changes
is_security_change = "allow_public_internet" in changes
_log_audit( _log_audit(
db, db,
action="cache_settings.update" if not is_security_change else "cache_settings.security_update", action="cache_settings.update",
resource="cache-settings", resource="cache-settings",
user_id=current_user.username, user_id=current_user.username,
source_ip=request.client.host if request.client else None, source_ip=request.client.host if request.client else None,
@@ -8976,12 +8924,6 @@ def update_cache_settings(
db.refresh(settings) db.refresh(settings)
# Apply env var overrides for the response # Apply env var overrides for the response
allow_public_internet = settings.allow_public_internet
allow_public_internet_env_override = None
if app_settings.cache_allow_public_internet is not None:
allow_public_internet = app_settings.cache_allow_public_internet
allow_public_internet_env_override = app_settings.cache_allow_public_internet
auto_create_system_projects = settings.auto_create_system_projects auto_create_system_projects = settings.auto_create_system_projects
auto_create_system_projects_env_override = None auto_create_system_projects_env_override = None
if app_settings.cache_auto_create_system_projects is not None: if app_settings.cache_auto_create_system_projects is not None:
@@ -8989,9 +8931,7 @@ def update_cache_settings(
auto_create_system_projects_env_override = app_settings.cache_auto_create_system_projects auto_create_system_projects_env_override = app_settings.cache_auto_create_system_projects
return CacheSettingsResponse( return CacheSettingsResponse(
allow_public_internet=allow_public_internet,
auto_create_system_projects=auto_create_system_projects, auto_create_system_projects=auto_create_system_projects,
allow_public_internet_env_override=allow_public_internet_env_override,
auto_create_system_projects_env_override=auto_create_system_projects_env_override, auto_create_system_projects_env_override=auto_create_system_projects_env_override,
created_at=settings.created_at, created_at=settings.created_at,
updated_at=settings.updated_at, updated_at=settings.updated_at,

View File

@@ -1214,7 +1214,6 @@ class UpstreamSourceCreate(BaseModel):
source_type: str = "generic" source_type: str = "generic"
url: str url: str
enabled: bool = False enabled: bool = False
is_public: bool = True
auth_type: str = "none" auth_type: str = "none"
username: Optional[str] = None username: Optional[str] = None
password: Optional[str] = None # Write-only password: Optional[str] = None # Write-only
@@ -1271,7 +1270,6 @@ class UpstreamSourceUpdate(BaseModel):
source_type: Optional[str] = None source_type: Optional[str] = None
url: Optional[str] = None url: Optional[str] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None
is_public: Optional[bool] = None
auth_type: Optional[str] = None auth_type: Optional[str] = None
username: Optional[str] = None username: Optional[str] = None
password: Optional[str] = None # Write-only, None = keep existing, empty string = clear password: Optional[str] = None # Write-only, None = keep existing, empty string = clear
@@ -1331,7 +1329,6 @@ class UpstreamSourceResponse(BaseModel):
source_type: str source_type: str
url: str url: str
enabled: bool enabled: bool
is_public: bool
auth_type: str auth_type: str
username: Optional[str] username: Optional[str]
has_password: bool # True if password is set has_password: bool # True if password is set
@@ -1347,9 +1344,7 @@ class UpstreamSourceResponse(BaseModel):
class CacheSettingsResponse(BaseModel): class CacheSettingsResponse(BaseModel):
"""Global cache settings response""" """Global cache settings response"""
allow_public_internet: bool
auto_create_system_projects: bool auto_create_system_projects: bool
allow_public_internet_env_override: Optional[bool] = None # Set if overridden by env var
auto_create_system_projects_env_override: Optional[bool] = None # Set if overridden by env var auto_create_system_projects_env_override: Optional[bool] = None # Set if overridden by env var
created_at: Optional[datetime] = None # May be None for legacy data created_at: Optional[datetime] = None # May be None for legacy data
updated_at: Optional[datetime] = None # May be None for legacy data updated_at: Optional[datetime] = None # May be None for legacy data
@@ -1360,7 +1355,6 @@ class CacheSettingsResponse(BaseModel):
class CacheSettingsUpdate(BaseModel): class CacheSettingsUpdate(BaseModel):
"""Update cache settings (partial)""" """Update cache settings (partial)"""
allow_public_internet: Optional[bool] = None
auto_create_system_projects: Optional[bool] = None auto_create_system_projects: Optional[bool] = None

View File

@@ -57,10 +57,6 @@ class UpstreamSSLError(UpstreamError):
pass pass
class AirGapError(UpstreamError):
"""Request blocked due to air-gap mode."""
pass
class FileSizeExceededError(UpstreamError): class FileSizeExceededError(UpstreamError):
@@ -156,12 +152,6 @@ class UpstreamClient:
# Sort sources by priority (lower = higher priority) # Sort sources by priority (lower = higher priority)
self.sources = sorted(self.sources, key=lambda s: s.priority) self.sources = sorted(self.sources, key=lambda s: s.priority)
def _get_allow_public_internet(self) -> bool:
"""Get the allow_public_internet setting."""
if self.cache_settings is None:
return True # Default to allowing if no settings provided
return self.cache_settings.allow_public_internet
def _match_source(self, url: str) -> Optional[UpstreamSource]: def _match_source(self, url: str) -> Optional[UpstreamSource]:
""" """
Find the upstream source that matches the given URL. Find the upstream source that matches the given URL.
@@ -301,19 +291,6 @@ class UpstreamClient:
# Match URL to source # Match URL to source
source = self._match_source(url) source = self._match_source(url)
# Check air-gap mode
allow_public = self._get_allow_public_internet()
if not allow_public:
if source is None:
raise AirGapError(
f"Air-gap mode enabled: URL does not match any configured upstream source: {url}"
)
if source.is_public:
raise AirGapError(
f"Air-gap mode enabled: Cannot fetch from public source '{source.name}'"
)
# Check if source is enabled (if we have a match) # Check if source is enabled (if we have a match)
if source is not None and not source.enabled: if source is not None and not source.enabled:
raise SourceDisabledError( raise SourceDisabledError(
@@ -536,7 +513,8 @@ class UpstreamClient:
Test connectivity to an upstream source. Test connectivity to an upstream source.
Performs a HEAD request to the source URL to verify connectivity Performs a HEAD request to the source URL to verify connectivity
and authentication. and authentication. Does not follow redirects - a 3xx response
is considered successful since it proves the server is reachable.
Args: Args:
source: The upstream source to test. source: The upstream source to test.
@@ -564,7 +542,7 @@ class UpstreamClient:
source.url, source.url,
headers=headers, headers=headers,
auth=auth, auth=auth,
follow_redirects=True, follow_redirects=False,
) )
# Consider 2xx and 3xx as success, also 405 (Method Not Allowed) # Consider 2xx and 3xx as success, also 405 (Method Not Allowed)
# since some servers don't support HEAD # since some servers don't support HEAD
@@ -582,5 +560,7 @@ class UpstreamClient:
return (False, f"Connection timed out: {e}", None) return (False, f"Connection timed out: {e}", None)
except httpx.ReadTimeout as e: except httpx.ReadTimeout as e:
return (False, f"Read timed out: {e}", None) return (False, f"Read timed out: {e}", None)
except httpx.TooManyRedirects as e:
return (False, f"Too many redirects: {e}", None)
except Exception as e: except Exception as e:
return (False, f"Error: {e}", None) return (False, f"Error: {e}", None)

View File

@@ -156,6 +156,7 @@
.source-name { .source-name {
font-weight: 500; font-weight: 500;
color: var(--text-primary); color: var(--text-primary);
white-space: nowrap;
} }
.url-cell { .url-cell {
@@ -168,7 +169,6 @@
} }
/* Badges */ /* Badges */
.public-badge,
.env-badge, .env-badge,
.status-badge { .status-badge {
display: inline-block; display: inline-block;
@@ -179,11 +179,6 @@
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.public-badge {
background-color: #e3f2fd;
color: #1976d2;
}
.env-badge { .env-badge {
background-color: #fff3e0; background-color: #fff3e0;
color: #e65100; color: #e65100;
@@ -213,17 +208,64 @@
} }
.test-result { .test-result {
display: inline-block; display: inline-flex;
margin-left: 0.5rem; align-items: center;
font-size: 0.85rem; gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
} }
.test-result.success { .test-result.success {
background-color: #e8f5e9;
color: #2e7d32; color: #2e7d32;
} }
.test-result.failure { .test-result.failure {
background-color: #ffebee;
color: #c62828; color: #c62828;
cursor: pointer;
}
.test-result.failure:hover {
background-color: #ffcdd2;
}
.test-result.testing {
background-color: #e3f2fd;
color: #1976d2;
}
/* Error Modal */
.error-modal-content {
background: var(--bg-primary);
border-radius: 8px;
padding: 2rem;
width: 100%;
max-width: 500px;
}
.error-modal-content h3 {
margin-top: 0;
color: #c62828;
}
.error-modal-content .error-details {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.9rem;
word-break: break-word;
white-space: pre-wrap;
}
.error-modal-content .modal-actions {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
} }
/* Buttons */ /* Buttons */

View File

@@ -38,7 +38,6 @@ function AdminCachePage() {
source_type: 'generic' as SourceType, source_type: 'generic' as SourceType,
url: '', url: '',
enabled: true, enabled: true,
is_public: true,
auth_type: 'none' as AuthType, auth_type: 'none' as AuthType,
username: '', username: '',
password: '', password: '',
@@ -60,6 +59,10 @@ function AdminCachePage() {
// Success message // Success message
const [successMessage, setSuccessMessage] = useState<string | null>(null); 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(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
navigate('/login', { state: { from: '/admin/cache' } }); navigate('/login', { state: { from: '/admin/cache' } });
@@ -113,7 +116,6 @@ function AdminCachePage() {
source_type: 'generic', source_type: 'generic',
url: '', url: '',
enabled: true, enabled: true,
is_public: true,
auth_type: 'none', auth_type: 'none',
username: '', username: '',
password: '', password: '',
@@ -130,7 +132,6 @@ function AdminCachePage() {
source_type: source.source_type, source_type: source.source_type,
url: source.url, url: source.url,
enabled: source.enabled, enabled: source.enabled,
is_public: source.is_public,
auth_type: source.auth_type, auth_type: source.auth_type,
username: source.username || '', username: source.username || '',
password: '', password: '',
@@ -155,6 +156,8 @@ function AdminCachePage() {
setFormError(null); setFormError(null);
try { try {
let savedSourceId: string | null = null;
if (editingSource) { if (editingSource) {
// Update existing source // Update existing source
await updateUpstreamSource(editingSource.id, { await updateUpstreamSource(editingSource.id, {
@@ -162,30 +165,35 @@ function AdminCachePage() {
source_type: formData.source_type, source_type: formData.source_type,
url: formData.url.trim(), url: formData.url.trim(),
enabled: formData.enabled, enabled: formData.enabled,
is_public: formData.is_public,
auth_type: formData.auth_type, auth_type: formData.auth_type,
username: formData.username.trim() || undefined, username: formData.username.trim() || undefined,
password: formData.password || undefined, password: formData.password || undefined,
priority: formData.priority, priority: formData.priority,
}); });
savedSourceId = editingSource.id;
setSuccessMessage('Source updated successfully'); setSuccessMessage('Source updated successfully');
} else { } else {
// Create new source // Create new source
await createUpstreamSource({ const newSource = await createUpstreamSource({
name: formData.name.trim(), name: formData.name.trim(),
source_type: formData.source_type, source_type: formData.source_type,
url: formData.url.trim(), url: formData.url.trim(),
enabled: formData.enabled, enabled: formData.enabled,
is_public: formData.is_public,
auth_type: formData.auth_type, auth_type: formData.auth_type,
username: formData.username.trim() || undefined, username: formData.username.trim() || undefined,
password: formData.password || undefined, password: formData.password || undefined,
priority: formData.priority, priority: formData.priority,
}); });
savedSourceId = newSource.id;
setSuccessMessage('Source created successfully'); setSuccessMessage('Source created successfully');
} }
setShowForm(false); setShowForm(false);
await loadSources(); await loadSources();
// Auto-test the source after save
if (savedSourceId) {
testSourceById(savedSourceId);
}
} catch (err) { } catch (err) {
setFormError(err instanceof Error ? err.message : 'Failed to save source'); setFormError(err instanceof Error ? err.message : 'Failed to save source');
} finally { } finally {
@@ -211,24 +219,28 @@ function AdminCachePage() {
} }
async function handleTest(source: UpstreamSource) { async function handleTest(source: UpstreamSource) {
setTestingId(source.id); testSourceById(source.id);
setTestResults((prev) => ({ ...prev, [source.id]: { success: true, message: 'Testing...' } })); }
async function testSourceById(sourceId: string) {
setTestingId(sourceId);
setTestResults((prev) => ({ ...prev, [sourceId]: { success: true, message: 'Testing...' } }));
try { try {
const result = await testUpstreamSource(source.id); const result = await testUpstreamSource(sourceId);
setTestResults((prev) => ({ setTestResults((prev) => ({
...prev, ...prev,
[source.id]: { [sourceId]: {
success: result.success, success: result.success,
message: result.success message: result.success
? `Connected (${result.elapsed_ms}ms)` ? `OK (${result.elapsed_ms}ms)`
: result.error || `HTTP ${result.status_code}`, : result.error || `HTTP ${result.status_code}`,
}, },
})); }));
} catch (err) { } catch (err) {
setTestResults((prev) => ({ setTestResults((prev) => ({
...prev, ...prev,
[source.id]: { [sourceId]: {
success: false, success: false,
message: err instanceof Error ? err.message : 'Test failed', message: err instanceof Error ? err.message : 'Test failed',
}, },
@@ -238,13 +250,16 @@ function AdminCachePage() {
} }
} }
async function handleSettingsToggle(field: 'allow_public_internet' | 'auto_create_system_projects') { function showError(sourceName: string, error: string) {
setSelectedError({ sourceName, error });
setShowErrorModal(true);
}
async function handleSettingsToggle(field: 'auto_create_system_projects') {
if (!settings) return; if (!settings) return;
// Check if env override is active // Check if env override is active
const isOverridden = const isOverridden = field === 'auto_create_system_projects' && settings.auto_create_system_projects_env_override !== null;
(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) { if (isOverridden) {
alert('This setting is overridden by an environment variable and cannot be changed via UI.'); alert('This setting is overridden by an environment variable and cannot be changed via UI.');
@@ -291,28 +306,6 @@ function AdminCachePage() {
<div className="error-message">{settingsError}</div> <div className="error-message">{settingsError}</div>
) : settings ? ( ) : settings ? (
<div className="settings-grid"> <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"> <div className="setting-item">
<label className="toggle-label"> <label className="toggle-label">
<span className="setting-name"> <span className="setting-name">
@@ -364,6 +357,7 @@ function AdminCachePage() {
<th>Priority</th> <th>Priority</th>
<th>Status</th> <th>Status</th>
<th>Source</th> <th>Source</th>
<th>Test</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -372,7 +366,6 @@ function AdminCachePage() {
<tr key={source.id} className={source.enabled ? '' : 'disabled-row'}> <tr key={source.id} className={source.enabled ? '' : 'disabled-row'}>
<td> <td>
<span className="source-name">{source.name}</span> <span className="source-name">{source.name}</span>
{source.is_public && <span className="public-badge">Public</span>}
</td> </td>
<td>{source.source_type}</td> <td>{source.source_type}</td>
<td className="url-cell">{source.url}</td> <td className="url-cell">{source.url}</td>
@@ -391,13 +384,34 @@ function AdminCachePage() {
'Database' 'Database'
)} )}
</td> </td>
<td>
{testingId === source.id ? (
<span className="test-result testing">Testing...</span>
) : testResults[source.id] ? (
testResults[source.id].success ? (
<span className="test-result success" title={testResults[source.id].message}>
OK
</span>
) : (
<span
className="test-result failure"
title="Click to see details"
onClick={() => showError(source.name, testResults[source.id].message)}
>
Error
</span>
)
) : (
<span className="test-result" style={{ opacity: 0.5 }}></span>
)}
</td>
<td className="actions-cell"> <td className="actions-cell">
<button <button
className="btn btn-sm" className="btn btn-sm"
onClick={() => handleTest(source)} onClick={() => handleTest(source)}
disabled={testingId === source.id} disabled={testingId === source.id}
> >
{testingId === source.id ? 'Testing...' : 'Test'} Test
</button> </button>
{source.source !== 'env' && ( {source.source !== 'env' && (
<> <>
@@ -413,11 +427,6 @@ function AdminCachePage() {
</button> </button>
</> </>
)} )}
{testResults[source.id] && (
<span className={`test-result ${testResults[source.id].success ? 'success' : 'failure'}`}>
{testResults[source.id].message}
</span>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -498,16 +507,6 @@ function AdminCachePage() {
Enabled Enabled
</label> </label>
</div> </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>
<div className="form-group"> <div className="form-group">
@@ -573,6 +572,21 @@ function AdminCachePage() {
</div> </div>
</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> </div>
); );
} }

View File

@@ -515,7 +515,6 @@ export interface UpstreamSource {
source_type: SourceType; source_type: SourceType;
url: string; url: string;
enabled: boolean; enabled: boolean;
is_public: boolean;
auth_type: AuthType; auth_type: AuthType;
username: string | null; username: string | null;
has_password: boolean; has_password: boolean;
@@ -531,7 +530,6 @@ export interface UpstreamSourceCreate {
source_type: SourceType; source_type: SourceType;
url: string; url: string;
enabled?: boolean; enabled?: boolean;
is_public?: boolean;
auth_type?: AuthType; auth_type?: AuthType;
username?: string; username?: string;
password?: string; password?: string;
@@ -544,7 +542,6 @@ export interface UpstreamSourceUpdate {
source_type?: SourceType; source_type?: SourceType;
url?: string; url?: string;
enabled?: boolean; enabled?: boolean;
is_public?: boolean;
auth_type?: AuthType; auth_type?: AuthType;
username?: string; username?: string;
password?: string; password?: string;
@@ -563,15 +560,12 @@ export interface UpstreamSourceTestResult {
// Cache Settings types // Cache Settings types
export interface CacheSettings { export interface CacheSettings {
allow_public_internet: boolean;
auto_create_system_projects: boolean; auto_create_system_projects: boolean;
allow_public_internet_env_override: boolean | null;
auto_create_system_projects_env_override: boolean | null; auto_create_system_projects_env_override: boolean | null;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
} }
export interface CacheSettingsUpdate { export interface CacheSettingsUpdate {
allow_public_internet?: boolean;
auto_create_system_projects?: boolean; auto_create_system_projects?: boolean;
} }

View File

@@ -128,6 +128,10 @@ spec:
value: {{ .Values.orchard.rateLimit.login | quote }} value: {{ .Values.orchard.rateLimit.login | quote }}
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- if .Values.orchard.purgeSeedData }}
- name: ORCHARD_PURGE_SEED_DATA
value: "true"
{{- end }}
{{- if .Values.orchard.database.poolSize }} {{- if .Values.orchard.database.poolSize }}
- name: ORCHARD_DATABASE_POOL_SIZE - name: ORCHARD_DATABASE_POOL_SIZE
value: {{ .Values.orchard.database.poolSize | quote }} value: {{ .Values.orchard.database.poolSize | quote }}

View File

@@ -91,6 +91,7 @@ affinity: {}
# Orchard server configuration # Orchard server configuration
orchard: orchard:
env: "development" # Allows seed data for testing env: "development" # Allows seed data for testing
purgeSeedData: true # Remove public seed data (npm-public, pypi-public, etc.)
server: server:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 port: 8080