From 0b4647ddd47d4506a839b565109cc1f8d0c2e63b Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:24:41 -0700 Subject: [PATCH] Add per-service config status indicators to Settings Connections tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds green/yellow header gradient on each service card showing whether the user has filled in credentials, plus an expand-triggered verification layer that surfaces working-or-not status inline. Backend (web_server.py): - SERVICE_CONFIG_REGISTRY mapping each of the 11 services in Connections to its config requirements. Supports required-keys, always-green, any-of, and custom-check semantics (Tidal uses token-file check, Qobuz accepts either email/password OR cached auth token). - _is_service_configured(service) — cheap config presence check, no APIs hit. - GET /api/settings/config-status — returns {service: {configured}} for all services in one call. Drives the page-load gradient. - POST /api/settings/verify — takes {services: [...]}, runs run_service_test per service, caches results 5 min in-memory, parallelizes with ThreadPoolExecutor(max_workers=3) to avoid self-rate-limiting. Query param ?force=true busts cache. - Added verify branches for iTunes, Deezer, Discogs, Qobuz, Hydrabase in run_service_test (previously missing — these services couldn't be tested). HTML (webui/index.html): - data-service="..." on all 11 .stg-service containers so JS can map card to backend service name. CSS (webui/static/style.css): - .status-configured gradient (subtle green, left-to-transparent fade) - .status-missing gradient (yellow, same shape) - Spinner badge in header for .status-checking state - "Testing connection…" status line style inside panel body - Red warning bar style for verify failures at top of expanded panel - Brand dot now glows always (was only glowing when expanded); hover and expand states intensify the glow progressively. JS (webui/static/script.js): - applyServiceStatusGradients() fetches config-status and applies green/yellow class per card. Called on Connections tab activate + after any settings save. - _stgVerifyServices(services, {force}) — batch verify POST, tracks in-flight state, renders spinners/status lines/warnings per service. - toggleStgService() fires single-service verify when a card is expanded (not on collapse). Skipped if a verify is already in flight for that service. - toggleAllServiceAccordions() fires one batched verify for all 11 services when "Expand All" is clicked; skipped on "Collapse All". - _stgRefreshAfterSave() — after settings save, refreshes gradient (cheap) and re-verifies only the cards the user currently has expanded (so freshly-edited credentials show their new verify result immediately, without re-pinging every service). Failure UI: top-of-panel red warning bar with the error message (e.g. "Discogs token rejected (HTTP 401)", "Hydrabase not connected…"). Removed automatically on next successful verify. No existing tests changed. Full suite stays at 263 passed. Ruff clean. --- web_server.py | 237 +++++++++++++++++++++++++++++++++++++++++ webui/index.html | 22 ++-- webui/static/script.js | 185 +++++++++++++++++++++++++++++++- webui/static/style.css | 94 +++++++++++++++- 4 files changed, 521 insertions(+), 17 deletions(-) diff --git a/web_server.py b/web_server.py index 37f87f9a..fc635658 100644 --- a/web_server.py +++ b/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""" diff --git a/webui/index.html b/webui/index.html index 9fa2bc49..e2dcc2af 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3871,7 +3871,7 @@ -
+

Spotify

@@ -3914,7 +3914,7 @@
-
+

iTunes / Apple Music

@@ -3949,7 +3949,7 @@
-
+

Deezer (Favorites & Playlists)

@@ -3995,7 +3995,7 @@
-
+

Discogs

@@ -4017,7 +4017,7 @@
-
+

Tidal (Playlists & Metadata)

@@ -4052,7 +4052,7 @@
-
+

Qobuz (Metadata & Enrichment)

@@ -4109,7 +4109,7 @@
-
+

Last.fm

@@ -4146,7 +4146,7 @@
-
+

Genius

@@ -4168,7 +4168,7 @@
-
+

AcoustID Verification

@@ -4205,7 +4205,7 @@
-
+

ListenBrainz

@@ -4238,7 +4238,7 @@
-
+

Hydrabase

diff --git a/webui/static/script.js b/webui/static/script.js index 5b997db3..e360ad0c 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = ` + + + `; + 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'); } diff --git a/webui/static/style.css b/webui/static/style.css index 0865189e..4c2df1fa 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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;