Merge pull request #347 from Nezreka/feat/service-status-indicators
Add per-service config status indicators to Settings Connections
This commit is contained in:
commit
9d1009fa69
4 changed files with 521 additions and 17 deletions
237
web_server.py
237
web_server.py
|
|
@ -4384,6 +4384,70 @@ def _find_downloaded_file(download_path, track_data):
|
|||
# --- Refactored Logic from GUI Threads ---
|
||||
# This logic is extracted from the database update worker to be used directly by Flask.
|
||||
|
||||
|
||||
# ── Settings Connection Status Registry ──
|
||||
# Maps each service shown in Settings → Connections to its config requirements.
|
||||
# Used by _is_service_configured() to drive the green/yellow header gradient.
|
||||
#
|
||||
# Registry entry shapes:
|
||||
# {'required': [keys]} — green if all keys populated in config_manager.get(service)
|
||||
# {'always': True} — always green (no credentials required, e.g. default-storefront iTunes)
|
||||
# {'custom': callable} — callable(service_name) -> bool, for services with non-field checks (e.g. token file)
|
||||
# {'any_of': [[keys_a], [keys_b]]} — green if any one group's keys are all populated (e.g. Qobuz: email+password OR token)
|
||||
SERVICE_CONFIG_REGISTRY = {
|
||||
'spotify': {'required': ['client_id', 'client_secret']},
|
||||
'itunes': {'always': True}, # default storefront works anon
|
||||
'deezer': {'always': True}, # anon search works, premium ARL is optional
|
||||
'discogs': {'required': ['token']},
|
||||
'tidal': {'custom': lambda _svc: _tidal_has_auth_token()},
|
||||
'qobuz': {'any_of': [['email', 'password'], ['token'], ['user_auth_token']]},
|
||||
'lastfm': {'required': ['api_key']},
|
||||
'genius': {'required': ['access_token']},
|
||||
'acoustid': {'required': ['api_key']},
|
||||
'listenbrainz': {'required': ['token']},
|
||||
'hydrabase': {'required': ['url', 'api_key']},
|
||||
}
|
||||
|
||||
|
||||
def _tidal_has_auth_token() -> bool:
|
||||
"""Check if Tidal has a cached OAuth token. Tidal uses a token file, not config fields."""
|
||||
try:
|
||||
return bool(tidal_client and tidal_client.is_authenticated())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_service_configured(service: str) -> bool:
|
||||
"""Return True if the user has provided the required credentials for this service.
|
||||
Drives the green/yellow header gradient on the Connections tab.
|
||||
"""
|
||||
entry = SERVICE_CONFIG_REGISTRY.get(service)
|
||||
if not entry:
|
||||
return False
|
||||
|
||||
if entry.get('always'):
|
||||
return True
|
||||
|
||||
if 'custom' in entry:
|
||||
try:
|
||||
return bool(entry['custom'](service))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
service_config = config_manager.get(service, {}) or {}
|
||||
|
||||
if 'required' in entry:
|
||||
return all(bool(service_config.get(key)) for key in entry['required'])
|
||||
|
||||
if 'any_of' in entry:
|
||||
for key_group in entry['any_of']:
|
||||
if all(bool(service_config.get(key)) for key in key_group):
|
||||
return True
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def run_service_test(service, test_config):
|
||||
"""
|
||||
Performs the actual connection test for a given service.
|
||||
|
|
@ -4653,6 +4717,65 @@ def run_service_test(service, test_config):
|
|||
return False, f"Lidarr returned HTTP {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Lidarr connection error: {str(e)}"
|
||||
elif service == "itunes":
|
||||
# Public API — just confirm we can reach it with a cheap search
|
||||
try:
|
||||
storefront = config_manager.get('itunes.storefront', 'US') or 'US'
|
||||
resp = requests.get(
|
||||
'https://itunes.apple.com/search',
|
||||
params={'term': 'beatles', 'limit': 1, 'country': storefront, 'media': 'music'},
|
||||
timeout=5,
|
||||
)
|
||||
if resp.ok and resp.json().get('resultCount', 0) >= 0:
|
||||
return True, f"iTunes Search API reachable (storefront: {storefront})"
|
||||
return False, f"iTunes returned HTTP {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"iTunes connection error: {str(e)}"
|
||||
elif service == "deezer":
|
||||
# Public API — anon search works without credentials
|
||||
try:
|
||||
resp = requests.get(
|
||||
'https://api.deezer.com/search/artist',
|
||||
params={'q': 'beatles', 'limit': 1},
|
||||
timeout=5,
|
||||
)
|
||||
if resp.ok and isinstance(resp.json(), dict):
|
||||
return True, "Deezer Public API reachable"
|
||||
return False, f"Deezer returned HTTP {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Deezer connection error: {str(e)}"
|
||||
elif service == "discogs":
|
||||
token = test_config.get('token', '') or config_manager.get('discogs.token', '')
|
||||
if not token:
|
||||
return False, "Missing Discogs personal token."
|
||||
try:
|
||||
resp = requests.get(
|
||||
'https://api.discogs.com/database/search',
|
||||
params={'q': 'beatles', 'per_page': 1},
|
||||
headers={'Authorization': f'Discogs token={token}', 'User-Agent': 'SoulSync/1.0'},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
return True, "Discogs API reachable with provided token"
|
||||
if resp.status_code == 401:
|
||||
return False, "Discogs token rejected (HTTP 401)"
|
||||
return False, f"Discogs returned HTTP {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Discogs connection error: {str(e)}"
|
||||
elif service == "qobuz":
|
||||
try:
|
||||
if qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.is_authenticated():
|
||||
return True, "Qobuz client authenticated"
|
||||
return False, "Qobuz not authenticated. Provide email/password or user auth token."
|
||||
except Exception as e:
|
||||
return False, f"Qobuz connection error: {str(e)}"
|
||||
elif service == "hydrabase":
|
||||
try:
|
||||
if hydrabase_client and hydrabase_client.is_connected():
|
||||
return True, "Hydrabase connected"
|
||||
return False, "Hydrabase not connected. Configure URL + API key and click Connect."
|
||||
except Exception as e:
|
||||
return False, f"Hydrabase connection error: {str(e)}"
|
||||
return False, "Unknown service."
|
||||
except AttributeError as e:
|
||||
# This specifically catches the error you reported for Jellyfin
|
||||
|
|
@ -7209,6 +7332,120 @@ def test_connection_endpoint():
|
|||
|
||||
return jsonify({"success": success, "error": "" if success else message, "message": message if success else ""})
|
||||
|
||||
|
||||
@app.route('/api/settings/config-status', methods=['GET'])
|
||||
def settings_config_status_endpoint():
|
||||
"""Return per-service config state for the Settings → Connections page.
|
||||
Drives the green/yellow header gradient. No API calls — just config reads.
|
||||
"""
|
||||
try:
|
||||
return jsonify({
|
||||
service: {'configured': _is_service_configured(service)}
|
||||
for service in SERVICE_CONFIG_REGISTRY
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"config-status error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ── Per-service verify cache ──
|
||||
# Stores the last verify result per service for 5 minutes to prevent
|
||||
# hammering external APIs when the user rapidly expands/collapses cards.
|
||||
_settings_verify_cache = {} # service -> {'success': bool, 'message': str, 'error': str, 'ts': float}
|
||||
_settings_verify_cache_lock = threading.Lock()
|
||||
_SETTINGS_VERIFY_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _get_cached_verify_result(service: str):
|
||||
with _settings_verify_cache_lock:
|
||||
entry = _settings_verify_cache.get(service)
|
||||
if entry and (time.time() - entry['ts']) < _SETTINGS_VERIFY_TTL_SECONDS:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _store_verify_result(service: str, success: bool, message: str):
|
||||
with _settings_verify_cache_lock:
|
||||
_settings_verify_cache[service] = {
|
||||
'success': bool(success),
|
||||
'message': message or '',
|
||||
'error': '' if success else (message or 'Unknown error'),
|
||||
'ts': time.time(),
|
||||
}
|
||||
|
||||
|
||||
def _run_single_verify(service: str):
|
||||
"""Run verify for one service, reading its current saved config. Returns cached
|
||||
result if recent, else executes run_service_test and caches the outcome.
|
||||
"""
|
||||
if service not in SERVICE_CONFIG_REGISTRY:
|
||||
return {'success': False, 'error': f'Unknown service: {service}', 'cached': False}
|
||||
|
||||
cached = _get_cached_verify_result(service)
|
||||
if cached:
|
||||
return {
|
||||
'success': cached['success'],
|
||||
'error': cached.get('error', ''),
|
||||
'message': cached.get('message', ''),
|
||||
'cached': True,
|
||||
}
|
||||
|
||||
try:
|
||||
saved_config = config_manager.get(service, {}) or {}
|
||||
success, message = run_service_test(service, saved_config)
|
||||
_store_verify_result(service, success, message)
|
||||
return {
|
||||
'success': bool(success),
|
||||
'error': '' if success else (message or 'Verification failed'),
|
||||
'message': message or '',
|
||||
'cached': False,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"verify error for {service}: {e}")
|
||||
_store_verify_result(service, False, str(e))
|
||||
return {'success': False, 'error': str(e), 'cached': False}
|
||||
|
||||
|
||||
@app.route('/api/settings/verify', methods=['POST'])
|
||||
def settings_verify_endpoint():
|
||||
"""Run connection verification for one or more services.
|
||||
|
||||
Body: {"services": ["spotify", "deezer"]} — which services to check
|
||||
Query: ?force=true — bust cache and re-run
|
||||
Returns {service: {success, error, message, cached}} per requested service.
|
||||
Concurrency capped at 3 to avoid rate-limiting ourselves on Expand All.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
services = data.get('services') or []
|
||||
if isinstance(services, str):
|
||||
services = [services]
|
||||
if not services:
|
||||
return jsonify({'error': 'No services specified'}), 400
|
||||
|
||||
force = (request.args.get('force') or '').strip().lower() in ('1', 'true', 'yes')
|
||||
if force:
|
||||
with _settings_verify_cache_lock:
|
||||
for svc in services:
|
||||
_settings_verify_cache.pop(svc, None)
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
results = {}
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
futures = {pool.submit(_run_single_verify, svc): svc for svc in services}
|
||||
for fut in as_completed(futures):
|
||||
svc = futures[fut]
|
||||
try:
|
||||
results[svc] = fut.result()
|
||||
except Exception as e:
|
||||
results[svc] = {'success': False, 'error': str(e), 'cached': False}
|
||||
|
||||
return jsonify(results)
|
||||
except Exception as e:
|
||||
logger.error(f"settings/verify error: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/test-dashboard-connection', methods=['POST'])
|
||||
def test_dashboard_connection_endpoint():
|
||||
"""Test connection from dashboard - creates specific dashboard activity items"""
|
||||
|
|
|
|||
|
|
@ -3871,7 +3871,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Spotify Settings -->
|
||||
<div class="api-service-frame stg-service">
|
||||
<div class="api-service-frame stg-service" data-service="spotify">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #1DB954;"></span>
|
||||
<h4 class="service-title spotify-title">Spotify</h4>
|
||||
|
|
@ -3914,7 +3914,7 @@
|
|||
</div>
|
||||
|
||||
<!-- iTunes Settings -->
|
||||
<div class="api-service-frame stg-service">
|
||||
<div class="api-service-frame stg-service" data-service="itunes">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #fc3c44;"></span>
|
||||
<h4 class="service-title itunes-title">iTunes / Apple Music</h4>
|
||||
|
|
@ -3949,7 +3949,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Deezer OAuth Auth -->
|
||||
<div class="api-service-frame stg-service">
|
||||
<div class="api-service-frame stg-service" data-service="deezer">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #A238FF;"></span>
|
||||
<h4 class="service-title deezer-title">Deezer (Favorites & Playlists)</h4>
|
||||
|
|
@ -3995,7 +3995,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Discogs Settings -->
|
||||
<div class="api-service-frame stg-service">
|
||||
<div class="api-service-frame stg-service" data-service="discogs">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #e0d4b8;"></span>
|
||||
<h4 class="service-title">Discogs</h4>
|
||||
|
|
@ -4017,7 +4017,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Tidal Playlist/Metadata Auth -->
|
||||
<div class="api-service-frame stg-service">
|
||||
<div class="api-service-frame stg-service" data-service="tidal">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #ff6600;"></span>
|
||||
<h4 class="service-title tidal-title">Tidal (Playlists & Metadata)</h4>
|
||||
|
|
@ -4052,7 +4052,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Qobuz Metadata/Enrichment Auth -->
|
||||
<div class="api-service-frame stg-service">
|
||||
<div class="api-service-frame stg-service" data-service="qobuz">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #4285f4;"></span>
|
||||
<h4 class="service-title qobuz-title">Qobuz (Metadata & Enrichment)</h4>
|
||||
|
|
@ -4109,7 +4109,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Last.fm Settings -->
|
||||
<div class="api-service-frame stg-service">
|
||||
<div class="api-service-frame stg-service" data-service="lastfm">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #d51007;"></span>
|
||||
<h4 class="service-title lastfm-title">Last.fm</h4>
|
||||
|
|
@ -4146,7 +4146,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Genius Settings -->
|
||||
<div class="api-service-frame stg-service">
|
||||
<div class="api-service-frame stg-service" data-service="genius">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #ffff64;"></span>
|
||||
<h4 class="service-title genius-title">Genius</h4>
|
||||
|
|
@ -4168,7 +4168,7 @@
|
|||
</div>
|
||||
|
||||
<!-- AcoustID Settings -->
|
||||
<div class="api-service-frame stg-service">
|
||||
<div class="api-service-frame stg-service" data-service="acoustid">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #ba55d3;"></span>
|
||||
<h4 class="service-title acoustid-title">AcoustID Verification</h4>
|
||||
|
|
@ -4205,7 +4205,7 @@
|
|||
</div>
|
||||
|
||||
<!-- ListenBrainz Settings -->
|
||||
<div class="api-service-frame stg-service">
|
||||
<div class="api-service-frame stg-service" data-service="listenbrainz">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #eb743b;"></span>
|
||||
<h4 class="service-title listenbrainz-title">ListenBrainz</h4>
|
||||
|
|
@ -4238,7 +4238,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Hydrabase P2P Metadata -->
|
||||
<div class="api-service-frame stg-service" data-stg="connections">
|
||||
<div class="api-service-frame stg-service" data-stg="connections" data-service="hydrabase">
|
||||
<div class="stg-service-header" onclick="toggleStgService(this)">
|
||||
<span class="stg-service-dot" style="color: #00b4d8;"></span>
|
||||
<h4 class="service-title">Hydrabase</h4>
|
||||
|
|
|
|||
|
|
@ -5808,17 +5808,195 @@ function switchSettingsTab(tab) {
|
|||
} else {
|
||||
_logViewerStop();
|
||||
}
|
||||
// Refresh the green/yellow header gradient when arriving on Connections
|
||||
if (tab === 'connections') {
|
||||
try { applyServiceStatusGradients(); } catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings → Connections: per-service status gradient + verify wiring ──
|
||||
// Gradient shows green when the user has filled in credentials, yellow when empty.
|
||||
// It's based purely on config presence (cheap, no API calls). The verify layer —
|
||||
// which runs on expand / Expand All — surfaces whether those credentials actually
|
||||
// work, via an inline warning bar inside the expanded panel.
|
||||
|
||||
let _stgServiceStatusState = {}; // service -> {configured: bool}
|
||||
let _stgServiceVerifyInFlight = {}; // service -> true while a verify call is running
|
||||
|
||||
async function applyServiceStatusGradients() {
|
||||
try {
|
||||
const resp = await fetch('/api/settings/config-status');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
_stgServiceStatusState = data || {};
|
||||
document.querySelectorAll('#settings-page .stg-service[data-service]').forEach(card => {
|
||||
const service = card.getAttribute('data-service');
|
||||
const header = card.querySelector('.stg-service-header');
|
||||
if (!service || !header) return;
|
||||
const configured = !!(data[service] && data[service].configured);
|
||||
header.classList.toggle('status-configured', configured);
|
||||
header.classList.toggle('status-missing', !configured);
|
||||
// Ensure the header has a spinner placeholder for the verify-checking state
|
||||
if (!header.querySelector('.stg-service-verify-spinner')) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'stg-service-verify-spinner';
|
||||
// Insert before the chevron on the right
|
||||
const chevron = header.querySelector('.stg-service-chevron');
|
||||
if (chevron) header.insertBefore(spinner, chevron);
|
||||
else header.appendChild(spinner);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Settings Status] Failed to apply gradients:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function _stgSetCheckingState(service, isChecking) {
|
||||
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
|
||||
if (!card) return;
|
||||
const header = card.querySelector('.stg-service-header');
|
||||
const body = card.querySelector('.stg-service-body');
|
||||
if (header) {
|
||||
header.classList.toggle('status-checking', !!isChecking);
|
||||
// Lazy-create the spinner element so it's there even if
|
||||
// applyServiceStatusGradients() hasn't run yet.
|
||||
if (!header.querySelector('.stg-service-verify-spinner')) {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'stg-service-verify-spinner';
|
||||
const chevron = header.querySelector('.stg-service-chevron');
|
||||
if (chevron) header.insertBefore(spinner, chevron);
|
||||
else header.appendChild(spinner);
|
||||
}
|
||||
}
|
||||
if (!body) return;
|
||||
const existing = body.querySelector('.stg-service-verify-status');
|
||||
if (isChecking) {
|
||||
if (!existing) {
|
||||
const status = document.createElement('div');
|
||||
status.className = 'stg-service-verify-status';
|
||||
status.textContent = 'Testing connection…';
|
||||
body.insertBefore(status, body.firstChild);
|
||||
}
|
||||
} else if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function _stgShowVerifyWarning(service, message) {
|
||||
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
|
||||
if (!card) return;
|
||||
const body = card.querySelector('.stg-service-body');
|
||||
if (!body) return;
|
||||
const existing = body.querySelector('.stg-service-warning');
|
||||
if (existing) existing.remove();
|
||||
const warning = document.createElement('div');
|
||||
warning.className = 'stg-service-warning';
|
||||
warning.innerHTML = `
|
||||
<span class="stg-service-warning-icon">⚠</span>
|
||||
<span class="stg-service-warning-text"></span>
|
||||
`;
|
||||
warning.querySelector('.stg-service-warning-text').textContent =
|
||||
message || 'Connection test failed.';
|
||||
body.insertBefore(warning, body.firstChild);
|
||||
}
|
||||
|
||||
function _stgClearVerifyWarning(service) {
|
||||
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
|
||||
if (!card) return;
|
||||
const existing = card.querySelector('.stg-service-warning');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
async function _stgRefreshAfterSave() {
|
||||
// Called after a successful settings save. Cheap gradient refresh always,
|
||||
// plus re-verify any cards the user currently has expanded (so they see
|
||||
// immediate feedback on credentials they just edited). Collapsed cards
|
||||
// keep their cached verify result until the user expands them.
|
||||
try {
|
||||
await applyServiceStatusGradients();
|
||||
const expandedServices = Array.from(
|
||||
document.querySelectorAll('#settings-page .stg-service.expanded[data-service]')
|
||||
)
|
||||
.map(card => card.getAttribute('data-service'))
|
||||
.filter(Boolean);
|
||||
if (expandedServices.length > 0) {
|
||||
_stgVerifyServices(expandedServices, { force: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Settings Status] Post-save refresh failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function _stgVerifyServices(services, { force = false } = {}) {
|
||||
if (!services || !services.length) return {};
|
||||
// Mark all as checking immediately so the user sees spinners/status lines
|
||||
services.forEach(svc => {
|
||||
_stgServiceVerifyInFlight[svc] = true;
|
||||
_stgSetCheckingState(svc, true);
|
||||
_stgClearVerifyWarning(svc);
|
||||
});
|
||||
try {
|
||||
const url = '/api/settings/verify' + (force ? '?force=true' : '');
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ services })
|
||||
});
|
||||
const data = await resp.json();
|
||||
services.forEach(svc => {
|
||||
_stgServiceVerifyInFlight[svc] = false;
|
||||
_stgSetCheckingState(svc, false);
|
||||
const result = data[svc];
|
||||
if (result && result.success === false) {
|
||||
_stgShowVerifyWarning(svc, result.error || result.message || '');
|
||||
}
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.warn('[Settings Verify] Network error:', e);
|
||||
services.forEach(svc => {
|
||||
_stgServiceVerifyInFlight[svc] = false;
|
||||
_stgSetCheckingState(svc, false);
|
||||
_stgShowVerifyWarning(svc, 'Unable to reach the verification endpoint.');
|
||||
});
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStgService(el) {
|
||||
const service = el.closest('.stg-service');
|
||||
if (service) service.classList.toggle('expanded');
|
||||
if (service) {
|
||||
const wasExpanded = service.classList.contains('expanded');
|
||||
service.classList.toggle('expanded');
|
||||
// Fire verify when expanding a single card (not on collapse). The backend
|
||||
// caches per service for 5 min, so rapid expand/collapse won't re-ping.
|
||||
if (!wasExpanded) {
|
||||
const serviceName = service.getAttribute('data-service');
|
||||
if (serviceName && !_stgServiceVerifyInFlight[serviceName]) {
|
||||
_stgVerifyServices([serviceName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function toggleAllServiceAccordions(btn) {
|
||||
const services = document.querySelectorAll('#settings-page .stg-service');
|
||||
const allExpanded = Array.from(services).every(s => s.classList.contains('expanded'));
|
||||
services.forEach(s => s.classList.toggle('expanded', !allExpanded));
|
||||
const willExpand = !allExpanded;
|
||||
services.forEach(s => s.classList.toggle('expanded', willExpand));
|
||||
btn.textContent = allExpanded ? 'Expand All' : 'Collapse All';
|
||||
|
||||
// On Expand All, fire a single batched verify for every service that has a
|
||||
// data-service attribute. Backend caps concurrency at 3 to avoid rate limits.
|
||||
// Skipped on Collapse All.
|
||||
if (willExpand) {
|
||||
const serviceNames = Array.from(services)
|
||||
.map(s => s.getAttribute('data-service'))
|
||||
.filter(Boolean)
|
||||
.filter(name => !_stgServiceVerifyInFlight[name]);
|
||||
if (serviceNames.length > 0) {
|
||||
_stgVerifyServices(serviceNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hybrid source priority list (drag-and-drop) ──
|
||||
|
|
@ -7773,12 +7951,15 @@ async function saveSettings(quiet = false) {
|
|||
if (result.success && qualityProfileSaved && lookbackSaved) {
|
||||
showToast(quiet ? 'Settings auto-saved' : 'Settings saved successfully', 'success');
|
||||
_forceServiceStatusRefresh();
|
||||
_stgRefreshAfterSave();
|
||||
} else if (result.success && qualityProfileSaved && !lookbackSaved) {
|
||||
showToast('Settings saved, but discovery lookback period failed to save', 'warning');
|
||||
_forceServiceStatusRefresh();
|
||||
_stgRefreshAfterSave();
|
||||
} else if (result.success && !qualityProfileSaved) {
|
||||
showToast('Settings saved, but quality profile failed to save', 'warning');
|
||||
_forceServiceStatusRefresh();
|
||||
_stgRefreshAfterSave();
|
||||
} else {
|
||||
showToast(`Failed to save settings: ${result.error}`, 'error', 'set-services');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52349,23 +52349,25 @@ tr.tag-diff-same {
|
|||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* Brand color dot */
|
||||
/* Brand color dot — always glows, glow intensifies on hover/expand */
|
||||
#settings-page .stg-service-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: currentColor;
|
||||
opacity: 0.45;
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 0 6px currentColor;
|
||||
transition: opacity 0.25s, transform 0.25s, box-shadow 0.25s;
|
||||
}
|
||||
#settings-page .stg-service:hover .stg-service-dot {
|
||||
opacity: 0.75;
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 9px currentColor;
|
||||
}
|
||||
#settings-page .stg-service.expanded .stg-service-dot {
|
||||
opacity: 1;
|
||||
transform: scale(1.25);
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
box-shadow: 0 0 12px currentColor;
|
||||
}
|
||||
|
||||
/* Chevron */
|
||||
|
|
@ -52400,6 +52402,90 @@ tr.tag-diff-same {
|
|||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* ── Service config-status gradients ──
|
||||
Applied to the header based on whether the user has filled in credentials.
|
||||
Subtle left-to-transparent fade that reads at a glance when scrolling. */
|
||||
#settings-page .stg-service-header.status-configured {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(46, 204, 113, 0.32) 0%,
|
||||
rgba(46, 204, 113, 0.12) 18%,
|
||||
rgba(46, 204, 113, 0.04) 45%,
|
||||
transparent 80%
|
||||
);
|
||||
}
|
||||
#settings-page .stg-service-header.status-missing {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(241, 196, 15, 0.35) 0%,
|
||||
rgba(241, 196, 15, 0.13) 18%,
|
||||
rgba(241, 196, 15, 0.04) 45%,
|
||||
transparent 80%
|
||||
);
|
||||
}
|
||||
/* Keep hover subtlety — overlay a gentle highlight without clobbering the gradient */
|
||||
#settings-page .stg-service-header.status-configured:hover,
|
||||
#settings-page .stg-service-header.status-missing:hover {
|
||||
background-color: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
/* ── Verify state: spinner badge in the header ──
|
||||
Shown while a verify API call is in flight for this service. */
|
||||
#settings-page .stg-service-verify-spinner {
|
||||
display: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: rgba(255, 255, 255, 0.65);
|
||||
border-radius: 50%;
|
||||
animation: stgSpin 0.9s linear infinite;
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
#settings-page .stg-service-header.status-checking .stg-service-verify-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes stgSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Verify state: status line inside panel body ──
|
||||
"Testing connection…" while verify runs, removed when result arrives. */
|
||||
#settings-page .stg-service-verify-status {
|
||||
padding: 10px 18px;
|
||||
font-size: 0.78em;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── Verify failure warning bar at top of expanded panel ──
|
||||
Shown when the verify call failed. Removed on next successful verify. */
|
||||
#settings-page .stg-service-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 10px 18px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(231, 76, 60, 0.08);
|
||||
border: 1px solid rgba(231, 76, 60, 0.25);
|
||||
border-left: 3px solid rgba(231, 76, 60, 0.65);
|
||||
border-radius: 6px;
|
||||
font-size: 0.82em;
|
||||
color: rgba(255, 220, 216, 0.9);
|
||||
line-height: 1.4;
|
||||
}
|
||||
#settings-page .stg-service-warning .stg-service-warning-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.05em;
|
||||
color: rgba(231, 76, 60, 0.85);
|
||||
line-height: 1.3;
|
||||
}
|
||||
#settings-page .stg-service-warning .stg-service-warning-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Expand All / Collapse All toggle button */
|
||||
#settings-page .stg-accordion-toggle {
|
||||
background: none;
|
||||
|
|
|
|||
Loading…
Reference in a new issue