feat: NPM remote mode for cross-machine deployments
- Add npm_remote setting: when enabled, proxy forwards to server_ip with published host ports instead of Docker container names - Deployer looks up assigned host port via InspectContainerPort in remote mode - Auto-remove stale containers with same name before creating new ones - Add Remote NPM toggle with warning on NPM settings page - DB migration + schema for npm_remote column
This commit is contained in:
@@ -36,6 +36,7 @@ type settingsRequest struct {
|
|||||||
DNSProvider *string `json:"dns_provider,omitempty"`
|
DNSProvider *string `json:"dns_provider,omitempty"`
|
||||||
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
||||||
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
|
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
|
||||||
|
NpmRemote *bool `json:"npm_remote,omitempty"`
|
||||||
ProxyProvider *string `json:"proxy_provider,omitempty"`
|
ProxyProvider *string `json:"proxy_provider,omitempty"`
|
||||||
TraefikEntrypoint *string `json:"traefik_entrypoint,omitempty"`
|
TraefikEntrypoint *string `json:"traefik_entrypoint,omitempty"`
|
||||||
TraefikCertResolver *string `json:"traefik_cert_resolver,omitempty"`
|
TraefikCertResolver *string `json:"traefik_cert_resolver,omitempty"`
|
||||||
@@ -64,6 +65,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"npm_url": settings.NpmURL,
|
"npm_url": settings.NpmURL,
|
||||||
"npm_email": settings.NpmEmail,
|
"npm_email": settings.NpmEmail,
|
||||||
"has_npm_password": settings.NpmPassword != "",
|
"has_npm_password": settings.NpmPassword != "",
|
||||||
|
"npm_remote": settings.NpmRemote,
|
||||||
"polling_interval": settings.PollingInterval,
|
"polling_interval": settings.PollingInterval,
|
||||||
"ssl_certificate_id": settings.SSLCertificateID,
|
"ssl_certificate_id": settings.SSLCertificateID,
|
||||||
"stale_threshold_days": settings.StaleThresholdDays,
|
"stale_threshold_days": settings.StaleThresholdDays,
|
||||||
@@ -187,6 +189,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
updated.ProxyProvider = prov
|
updated.ProxyProvider = prov
|
||||||
}
|
}
|
||||||
|
if req.NpmRemote != nil {
|
||||||
|
updated.NpmRemote = *req.NpmRemote
|
||||||
|
}
|
||||||
|
|
||||||
// Traefik provider settings.
|
// Traefik provider settings.
|
||||||
if req.TraefikEntrypoint != nil {
|
if req.TraefikEntrypoint != nil {
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ func (d *Deployer) blueGreenDeploy(
|
|||||||
}
|
}
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
|
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
|
||||||
|
|
||||||
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
|
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return containerID, "", instanceID, fmt.Errorf("configure proxy: %w", err)
|
return containerID, "", instanceID, fmt.Errorf("configure proxy: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,6 +306,10 @@ func (d *Deployer) executeDeploy(
|
|||||||
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
|
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
|
||||||
|
|
||||||
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
||||||
|
|
||||||
|
// Remove any stale container with the same name (e.g., from a previous failed deploy).
|
||||||
|
_ = d.docker.RemoveContainer(ctx, containerName, true)
|
||||||
|
|
||||||
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
||||||
envVars := d.mergeEnvVars(project, stage.ID)
|
envVars := d.mergeEnvVars(project, stage.ID)
|
||||||
mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath)
|
mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath)
|
||||||
@@ -385,7 +389,7 @@ func (d *Deployer) executeDeploy(
|
|||||||
}
|
}
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
|
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
|
||||||
|
|
||||||
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
|
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("configure proxy: %w", err)
|
return containerID, proxyRouteID, instanceID, fmt.Errorf("configure proxy: %w", err)
|
||||||
}
|
}
|
||||||
@@ -431,19 +435,40 @@ func (d *Deployer) executeDeploy(
|
|||||||
|
|
||||||
// configureProxy creates or updates a proxy route for the deployed container.
|
// configureProxy creates or updates a proxy route for the deployed container.
|
||||||
// Uses the configured proxy.Provider (NPM, Traefik, or None).
|
// Uses the configured proxy.Provider (NPM, Traefik, or None).
|
||||||
|
// In NPM remote mode, uses server_ip + published host port instead of container name.
|
||||||
// Returns the proxy route ID string.
|
// Returns the proxy route ID string.
|
||||||
func (d *Deployer) configureProxy(
|
func (d *Deployer) configureProxy(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
deployID string,
|
deployID string,
|
||||||
settings store.Settings,
|
settings store.Settings,
|
||||||
|
containerID string,
|
||||||
containerName string,
|
containerName string,
|
||||||
containerPort int,
|
containerPort int,
|
||||||
subdomain string,
|
subdomain string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
fqdn := subdomain + "." + settings.Domain
|
fqdn := subdomain + "." + settings.Domain
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, containerName, containerPort), "info")
|
|
||||||
|
|
||||||
routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, containerName, containerPort, proxy.RouteOptions{
|
forwardHost := containerName
|
||||||
|
forwardPort := containerPort
|
||||||
|
|
||||||
|
// In NPM remote mode, use server_ip and the published host port.
|
||||||
|
if settings.NpmRemote && settings.ProxyProvider == "npm" {
|
||||||
|
if settings.ServerIP == "" {
|
||||||
|
return "", fmt.Errorf("NPM remote mode requires Server IP to be configured in settings")
|
||||||
|
}
|
||||||
|
forwardHost = settings.ServerIP
|
||||||
|
|
||||||
|
hostPort, err := d.docker.InspectContainerPort(ctx, containerID, fmt.Sprintf("%d/tcp", containerPort))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("look up host port for remote NPM: %w", err)
|
||||||
|
}
|
||||||
|
forwardPort = int(hostPort)
|
||||||
|
d.logDeploy(deployID, fmt.Sprintf("NPM remote mode: using %s:%d (host port)", forwardHost, forwardPort), "info")
|
||||||
|
}
|
||||||
|
|
||||||
|
d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, forwardHost, forwardPort), "info")
|
||||||
|
|
||||||
|
routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, forwardHost, forwardPort, proxy.RouteOptions{
|
||||||
SSLCertificateID: settings.SSLCertificateID,
|
SSLCertificateID: settings.SSLCertificateID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type Settings struct {
|
|||||||
DNSProvider string `json:"dns_provider"`
|
DNSProvider string `json:"dns_provider"`
|
||||||
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
||||||
CloudflareZoneID string `json:"cloudflare_zone_id"`
|
CloudflareZoneID string `json:"cloudflare_zone_id"`
|
||||||
|
NpmRemote bool `json:"npm_remote"`
|
||||||
ProxyProvider string `json:"proxy_provider"`
|
ProxyProvider string `json:"proxy_provider"`
|
||||||
TraefikEntrypoint string `json:"traefik_entrypoint"`
|
TraefikEntrypoint string `json:"traefik_entrypoint"`
|
||||||
TraefikCertResolver string `json:"traefik_cert_resolver"`
|
TraefikCertResolver string `json:"traefik_cert_resolver"`
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import (
|
|||||||
// GetSettings returns the global settings (single-row pattern, always row id=1).
|
// GetSettings returns the global settings (single-row pattern, always row id=1).
|
||||||
func (s *Store) GetSettings() (Settings, error) {
|
func (s *Store) GetSettings() (Settings, error) {
|
||||||
var st Settings
|
var st Settings
|
||||||
var wildcardDNS, backupEnabled int
|
var wildcardDNS, npmRemote, backupEnabled int
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
|
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
|
||||||
npm_url, npm_email, npm_password, webhook_secret, polling_interval,
|
npm_url, npm_email, npm_password, webhook_secret, polling_interval,
|
||||||
base_volume_path, ssl_certificate_id, stale_threshold_days,
|
base_volume_path, ssl_certificate_id, stale_threshold_days,
|
||||||
allowed_volume_paths, wildcard_dns, dns_provider,
|
allowed_volume_paths, wildcard_dns, dns_provider,
|
||||||
cloudflare_api_token, cloudflare_zone_id,
|
cloudflare_api_token, cloudflare_zone_id,
|
||||||
proxy_provider,
|
npm_remote, proxy_provider,
|
||||||
traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url,
|
traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url,
|
||||||
backup_enabled, backup_interval_hours, backup_retention_count,
|
backup_enabled, backup_interval_hours, backup_retention_count,
|
||||||
updated_at
|
updated_at
|
||||||
@@ -24,7 +24,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
|||||||
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
|
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
|
||||||
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
|
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
|
||||||
&st.CloudflareAPIToken, &st.CloudflareZoneID,
|
&st.CloudflareAPIToken, &st.CloudflareZoneID,
|
||||||
&st.ProxyProvider,
|
&npmRemote, &st.ProxyProvider,
|
||||||
&st.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL,
|
&st.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL,
|
||||||
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
|
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
|
||||||
&st.UpdatedAt)
|
&st.UpdatedAt)
|
||||||
@@ -32,6 +32,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
|||||||
return Settings{}, fmt.Errorf("query settings: %w", err)
|
return Settings{}, fmt.Errorf("query settings: %w", err)
|
||||||
}
|
}
|
||||||
st.WildcardDNS = wildcardDNS != 0
|
st.WildcardDNS = wildcardDNS != 0
|
||||||
|
st.NpmRemote = npmRemote != 0
|
||||||
st.BackupEnabled = backupEnabled != 0
|
st.BackupEnabled = backupEnabled != 0
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,10 @@ func (s *Store) UpdateSettings(st Settings) error {
|
|||||||
if st.WildcardDNS {
|
if st.WildcardDNS {
|
||||||
wildcardDNS = 1
|
wildcardDNS = 1
|
||||||
}
|
}
|
||||||
|
npmRemote := 0
|
||||||
|
if st.NpmRemote {
|
||||||
|
npmRemote = 1
|
||||||
|
}
|
||||||
backupEnabled := 0
|
backupEnabled := 0
|
||||||
if st.BackupEnabled {
|
if st.BackupEnabled {
|
||||||
backupEnabled = 1
|
backupEnabled = 1
|
||||||
@@ -54,7 +59,7 @@ func (s *Store) UpdateSettings(st Settings) error {
|
|||||||
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
|
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
|
||||||
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
|
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
|
||||||
cloudflare_api_token=?, cloudflare_zone_id=?,
|
cloudflare_api_token=?, cloudflare_zone_id=?,
|
||||||
proxy_provider=?,
|
npm_remote=?, proxy_provider=?,
|
||||||
traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?,
|
traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?,
|
||||||
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
|
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
|
||||||
updated_at=?
|
updated_at=?
|
||||||
@@ -64,7 +69,7 @@ func (s *Store) UpdateSettings(st Settings) error {
|
|||||||
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
|
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
|
||||||
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
|
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
|
||||||
st.CloudflareAPIToken, st.CloudflareZoneID,
|
st.CloudflareAPIToken, st.CloudflareZoneID,
|
||||||
st.ProxyProvider,
|
npmRemote, st.ProxyProvider,
|
||||||
st.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL,
|
st.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL,
|
||||||
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
|
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
|
||||||
st.UpdatedAt,
|
st.UpdatedAt,
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ func (s *Store) runMigrations() error {
|
|||||||
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
|
||||||
// Set default network for existing databases with empty network.
|
// Set default network for existing databases with empty network.
|
||||||
`UPDATE settings SET network = 'docker-watcher' WHERE network = ''`,
|
`UPDATE settings SET network = 'docker-watcher' WHERE network = ''`,
|
||||||
|
// NPM remote mode: forward to server_ip instead of container name.
|
||||||
|
`ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
@@ -210,6 +212,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
polling_interval TEXT NOT NULL DEFAULT '5m',
|
polling_interval TEXT NOT NULL DEFAULT '5m',
|
||||||
base_volume_path TEXT NOT NULL DEFAULT '',
|
base_volume_path TEXT NOT NULL DEFAULT '',
|
||||||
ssl_certificate_id INTEGER NOT NULL DEFAULT 0,
|
ssl_certificate_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
npm_remote INTEGER NOT NULL DEFAULT 0,
|
||||||
traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure',
|
traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure',
|
||||||
traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt',
|
traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt',
|
||||||
traefik_network TEXT NOT NULL DEFAULT '',
|
traefik_network TEXT NOT NULL DEFAULT '',
|
||||||
|
|||||||
@@ -373,7 +373,10 @@
|
|||||||
"testing": "Testing...",
|
"testing": "Testing...",
|
||||||
"testSuccess": "NPM connection successful",
|
"testSuccess": "NPM connection successful",
|
||||||
"testFailed": "NPM connection failed",
|
"testFailed": "NPM connection failed",
|
||||||
"saveFailedConnection": "Cannot save \u2014 connection test failed"
|
"saveFailedConnection": "Cannot save \u2014 connection test failed",
|
||||||
|
"remoteMode": "Remote NPM",
|
||||||
|
"remoteModeHelp": "Enable when NPM runs on a different machine than Docker. Forwards to Server IP with published host ports.",
|
||||||
|
"remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports."
|
||||||
},
|
},
|
||||||
"settingsCredentials": {
|
"settingsCredentials": {
|
||||||
"title": "Credentials",
|
"title": "Credentials",
|
||||||
|
|||||||
@@ -373,7 +373,10 @@
|
|||||||
"testing": "Проверка...",
|
"testing": "Проверка...",
|
||||||
"testSuccess": "Подключение к NPM успешно",
|
"testSuccess": "Подключение к NPM успешно",
|
||||||
"testFailed": "Не удалось подключиться к NPM",
|
"testFailed": "Не удалось подключиться к NPM",
|
||||||
"saveFailedConnection": "Невозможно сохранить — проверка соединения не пройдена"
|
"saveFailedConnection": "Невозможно сохранить — проверка соединения не пройдена",
|
||||||
|
"remoteMode": "Удалённый NPM",
|
||||||
|
"remoteModeHelp": "Включите, если NPM работает на другой машине. Перенаправление на IP сервера с опубликованными портами.",
|
||||||
|
"remoteModeWarning": "Требуется IP сервера в общих настройках. Порты автоматически привязываются к случайным портам хоста."
|
||||||
},
|
},
|
||||||
"settingsCredentials": {
|
"settingsCredentials": {
|
||||||
"title": "Учётные данные",
|
"title": "Учётные данные",
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export interface Settings {
|
|||||||
has_npm_password: boolean;
|
has_npm_password: boolean;
|
||||||
/** Sent on PUT to update the password; never returned by GET. */
|
/** Sent on PUT to update the password; never returned by GET. */
|
||||||
npm_password?: string;
|
npm_password?: string;
|
||||||
|
npm_remote: boolean;
|
||||||
polling_interval: string;
|
polling_interval: string;
|
||||||
base_volume_path: string;
|
base_volume_path: string;
|
||||||
ssl_certificate_id: number;
|
ssl_certificate_id: number;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { getSettings, updateSettings, listNpmCertificates, testNpmConnection } from '$lib/api';
|
import { getSettings, updateSettings, listNpmCertificates, testNpmConnection } from '$lib/api';
|
||||||
import type { EntityPickerItem } from '$lib/types';
|
import type { EntityPickerItem } from '$lib/types';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
let npmEmail = $state('');
|
let npmEmail = $state('');
|
||||||
let npmPassword = $state('');
|
let npmPassword = $state('');
|
||||||
let npmHasCredentials = $state(false);
|
let npmHasCredentials = $state(false);
|
||||||
|
let npmRemote = $state(false);
|
||||||
let editingNpm = $state(false);
|
let editingNpm = $state(false);
|
||||||
let errors = $state<Record<string, string>>({});
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@
|
|||||||
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
|
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
|
||||||
npmPassword = '';
|
npmPassword = '';
|
||||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
||||||
|
npmRemote = settings.npm_remote ?? false;
|
||||||
if (sslCertificateId > 0) sslCertName = `Certificate #${sslCertificateId}`;
|
if (sslCertificateId > 0) sslCertName = `Certificate #${sslCertificateId}`;
|
||||||
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
|
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
|
||||||
}
|
}
|
||||||
@@ -77,7 +80,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() };
|
const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim(), npm_remote: npmRemote };
|
||||||
if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
|
if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
|
||||||
await updateSettings(payload);
|
await updateSettings(payload);
|
||||||
npmHasCredentials = true;
|
npmHasCredentials = true;
|
||||||
@@ -195,6 +198,22 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- NPM Mode -->
|
||||||
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<ToggleSwitch bind:checked={npmRemote} label={$t('settingsNpm.remoteMode')} />
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsNpm.remoteMode')}</span>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsNpm.remoteModeHelp')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if npmRemote}
|
||||||
|
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30 p-3">
|
||||||
|
<p class="text-sm text-amber-800 dark:text-amber-300">{$t('settingsNpm.remoteModeWarning')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- SSL Certificate -->
|
<!-- SSL Certificate -->
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user