Skip to content

Commit 24a35e5

Browse files
committed
Add backend switch feature to web GUI
- New endpoint: POST /api/v1/config/switch-backend Accepts backend_type (local/dynamodb), table_name, region, s3_bucket, s3_prefix, profile. Validates DynamoDB connection before committing switch. Returns updated backend info on success. - Config page: new 'Switch Backend' collapsible form in Backend Configuration card with dropdown for backend type, conditional DynamoDB fields, Connect button, and session-only warning banner. - Client-side validation: S3 bucket required for DynamoDB, loading overlay during switch, toast notifications for success/error, page reload on success. - 6 new tests: switch-to-local, preserves-config, missing-bucket (400), connection-error (503/501), invalid-type (422), form-presence in HTML. - 250 total tests passing, zero regressions.
1 parent 64fb5c4 commit 24a35e5

File tree

3 files changed

+253
-1
lines changed

3 files changed

+253
-1
lines changed

tests/test_web_server.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,70 @@ def test_config_page_no_refresh_button_for_local(self, client):
753753
assert 'id="btn-refresh-backend"' not in response.text
754754
assert 'id="btn-detect-tables"' not in response.text
755755

756+
def test_config_page_shows_switch_backend_form(self, client):
757+
"""Test config page contains the switch backend form."""
758+
response = client.get("/config")
759+
assert 'id="switch-backend-form"' in response.text
760+
assert 'id="switch-backend-type"' in response.text
761+
assert 'id="btn-switch-backend"' in response.text
762+
763+
764+
class TestSwitchBackendAPI:
765+
"""Tests for POST /api/v1/config/switch-backend endpoint."""
766+
767+
def test_switch_to_local_succeeds(self, client):
768+
"""Test switching to local backend succeeds."""
769+
response = client.post(
770+
"/api/v1/config/switch-backend",
771+
json={"backend_type": "local"},
772+
)
773+
assert response.status_code == 200
774+
data = response.json()
775+
assert data["success"] is True
776+
assert "local" in data["message"]
777+
assert data["backend"]["backend_type"] == "local"
778+
779+
def test_switch_to_local_preserves_config(self, client):
780+
"""Test switching to local keeps config intact."""
781+
before = client.get("/api/v1/config").json()
782+
client.post(
783+
"/api/v1/config/switch-backend",
784+
json={"backend_type": "local"},
785+
)
786+
after = client.get("/api/v1/config").json()
787+
assert before.get("schema_version") == after.get("schema_version")
788+
789+
def test_switch_to_dynamodb_missing_bucket(self, client):
790+
"""Test switching to dynamodb without s3_bucket returns 400."""
791+
response = client.post(
792+
"/api/v1/config/switch-backend",
793+
json={"backend_type": "dynamodb", "table_name": "test-table", "s3_bucket": ""},
794+
)
795+
assert response.status_code == 400
796+
assert "s3_bucket" in response.json()["detail"].lower()
797+
798+
def test_switch_to_dynamodb_connection_error(self, client):
799+
"""Test switching to dynamodb with unreachable table returns 503 or 501."""
800+
response = client.post(
801+
"/api/v1/config/switch-backend",
802+
json={
803+
"backend_type": "dynamodb",
804+
"table_name": "nonexistent-table",
805+
"region": "us-east-1",
806+
"s3_bucket": "test-bucket",
807+
},
808+
)
809+
# Either 503 (connection error) or 501 (boto3 not installed)
810+
assert response.status_code in (501, 503)
811+
812+
def test_switch_invalid_backend_type(self, client):
813+
"""Test switching to invalid backend type returns 422."""
814+
response = client.post(
815+
"/api/v1/config/switch-backend",
816+
json={"backend_type": "invalid"},
817+
)
818+
assert response.status_code == 422
819+
756820

757821
# Keep the simple assertion test for backward compatibility
758822
def test_web_ui():

zebra_day/templates/modern/config.html

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,58 @@ <h3 class="card-title"><i class="fas fa-database"></i> Backend Configuration</h3
155155

156156
<!-- Detect tables results area (hidden by default) -->
157157
<div id="detect-tables-results" class="d-none" style="margin-top: 1rem;"></div>
158+
159+
<!-- Switch Backend -->
160+
<div style="margin-top: 1.5rem; border-top: 1px solid var(--color-gray-600); padding-top: 1rem;">
161+
<h4 style="margin-bottom: 0.75rem; cursor: pointer;" onclick="document.getElementById('switch-backend-form').classList.toggle('d-none')">
162+
<i class="fas fa-exchange-alt"></i> Switch Backend
163+
<small class="text-muted" style="font-weight: 400; font-size: 0.75rem; margin-left: 0.5rem;">click to expand</small>
164+
</h4>
165+
166+
<div id="switch-backend-form" class="d-none">
167+
<!-- Session-only warning -->
168+
<div style="padding: 0.6rem 0.9rem; margin-bottom: 1rem; background: rgba(245,158,11,0.1); border: 1px solid var(--color-warning); border-radius: var(--radius-sm); color: var(--color-warning); font-size: 0.85rem;">
169+
<i class="fas fa-exclamation-triangle"></i> <strong>Session-only</strong>: Switching backend affects only this running server. To persist, set environment variables before restarting.
170+
</div>
171+
172+
<div class="form-group mb-md">
173+
<label for="switch-backend-type" style="font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem; display: block;">Backend Type</label>
174+
<select id="switch-backend-type" style="width: 100%; padding: 0.5rem 0.75rem; background: var(--color-gray-700); border: 1px solid var(--color-gray-600); border-radius: var(--radius-sm); color: var(--color-gray-200); font-size: 0.875rem;" onchange="toggleDynamoFields()">
175+
<option value="local" {% if not backend_info or backend_info.backend_type != 'dynamodb' %}selected{% endif %}>Local (filesystem)</option>
176+
<option value="dynamodb" {% if backend_info and backend_info.backend_type == 'dynamodb' %}selected{% endif %}>DynamoDB (AWS)</option>
177+
</select>
178+
</div>
179+
180+
<div id="dynamo-fields" class="{% if not backend_info or backend_info.backend_type != 'dynamodb' %}d-none{% endif %}">
181+
<div class="grid grid-2" style="gap: 0.75rem; margin-bottom: 0.75rem;">
182+
<div class="form-group">
183+
<label for="switch-table" style="font-size: 0.8rem; font-weight: 500; display: block; margin-bottom: 0.2rem;">Table Name</label>
184+
<input id="switch-table" type="text" value="{{ backend_info.dynamo_table if backend_info and backend_info.backend_type == 'dynamodb' else 'zebra-day-config' }}" placeholder="zebra-day-config" style="width: 100%; padding: 0.5rem 0.75rem; background: var(--color-gray-700); border: 1px solid var(--color-gray-600); border-radius: var(--radius-sm); color: var(--color-gray-200); font-size: 0.875rem;">
185+
</div>
186+
<div class="form-group">
187+
<label for="switch-region" style="font-size: 0.8rem; font-weight: 500; display: block; margin-bottom: 0.2rem;">Region</label>
188+
<input id="switch-region" type="text" value="{{ backend_info.aws_region if backend_info and backend_info.backend_type == 'dynamodb' else 'us-east-1' }}" placeholder="us-east-1" style="width: 100%; padding: 0.5rem 0.75rem; background: var(--color-gray-700); border: 1px solid var(--color-gray-600); border-radius: var(--radius-sm); color: var(--color-gray-200); font-size: 0.875rem;">
189+
</div>
190+
<div class="form-group">
191+
<label for="switch-s3-bucket" style="font-size: 0.8rem; font-weight: 500; display: block; margin-bottom: 0.2rem;">S3 Bucket <span style="color: var(--color-error);">*</span></label>
192+
<input id="switch-s3-bucket" type="text" value="{{ backend_info.s3_bucket if backend_info and backend_info.backend_type == 'dynamodb' else '' }}" placeholder="my-zebra-backups" style="width: 100%; padding: 0.5rem 0.75rem; background: var(--color-gray-700); border: 1px solid var(--color-gray-600); border-radius: var(--radius-sm); color: var(--color-gray-200); font-size: 0.875rem;">
193+
</div>
194+
<div class="form-group">
195+
<label for="switch-s3-prefix" style="font-size: 0.8rem; font-weight: 500; display: block; margin-bottom: 0.2rem;">S3 Prefix</label>
196+
<input id="switch-s3-prefix" type="text" value="{{ backend_info.s3_prefix if backend_info and backend_info.backend_type == 'dynamodb' else 'zebra-day/' }}" placeholder="zebra-day/" style="width: 100%; padding: 0.5rem 0.75rem; background: var(--color-gray-700); border: 1px solid var(--color-gray-600); border-radius: var(--radius-sm); color: var(--color-gray-200); font-size: 0.875rem;">
197+
</div>
198+
</div>
199+
<div class="form-group" style="margin-bottom: 0.75rem;">
200+
<label for="switch-profile" style="font-size: 0.8rem; font-weight: 500; display: block; margin-bottom: 0.2rem;">AWS Profile <small class="text-muted">(optional)</small></label>
201+
<input id="switch-profile" type="text" value="" placeholder="Leave empty for default credential chain" style="width: 100%; padding: 0.5rem 0.75rem; background: var(--color-gray-700); border: 1px solid var(--color-gray-600); border-radius: var(--radius-sm); color: var(--color-gray-200); font-size: 0.875rem;">
202+
</div>
203+
</div>
204+
205+
<button class="btn btn-primary" onclick="switchBackend()" id="btn-switch-backend">
206+
<i class="fas fa-plug"></i> Connect
207+
</button>
208+
</div>
209+
</div>
158210
</div>
159211

160212
<!-- Current Config Summary -->
@@ -284,5 +336,53 @@ <h4 class="mb-md">Configured Labs</h4>
284336
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-search"></i> Detect Tables'; }
285337
}
286338
}
339+
340+
function toggleDynamoFields() {
341+
const sel = document.getElementById('switch-backend-type');
342+
const fields = document.getElementById('dynamo-fields');
343+
if (sel && fields) {
344+
if (sel.value === 'dynamodb') { fields.classList.remove('d-none'); }
345+
else { fields.classList.add('d-none'); }
346+
}
347+
}
348+
349+
async function switchBackend() {
350+
const btn = document.getElementById('btn-switch-backend');
351+
const backendType = document.getElementById('switch-backend-type').value;
352+
const payload = { backend_type: backendType };
353+
if (backendType === 'dynamodb') {
354+
payload.table_name = document.getElementById('switch-table').value.trim() || 'zebra-day-config';
355+
payload.region = document.getElementById('switch-region').value.trim() || 'us-east-1';
356+
payload.s3_bucket = document.getElementById('switch-s3-bucket').value.trim();
357+
payload.s3_prefix = document.getElementById('switch-s3-prefix').value.trim() || 'zebra-day/';
358+
payload.profile = document.getElementById('switch-profile').value.trim();
359+
if (!payload.s3_bucket) {
360+
showToast('error', 'Validation Error', 'S3 Bucket is required for DynamoDB backend.');
361+
return;
362+
}
363+
}
364+
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Connecting...'; }
365+
showLoading('Switching backend...');
366+
try {
367+
const resp = await fetch('/api/v1/config/switch-backend', {
368+
method: 'POST',
369+
headers: { 'Content-Type': 'application/json' },
370+
body: JSON.stringify(payload),
371+
});
372+
const data = await resp.json();
373+
hideLoading();
374+
if (resp.ok && data.success) {
375+
showToast('success', 'Backend Switched', data.message || 'Backend switched successfully.');
376+
setTimeout(() => window.location.reload(), 1000);
377+
} else {
378+
showToast('error', 'Switch Failed', data.detail || 'Unknown error');
379+
}
380+
} catch (e) {
381+
hideLoading();
382+
showToast('error', 'Switch Failed', e.message || 'Network error');
383+
} finally {
384+
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-plug"></i> Connect'; }
385+
}
386+
}
287387
</script>
288388
{% endblock %}

zebra_day/web/routers/api.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,4 +715,92 @@ async def config_detect_tables(
715715
raise HTTPException(
716716
status_code=503,
717717
detail=f"Failed to scan DynamoDB tables in {scan_region}: {exc}",
718-
)
718+
)
719+
720+
721+
class SwitchBackendRequest(BaseModel):
722+
"""Request model for switching the active config backend."""
723+
724+
backend_type: Literal["local", "dynamodb"]
725+
table_name: str = Field(default="zebra-day-config", description="DynamoDB table name")
726+
region: str = Field(default="us-east-1", description="AWS region")
727+
s3_bucket: str = Field(default="", description="S3 backup bucket (required for dynamodb)")
728+
s3_prefix: str = Field(default="zebra-day/", description="S3 key prefix")
729+
profile: str = Field(default="", description="AWS profile name (optional)")
730+
731+
732+
@router.post("/config/switch-backend")
733+
async def config_switch_backend(
734+
request: Request,
735+
body: SwitchBackendRequest,
736+
) -> dict[str, Any]:
737+
"""Switch the running server's config backend.
738+
739+
**Session-only**: This affects the running process. To persist, set the
740+
corresponding environment variables before restarting the server.
741+
"""
742+
zp = request.app.state.zp
743+
744+
if body.backend_type == "dynamodb":
745+
# Validate required fields
746+
if not body.s3_bucket:
747+
raise HTTPException(
748+
status_code=400,
749+
detail="s3_bucket is required when switching to DynamoDB backend.",
750+
)
751+
752+
try:
753+
from zebra_day.backends.dynamo import DynamoBackend
754+
755+
profile = body.profile.strip() or None
756+
new_backend = DynamoBackend(
757+
table_name=body.table_name,
758+
region=body.region,
759+
s3_bucket=body.s3_bucket,
760+
s3_prefix=body.s3_prefix,
761+
profile=profile,
762+
)
763+
764+
# Validate connection by describing the table
765+
new_backend._ddb_client.describe_table(TableName=body.table_name)
766+
767+
except ImportError:
768+
raise HTTPException(
769+
status_code=501,
770+
detail="boto3 is not installed. Install with: pip install zebra_day[aws]",
771+
)
772+
except Exception as exc:
773+
raise HTTPException(
774+
status_code=503,
775+
detail=f"Cannot connect to DynamoDB table '{body.table_name}' "
776+
f"in {body.region}: {exc}",
777+
)
778+
779+
else:
780+
# Switch to local backend
781+
from zebra_day.backends.local import LocalBackend
782+
783+
new_backend = LocalBackend()
784+
785+
# Swap the backend on the live zpl instance
786+
try:
787+
zp._backend = new_backend
788+
zp.printers = new_backend.load_config()
789+
if hasattr(new_backend, "config_path_str"):
790+
zp.printers_filename = new_backend.config_path_str
791+
else:
792+
zp.printers_filename = ""
793+
zp._maybe_migrate_schema()
794+
except Exception as exc:
795+
_log.error("Backend switch failed during config reload: %s", exc)
796+
raise HTTPException(
797+
status_code=500,
798+
detail=f"Backend switch failed during config reload: {exc}",
799+
)
800+
801+
_log.info("Backend switched to %s", body.backend_type)
802+
return {
803+
"success": True,
804+
"message": f"Backend switched to {body.backend_type}. This is session-only.",
805+
"backend": _get_backend_info(zp),
806+
}

0 commit comments

Comments
 (0)