feat(monitoring): add Redis exporter + Sentry docs to deployment guide
Some checks failed
Some checks failed
- Add redis-exporter container to docker-compose (oliver006/redis_exporter, 32MB) - Add Redis scrape target to Prometheus config - Add 4 Redis alert rules: RedisDown, HighMemory, HighConnections, RejectedConnections - Document Step 19b (Sentry Error Tracking) in Hetzner deployment guide - Document Step 19c (Redis Monitoring) in Hetzner deployment guide - Update resource budget and port reference tables Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -100,28 +100,28 @@ def apply_theme_preset(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Apply a theme preset to store."""
|
||||
|
||||
|
||||
# Validate preset name
|
||||
if preset_name not in get_available_presets():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid preset. Available: {get_available_presets()}"
|
||||
)
|
||||
|
||||
|
||||
# Get or create store theme
|
||||
theme = db.query(StoreTheme).filter(
|
||||
StoreTheme.store_id == store.id
|
||||
).first()
|
||||
|
||||
|
||||
if not theme:
|
||||
theme = StoreTheme(store_id=store.id)
|
||||
db.add(theme)
|
||||
|
||||
|
||||
# Apply preset
|
||||
apply_preset(theme, preset_name)
|
||||
db.commit()
|
||||
db.refresh(theme)
|
||||
|
||||
|
||||
return {
|
||||
"message": f"Theme preset '{preset_name}' applied successfully",
|
||||
"theme": theme.to_dict()
|
||||
@@ -135,12 +135,12 @@ def apply_theme_preset(
|
||||
def get_theme_presets():
|
||||
"""Get all available theme presets with previews."""
|
||||
from app.core.theme_presets import get_available_presets, get_preset_preview
|
||||
|
||||
|
||||
presets = []
|
||||
for preset_name in get_available_presets():
|
||||
preview = get_preset_preview(preset_name)
|
||||
presets.append(preview)
|
||||
|
||||
|
||||
return {"presets": presets}
|
||||
|
||||
# Returns:
|
||||
@@ -302,16 +302,16 @@ from models.database.store_theme import StoreTheme
|
||||
def test_all_presets():
|
||||
"""Test applying all presets"""
|
||||
presets = get_available_presets()
|
||||
|
||||
|
||||
for preset_name in presets:
|
||||
theme = StoreTheme(store_id=999) # Test store
|
||||
apply_preset(theme, preset_name)
|
||||
|
||||
|
||||
assert theme.theme_name == preset_name
|
||||
assert theme.colors is not None
|
||||
assert theme.font_family_heading is not None
|
||||
assert theme.is_active == True
|
||||
|
||||
|
||||
print(f"✅ {preset_name} preset OK")
|
||||
|
||||
test_all_presets()
|
||||
|
||||
@@ -430,4 +430,3 @@ Which modules should be mandatory (cannot be disabled)?
|
||||
| `app/services/menu_service.py` | MODIFY | Filter by enabled modules |
|
||||
| `app/platforms/oms/config.py` | MODIFY | Add enabled_modules |
|
||||
| `app/platforms/loyalty/config.py` | MODIFY | Add enabled_modules |
|
||||
|
||||
|
||||
@@ -82,16 +82,16 @@ Update your `admin_service.py` to log actions:
|
||||
from app.services.admin_audit_service import admin_audit_service
|
||||
|
||||
class AdminService:
|
||||
|
||||
|
||||
def create_store_with_owner(
|
||||
self, db: Session, store_data: StoreCreate
|
||||
) -> Tuple[Store, User, str]:
|
||||
"""Create store with owner user account."""
|
||||
|
||||
|
||||
# ... existing code ...
|
||||
|
||||
|
||||
store, owner_user, temp_password = # ... your creation logic
|
||||
|
||||
|
||||
# LOG THE ACTION
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
@@ -105,19 +105,19 @@ class AdminService:
|
||||
"owner_email": owner_user.email
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
return store, owner_user, temp_password
|
||||
|
||||
|
||||
def toggle_store_status(
|
||||
self, db: Session, store_id: int, admin_user_id: int
|
||||
) -> Tuple[Store, str]:
|
||||
"""Toggle store status with audit logging."""
|
||||
|
||||
|
||||
store = self._get_store_by_id_or_raise(db, store_id)
|
||||
old_status = store.is_active
|
||||
|
||||
|
||||
# ... toggle logic ...
|
||||
|
||||
|
||||
# LOG THE ACTION
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
@@ -130,7 +130,7 @@ class AdminService:
|
||||
"new_status": "active" if store.is_active else "inactive"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
return store, message
|
||||
```
|
||||
|
||||
@@ -154,9 +154,9 @@ def create_store_with_owner(
|
||||
store_data=store_data,
|
||||
admin_user_id=current_admin.id # Pass admin ID for audit logging
|
||||
)
|
||||
|
||||
|
||||
# Audit log is automatically created inside the service
|
||||
|
||||
|
||||
return {
|
||||
**StoreResponse.model_validate(store).model_dump(),
|
||||
"owner_email": owner_user.email,
|
||||
@@ -205,9 +205,9 @@ def delete_store(
|
||||
# Get request metadata
|
||||
ip_address = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent")
|
||||
|
||||
|
||||
message = admin_service.delete_store(db, store_id)
|
||||
|
||||
|
||||
# Log with full context
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
@@ -219,7 +219,7 @@ def delete_store(
|
||||
user_agent=user_agent,
|
||||
details={"confirm": True}
|
||||
)
|
||||
|
||||
|
||||
return {"message": message}
|
||||
```
|
||||
|
||||
@@ -300,15 +300,15 @@ from app.services.admin_settings_service import admin_settings_service
|
||||
|
||||
def can_create_store(db: Session) -> bool:
|
||||
"""Check if platform allows creating more stores."""
|
||||
|
||||
|
||||
max_stores = admin_settings_service.get_setting_value(
|
||||
db=db,
|
||||
key="max_stores_allowed",
|
||||
default=1000
|
||||
)
|
||||
|
||||
|
||||
current_count = db.query(Store).count()
|
||||
|
||||
|
||||
return current_count < max_stores
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ def is_maintenance_mode(db: Session) -> bool:
|
||||
<!-- templates/admin/audit_logs.html -->
|
||||
<div x-data="auditLogs()" x-init="loadLogs()">
|
||||
<h1>Audit Logs</h1>
|
||||
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<select x-model="filters.action" @change="loadLogs()">
|
||||
@@ -341,7 +341,7 @@ def is_maintenance_mode(db: Session) -> bool:
|
||||
<option value="toggle_store_status">Toggle Status</option>
|
||||
<option value="update_setting">Update Setting</option>
|
||||
</select>
|
||||
|
||||
|
||||
<select x-model="filters.target_type" @change="loadLogs()">
|
||||
<option value="">All Targets</option>
|
||||
<option value="store">Stores</option>
|
||||
@@ -349,7 +349,7 @@ def is_maintenance_mode(db: Session) -> bool:
|
||||
<option value="setting">Settings</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Logs Table -->
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
@@ -379,7 +379,7 @@ def is_maintenance_mode(db: Session) -> bool:
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination">
|
||||
<button @click="previousPage()" :disabled="skip === 0">Previous</button>
|
||||
@@ -400,45 +400,45 @@ function auditLogs() {
|
||||
skip: 0,
|
||||
limit: 50,
|
||||
total: 0,
|
||||
|
||||
|
||||
async loadLogs() {
|
||||
const params = new URLSearchParams({
|
||||
skip: this.skip,
|
||||
limit: this.limit,
|
||||
...this.filters
|
||||
});
|
||||
|
||||
|
||||
const response = await apiClient.get(`/api/v1/admin/audit/logs?${params}`);
|
||||
this.logs = response.logs;
|
||||
this.total = response.total;
|
||||
},
|
||||
|
||||
|
||||
showDetails(log) {
|
||||
// Show modal with full details
|
||||
console.log('Details:', log.details);
|
||||
},
|
||||
|
||||
|
||||
formatDate(date) {
|
||||
return new Date(date).toLocaleString();
|
||||
},
|
||||
|
||||
|
||||
get currentPage() {
|
||||
return Math.floor(this.skip / this.limit) + 1;
|
||||
},
|
||||
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.total / this.limit);
|
||||
},
|
||||
|
||||
|
||||
get hasMore() {
|
||||
return this.skip + this.limit < this.total;
|
||||
},
|
||||
|
||||
|
||||
nextPage() {
|
||||
this.skip += this.limit;
|
||||
this.loadLogs();
|
||||
},
|
||||
|
||||
|
||||
previousPage() {
|
||||
this.skip = Math.max(0, this.skip - this.limit);
|
||||
this.loadLogs();
|
||||
@@ -454,23 +454,23 @@ function auditLogs() {
|
||||
<!-- templates/admin/settings.html -->
|
||||
<div x-data="platformSettings()" x-init="loadSettings()">
|
||||
<h1>Platform Settings</h1>
|
||||
|
||||
|
||||
<!-- Category Tabs -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
<button
|
||||
@click="selectedCategory = 'system'"
|
||||
:class="{'active': selectedCategory === 'system'}"
|
||||
>System</button>
|
||||
<button
|
||||
<button
|
||||
@click="selectedCategory = 'security'"
|
||||
:class="{'active': selectedCategory === 'security'}"
|
||||
>Security</button>
|
||||
<button
|
||||
<button
|
||||
@click="selectedCategory = 'payments'"
|
||||
:class="{'active': selectedCategory === 'payments'}"
|
||||
>Payments</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Settings List -->
|
||||
<div class="settings-list">
|
||||
<template x-for="setting in filteredSettings" :key="setting.id">
|
||||
@@ -480,10 +480,10 @@ function auditLogs() {
|
||||
<span class="badge" x-text="setting.value_type"></span>
|
||||
</div>
|
||||
<p class="setting-description" x-text="setting.description"></p>
|
||||
|
||||
|
||||
<div class="setting-value">
|
||||
<input
|
||||
type="text"
|
||||
<input
|
||||
type="text"
|
||||
:value="setting.value"
|
||||
@change="updateSetting(setting.key, $event.target.value)"
|
||||
>
|
||||
@@ -492,7 +492,7 @@ function auditLogs() {
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Add New Setting -->
|
||||
<button @click="showAddModal = true" class="btn-primary">
|
||||
Add New Setting
|
||||
@@ -505,17 +505,17 @@ function platformSettings() {
|
||||
settings: [],
|
||||
selectedCategory: 'system',
|
||||
showAddModal: false,
|
||||
|
||||
|
||||
async loadSettings() {
|
||||
const response = await apiClient.get('/api/v1/admin/settings');
|
||||
this.settings = response.settings;
|
||||
},
|
||||
|
||||
|
||||
get filteredSettings() {
|
||||
if (!this.selectedCategory) return this.settings;
|
||||
return this.settings.filter(s => s.category === this.selectedCategory);
|
||||
},
|
||||
|
||||
|
||||
async updateSetting(key, newValue) {
|
||||
try {
|
||||
await apiClient.put(`/api/v1/admin/settings/${key}`, {
|
||||
@@ -527,7 +527,7 @@ function platformSettings() {
|
||||
showNotification('Failed to update setting', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
formatDate(date) {
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
@@ -557,7 +557,7 @@ def test_log_admin_action(db_session, test_admin_user):
|
||||
target_id="123",
|
||||
details={"store_code": "TEST"}
|
||||
)
|
||||
|
||||
|
||||
assert log is not None
|
||||
assert log.action == "create_store"
|
||||
assert log.target_type == "store"
|
||||
@@ -574,12 +574,12 @@ def test_query_audit_logs(db_session, test_admin_user):
|
||||
target_type="test",
|
||||
target_id=str(i)
|
||||
)
|
||||
|
||||
|
||||
# Query logs
|
||||
from models.schemas.admin import AdminAuditLogFilters
|
||||
filters = AdminAuditLogFilters(limit=10)
|
||||
logs = admin_audit_service.get_audit_logs(db_session, filters)
|
||||
|
||||
|
||||
assert len(logs) == 5
|
||||
```
|
||||
|
||||
@@ -590,20 +590,20 @@ def test_query_audit_logs(db_session, test_admin_user):
|
||||
def test_create_setting(db_session, test_admin_user):
|
||||
"""Test creating platform setting."""
|
||||
from models.schemas.admin import AdminSettingCreate
|
||||
|
||||
|
||||
setting_data = AdminSettingCreate(
|
||||
key="test_setting",
|
||||
value="test_value",
|
||||
value_type="string",
|
||||
category="test"
|
||||
)
|
||||
|
||||
|
||||
result = admin_settings_service.create_setting(
|
||||
db=db_session,
|
||||
setting_data=setting_data,
|
||||
admin_user_id=test_admin_user.id
|
||||
)
|
||||
|
||||
|
||||
assert result.key == "test_setting"
|
||||
assert result.value == "test_value"
|
||||
|
||||
@@ -617,7 +617,7 @@ def test_get_setting_value_with_type_conversion(db_session):
|
||||
category="system"
|
||||
)
|
||||
admin_settings_service.create_setting(db_session, setting_data, 1)
|
||||
|
||||
|
||||
# Get value (should be converted to int)
|
||||
value = admin_settings_service.get_setting_value(db_session, "max_stores")
|
||||
assert isinstance(value, int)
|
||||
@@ -630,10 +630,10 @@ def test_get_setting_value_with_type_conversion(db_session):
|
||||
|
||||
You now have a complete admin infrastructure with:
|
||||
|
||||
✅ **Audit Logging**: Track all admin actions for compliance
|
||||
✅ **Platform Settings**: Manage global configuration
|
||||
✅ **Notifications**: System alerts for admins (structure ready)
|
||||
✅ **Platform Alerts**: Health monitoring (structure ready)
|
||||
✅ **Audit Logging**: Track all admin actions for compliance
|
||||
✅ **Platform Settings**: Manage global configuration
|
||||
✅ **Notifications**: System alerts for admins (structure ready)
|
||||
✅ **Platform Alerts**: Health monitoring (structure ready)
|
||||
✅ **Session Tracking**: Monitor admin logins (structure ready)
|
||||
|
||||
### Next Steps
|
||||
@@ -646,4 +646,4 @@ You now have a complete admin infrastructure with:
|
||||
6. **Implement notification service** (notifications.py stubs)
|
||||
7. **Add monitoring** for platform alerts
|
||||
|
||||
These additions make your platform production-ready with full compliance and monitoring capabilities!
|
||||
These additions make your platform production-ready with full compliance and monitoring capabilities!
|
||||
|
||||
@@ -1101,9 +1101,10 @@ Prometheus + Grafana monitoring stack with host and container metrics.
|
||||
| grafana | 192 MB | Dashboards (SQLite backend) |
|
||||
| node-exporter | 64 MB | Host CPU/RAM/disk metrics |
|
||||
| cadvisor | 128 MB | Per-container resource metrics |
|
||||
| **Total new** | **640 MB** | |
|
||||
| redis-exporter | 32 MB | Redis memory, connections, command stats |
|
||||
| **Total new** | **672 MB** | |
|
||||
|
||||
Existing stack ~1.8 GB + 640 MB new = ~2.4 GB. Leaves ~1.6 GB for OS. If too tight, live-upgrade to CAX21 (8 GB/80 GB, ~7.50 EUR/mo) via **Cloud Console > Server > Rescale** (~2 min restart).
|
||||
Existing stack ~1.8 GB + 672 MB new = ~2.5 GB. Leaves ~1.6 GB for OS. If too tight, live-upgrade to CAX21 (8 GB/80 GB, ~7.50 EUR/mo) via **Cloud Console > Server > Rescale** (~2 min restart).
|
||||
|
||||
### 18.1 DNS Record
|
||||
|
||||
@@ -1372,6 +1373,264 @@ This is the professional approach — emails come from the client's domain with
|
||||
|
||||
---
|
||||
|
||||
## Step 19b: Sentry Error Tracking
|
||||
|
||||
Application-level error tracking with [Sentry](https://sentry.io). While Prometheus monitors infrastructure metrics (CPU, memory, HTTP error rates), Sentry captures **individual exceptions** with full stack traces, request context, and breadcrumbs — making it possible to debug production errors without SSH access.
|
||||
|
||||
!!! info "How Sentry fits into the monitoring stack"
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Observability Stack │
|
||||
├──────────────────┬──────────────────┬────────────────────────┤
|
||||
│ Prometheus │ Grafana │ Sentry │
|
||||
│ Infrastructure │ Dashboards │ Application errors │
|
||||
│ metrics & alerts │ & visualization │ & performance traces │
|
||||
├──────────────────┴──────────────────┴────────────────────────┤
|
||||
│ Prometheus: "API 5xx rate is 3%" │
|
||||
│ Sentry: "TypeError in /api/v1/orders/checkout line 42 │
|
||||
│ request_id=abc123, user_id=7, store=acme" │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### What's Already Wired
|
||||
|
||||
The codebase already initializes Sentry in two places — you just need to provide the DSN:
|
||||
|
||||
| Component | File | Integrations |
|
||||
|---|---|---|
|
||||
| FastAPI (API server) | `main.py:42-58` | `FastApiIntegration`, `SqlalchemyIntegration` |
|
||||
| Celery (background workers) | `app/core/celery_config.py:31-39` | `CeleryIntegration` |
|
||||
|
||||
Both read from the same `SENTRY_DSN` environment variable. When unset, Sentry is silently skipped.
|
||||
|
||||
### 19b.1 Create Sentry Project
|
||||
|
||||
1. Sign up at [sentry.io](https://sentry.io) (free Developer plan: **5K errors/month**, 1 user)
|
||||
2. Create a new project:
|
||||
- **Platform**: Python → FastAPI
|
||||
- **Project name**: `orion` (or `rewardflow`)
|
||||
- **Team**: default
|
||||
3. Copy the **DSN** from the project settings — it looks like:
|
||||
```
|
||||
https://abc123def456@o123456.ingest.de.sentry.io/7891011
|
||||
```
|
||||
|
||||
!!! tip "Sentry pricing"
|
||||
| Plan | Errors/month | Cost | Notes |
|
||||
|---|---|---|---|
|
||||
| **Developer** (free) | 5,000 | $0 | 1 user, 30-day retention |
|
||||
| **Team** | 50,000 | $26/mo | Unlimited users, 90-day retention |
|
||||
| **Business** | 50,000 | $80/mo | SSO, audit logs, 90-day retention |
|
||||
|
||||
The free plan is sufficient for launch. Upgrade to Team if you exceed 5K errors/month or need multiple team members.
|
||||
|
||||
### 19b.2 Configure Environment
|
||||
|
||||
Add to `~/apps/orion/.env` on the server:
|
||||
|
||||
```bash
|
||||
# Sentry Error Tracking
|
||||
SENTRY_DSN=https://your-key@o123456.ingest.de.sentry.io/your-project-id
|
||||
SENTRY_ENVIRONMENT=production
|
||||
SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
```
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SENTRY_DSN` | `None` (disabled) | Project DSN from Sentry dashboard |
|
||||
| `SENTRY_ENVIRONMENT` | `development` | Tags errors by environment (`production`, `staging`) |
|
||||
| `SENTRY_TRACES_SAMPLE_RATE` | `0.1` | Fraction of requests traced for performance (0.1 = 10%) |
|
||||
|
||||
!!! warning "Traces sample rate"
|
||||
`0.1` (10%) is a good starting point. At high traffic, lower to `0.01` (1%) to stay within the free plan's span limits. For initial launch with low traffic, you can temporarily set `1.0` (100%) for full visibility.
|
||||
|
||||
### 19b.3 Deploy
|
||||
|
||||
Restart the API and Celery containers to pick up the new env vars:
|
||||
|
||||
```bash
|
||||
cd ~/apps/orion
|
||||
docker compose --profile full restart api celery-worker celery-beat
|
||||
```
|
||||
|
||||
Check the API logs to confirm Sentry initialized:
|
||||
|
||||
```bash
|
||||
docker compose --profile full logs api --tail 20 | grep -i sentry
|
||||
```
|
||||
|
||||
You should see:
|
||||
|
||||
```
|
||||
Sentry initialized for environment: production
|
||||
```
|
||||
|
||||
### 19b.4 Verify
|
||||
|
||||
**1. Trigger a test error** by hitting the API with a request that will fail:
|
||||
|
||||
```bash
|
||||
curl -s https://api.wizard.lu/api/v1/nonexistent-endpoint-sentry-test
|
||||
```
|
||||
|
||||
**2. Check Sentry dashboard:**
|
||||
|
||||
- Go to [sentry.io](https://sentry.io) → your project → **Issues**
|
||||
- You should see a `404 Not Found` or similar error appear within seconds
|
||||
- Click into it to see the full stack trace, request headers, and breadcrumbs
|
||||
|
||||
**3. Verify Celery integration** — check that the Celery worker also reports to Sentry:
|
||||
|
||||
```bash
|
||||
docker compose --profile full logs celery-worker --tail 10 | grep -i sentry
|
||||
```
|
||||
|
||||
### 19b.5 Sentry Features to Configure
|
||||
|
||||
After verifying the basic setup, configure these in the Sentry web UI:
|
||||
|
||||
**Alerts (Sentry → Alerts → Create Alert):**
|
||||
|
||||
| Alert | Condition | Action |
|
||||
|---|---|---|
|
||||
| New issue spike | >10 events in 1 hour | Email notification |
|
||||
| First seen error | Any new issue | Email notification |
|
||||
| Unresolved high-volume | >50 events in 24h | Email notification |
|
||||
|
||||
**Release tracking** — Sentry automatically tags errors with the release version via `release=f"orion@{settings.version}"` in `main.py`. This lets you see which deploy introduced a bug.
|
||||
|
||||
**Source maps** (optional, post-launch) — if you want JS errors from the admin frontend, add the Sentry browser SDK to your base template. Not needed for launch since most errors will be server-side.
|
||||
|
||||
### 19b.6 What Sentry Captures
|
||||
|
||||
With the current integration, Sentry automatically captures:
|
||||
|
||||
| Data | Source | Example |
|
||||
|---|---|---|
|
||||
| Python exceptions | FastAPI + Celery | `TypeError`, `ValidationError`, unhandled 500s |
|
||||
| Request context | `FastApiIntegration` | URL, method, headers, query params, user IP |
|
||||
| DB query breadcrumbs | `SqlalchemyIntegration` | SQL queries leading up to the error |
|
||||
| Celery task failures | `CeleryIntegration` | Task name, args, retry count, worker hostname |
|
||||
| User info | `send_default_pii=True` | User email and IP (if authenticated) |
|
||||
| Performance traces | `traces_sample_rate` | End-to-end request timing, DB query duration |
|
||||
|
||||
!!! note "Privacy"
|
||||
`send_default_pii=True` is set in both `main.py` and `celery_config.py`. This sends user emails and IP addresses to Sentry for debugging context. If GDPR compliance requires stricter data handling, set this to `False` and configure Sentry's [Data Scrubbing](https://docs.sentry.io/security-legal-pii/scrubbing/) rules.
|
||||
|
||||
---
|
||||
|
||||
## Step 19c: Redis Monitoring (Redis Exporter)
|
||||
|
||||
Add direct Redis monitoring to Prometheus. Without this, Redis can die silently — Celery tasks stop processing and emails stop sending, but no alert fires.
|
||||
|
||||
### Why Not Just cAdvisor?
|
||||
|
||||
cAdvisor tells you "the Redis container is running." The Redis exporter tells you "Redis is running, responding to commands, using 45MB memory, has 3 clients connected, and command latency is 0.2ms." It also catches scenarios where the container is running but Redis itself is unhealthy (maxmemory reached, connection limit hit).
|
||||
|
||||
### Resource Impact
|
||||
|
||||
| Container | RAM | CPU | Image Size |
|
||||
|---|---|---|---|
|
||||
| redis-exporter | ~5 MB | negligible | ~10 MB |
|
||||
|
||||
### 19c.1 Docker Compose
|
||||
|
||||
The `redis-exporter` service has been added to `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:latest
|
||||
restart: always
|
||||
profiles:
|
||||
- full
|
||||
ports:
|
||||
- "127.0.0.1:9121:9121"
|
||||
environment:
|
||||
REDIS_ADDR: redis://redis:6379
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mem_limit: 32m
|
||||
networks:
|
||||
- backend
|
||||
- monitoring
|
||||
```
|
||||
|
||||
It joins both `backend` (to reach Redis) and `monitoring` (so Prometheus can scrape it).
|
||||
|
||||
### 19c.2 Prometheus Scrape Target
|
||||
|
||||
Added to `monitoring/prometheus.yml`:
|
||||
|
||||
```yaml
|
||||
- job_name: "redis"
|
||||
static_configs:
|
||||
- targets: ["redis-exporter:9121"]
|
||||
labels:
|
||||
service: "redis"
|
||||
```
|
||||
|
||||
### 19c.3 Alert Rules
|
||||
|
||||
Four Redis-specific alerts added to `monitoring/prometheus/alert.rules.yml`:
|
||||
|
||||
| Alert | Condition | Severity | What It Means |
|
||||
|---|---|---|---|
|
||||
| `RedisDown` | `redis_up == 0` for 1m | critical | Redis is unreachable — all background tasks stalled |
|
||||
| `RedisHighMemoryUsage` | >80% of maxmemory for 5m | warning | Queue backlog or memory leak |
|
||||
| `RedisHighConnectionCount` | >50 clients for 5m | warning | Possible connection leak |
|
||||
| `RedisRejectedConnections` | Any rejected in 5m | critical | Redis is refusing new connections |
|
||||
|
||||
### 19c.4 Deploy
|
||||
|
||||
```bash
|
||||
cd ~/apps/orion
|
||||
git pull
|
||||
docker compose --profile full up -d
|
||||
```
|
||||
|
||||
Verify the exporter is running and Prometheus can scrape it:
|
||||
|
||||
```bash
|
||||
# Exporter health
|
||||
curl -s http://localhost:9121/health
|
||||
|
||||
# Redis metrics flowing
|
||||
curl -s http://localhost:9121/metrics | grep redis_up
|
||||
|
||||
# Prometheus target status (should show "redis" as UP)
|
||||
curl -s http://localhost:9090/api/v1/targets | python3 -m json.tool | grep -A2 '"redis"'
|
||||
```
|
||||
|
||||
### 19c.5 Grafana Dashboard
|
||||
|
||||
Import the community Redis dashboard:
|
||||
|
||||
1. Open `https://grafana.wizard.lu`
|
||||
2. **Dashboards** → **Import** → ID `763` → Select Prometheus datasource
|
||||
3. You'll see: memory usage, connected clients, commands/sec, hit rate, key count
|
||||
|
||||
### 19c.6 Verification
|
||||
|
||||
```bash
|
||||
# Redis is being monitored
|
||||
curl -s http://localhost:9121/metrics | grep redis_up
|
||||
# redis_up 1
|
||||
|
||||
# Memory usage
|
||||
curl -s http://localhost:9121/metrics | grep redis_memory_used_bytes
|
||||
# redis_memory_used_bytes 1.234e+07 (≈12 MB)
|
||||
|
||||
# Connected clients
|
||||
curl -s http://localhost:9121/metrics | grep redis_connected_clients
|
||||
# redis_connected_clients 4 (API + celery-worker + celery-beat + flower)
|
||||
|
||||
# Alert rules loaded
|
||||
curl -s http://localhost:9090/api/v1/rules | python3 -m json.tool | grep -i redis
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 20: Security Hardening
|
||||
|
||||
Docker network segmentation, fail2ban configuration, and automatic security updates.
|
||||
@@ -2010,6 +2269,7 @@ After Google Wallet is verified working:
|
||||
| Grafana | 3000 | 3001 (localhost) | `grafana.wizard.lu` |
|
||||
| Node Exporter | 9100 | 9100 (localhost) | (internal only) |
|
||||
| cAdvisor | 8080 | 8080 (localhost) | (internal only) |
|
||||
| Redis Exporter | 9121 | 9121 (localhost) | (internal only) |
|
||||
| Alertmanager | 9093 | 9093 (localhost) | (internal only) |
|
||||
| Caddy | — | 80, 443 | (reverse proxy) |
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ apiClient.interceptors.response.use(
|
||||
};
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
|
||||
// Handle network errors
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
throw {
|
||||
@@ -58,7 +58,7 @@ apiClient.interceptors.response.use(
|
||||
statusCode: 408
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
throw {
|
||||
errorCode: 'NETWORK_ERROR',
|
||||
message: 'Network error. Please check your connection.',
|
||||
@@ -105,7 +105,7 @@ class ApiClient {
|
||||
if (error.errorCode) {
|
||||
throw error; // Re-throw API errors
|
||||
}
|
||||
|
||||
|
||||
// Handle network errors
|
||||
throw {
|
||||
errorCode: 'NETWORK_ERROR',
|
||||
@@ -130,28 +130,28 @@ export const ERROR_MESSAGES = {
|
||||
INVALID_CREDENTIALS: 'Invalid username or password. Please try again.',
|
||||
TOKEN_EXPIRED: 'Your session has expired. Please log in again.',
|
||||
USER_NOT_ACTIVE: 'Your account has been deactivated. Contact support.',
|
||||
|
||||
|
||||
// MarketplaceProduct errors
|
||||
PRODUCT_NOT_FOUND: 'MarketplaceProduct not found. It may have been removed.',
|
||||
PRODUCT_ALREADY_EXISTS: 'A product with this ID already exists.',
|
||||
INVALID_PRODUCT_DATA: 'Please check the product information and try again.',
|
||||
|
||||
|
||||
// Inventory errors
|
||||
INSUFFICIENT_INVENTORY: 'Not enough inventory available for this operation.',
|
||||
INVENTORY_NOT_FOUND: 'No inventory information found for this product.',
|
||||
NEGATIVE_INVENTORY_NOT_ALLOWED: 'Inventory quantity cannot be negative.',
|
||||
|
||||
|
||||
// Shop errors
|
||||
SHOP_NOT_FOUND: 'Shop not found or no longer available.',
|
||||
UNAUTHORIZED_SHOP_ACCESS: 'You do not have permission to access this shop.',
|
||||
SHOP_ALREADY_EXISTS: 'A shop with this code already exists.',
|
||||
MAX_SHOPS_REACHED: 'You have reached the maximum number of shops allowed.',
|
||||
|
||||
|
||||
// Import errors
|
||||
IMPORT_JOB_NOT_FOUND: 'Import job not found.',
|
||||
IMPORT_JOB_CANNOT_BE_CANCELLED: 'This import job cannot be cancelled at this time.',
|
||||
MARKETPLACE_CONNECTION_FAILED: 'Failed to connect to marketplace. Please try again.',
|
||||
|
||||
|
||||
// Generic fallbacks
|
||||
VALIDATION_ERROR: 'Please check your input and try again.',
|
||||
NETWORK_ERROR: 'Connection error. Please check your internet connection.',
|
||||
@@ -179,7 +179,7 @@ export const useApiError = () => {
|
||||
const handleApiCall = async (apiCall) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const result = await apiCall();
|
||||
setIsLoading(false);
|
||||
@@ -233,7 +233,7 @@ const ProductForm = () => {
|
||||
if (apiError.field) {
|
||||
setFieldErrors({ [apiError.field]: apiError.message });
|
||||
}
|
||||
|
||||
|
||||
// Handle specific error codes
|
||||
switch (apiError.errorCode) {
|
||||
case 'PRODUCT_ALREADY_EXISTS':
|
||||
@@ -312,7 +312,7 @@ class ErrorBoundary extends React.Component {
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
|
||||
|
||||
// Log to error reporting service
|
||||
if (window.errorReporting) {
|
||||
window.errorReporting.captureException(error, {
|
||||
@@ -442,7 +442,7 @@ const handleInventoryUpdate = async (gtin, location, quantity) => {
|
||||
switch (error.errorCode) {
|
||||
case 'INSUFFICIENT_INVENTORY':
|
||||
const { available_quantity, requested_quantity } = error.details;
|
||||
notificationManager.notify('error',
|
||||
notificationManager.notify('error',
|
||||
`Cannot remove ${requested_quantity} items. Only ${available_quantity} available.`
|
||||
);
|
||||
break;
|
||||
@@ -493,7 +493,7 @@ const ImportJobStatus = ({ jobId }) => {
|
||||
switch (error.errorCode) {
|
||||
case 'IMPORT_JOB_CANNOT_BE_CANCELLED':
|
||||
const { current_status } = error.details;
|
||||
notificationManager.notify('error',
|
||||
notificationManager.notify('error',
|
||||
`Cannot cancel job in ${current_status} status`
|
||||
);
|
||||
break;
|
||||
@@ -549,12 +549,12 @@ export const logError = (error, context = {}) => {
|
||||
try {
|
||||
const existingErrors = JSON.parse(localStorage.getItem('apiErrors') || '[]');
|
||||
existingErrors.push(errorData);
|
||||
|
||||
|
||||
// Keep only last 50 errors
|
||||
if (existingErrors.length > 50) {
|
||||
existingErrors.splice(0, existingErrors.length - 50);
|
||||
}
|
||||
|
||||
|
||||
localStorage.setItem('apiErrors', JSON.stringify(existingErrors));
|
||||
} catch (e) {
|
||||
console.warn('Failed to store error locally:', e);
|
||||
|
||||
@@ -292,7 +292,7 @@ Add this script **before** Alpine.js in your HTML pages:
|
||||
<span>Options</span>
|
||||
<span x-html="$icon('chevron-down', 'w-4 h-4 ml-2')"></span>
|
||||
</button>
|
||||
|
||||
|
||||
<div x-show="open" class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg">
|
||||
<a href="#" class="flex items-center px-4 py-2 hover:bg-gray-100">
|
||||
<span x-html="$icon('edit', 'w-4 h-4 mr-2')"></span>
|
||||
@@ -425,4 +425,4 @@ The icon system works in all modern browsers:
|
||||
|
||||
- [Heroicons Official Site](https://heroicons.com/)
|
||||
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
|
||||
- [Alpine.js Magic Properties](https://alpinejs.dev/magics/el)
|
||||
- [Alpine.js Magic Properties](https://alpinejs.dev/magics/el)
|
||||
|
||||
@@ -213,13 +213,13 @@ For complex changes that require data transformation:
|
||||
def upgrade() -> None:
|
||||
# Create new column
|
||||
op.add_column('products', sa.Column('normalized_price', sa.Numeric(10, 2)))
|
||||
|
||||
|
||||
# Migrate data
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
text("UPDATE products SET normalized_price = CAST(price AS NUMERIC) WHERE price ~ '^[0-9.]+$'")
|
||||
)
|
||||
|
||||
|
||||
# Make column non-nullable after data migration
|
||||
op.alter_column('products', 'normalized_price', nullable=False)
|
||||
|
||||
@@ -329,7 +329,7 @@ def upgrade() -> None:
|
||||
if context.get_x_argument(as_dictionary=True).get('dev_data', False):
|
||||
# Add development sample data
|
||||
pass
|
||||
|
||||
|
||||
# Always apply schema changes
|
||||
op.create_table(...)
|
||||
```
|
||||
@@ -345,20 +345,20 @@ For large data transformations, use batch processing:
|
||||
```python
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
|
||||
# Process in batches to avoid memory issues
|
||||
batch_size = 1000
|
||||
offset = 0
|
||||
|
||||
|
||||
while True:
|
||||
result = connection.execute(
|
||||
text(f"SELECT id, old_field FROM products LIMIT {batch_size} OFFSET {offset}")
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
|
||||
if not rows:
|
||||
break
|
||||
|
||||
|
||||
for row in rows:
|
||||
# Transform data
|
||||
new_value = transform_function(row.old_field)
|
||||
@@ -366,7 +366,7 @@ def upgrade() -> None:
|
||||
text("UPDATE products SET new_field = :new_val WHERE id = :id"),
|
||||
{"new_val": new_value, "id": row.id}
|
||||
)
|
||||
|
||||
|
||||
offset += batch_size
|
||||
```
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ C:\ProgramData\chocolatey\bin\make.exe %*
|
||||
1. Open PyCharm Settings: **File → Settings** (Ctrl+Alt+S)
|
||||
2. Navigate to: **Build, Execution, Deployment → Build Tools → Make**
|
||||
3. Set the **Path to make executable** to: `[YOUR_PROJECT_PATH]\make-venv.bat`
|
||||
|
||||
|
||||
For example: `E:\Letzshop-Import-v1\make-venv.bat`
|
||||
|
||||
4. Click **Apply** and **OK**
|
||||
@@ -112,4 +112,4 @@ your-project/
|
||||
├── Makefile
|
||||
├── make-venv.bat
|
||||
└── requirements.txt
|
||||
```
|
||||
```
|
||||
|
||||
@@ -11,11 +11,11 @@ Git is an open-source, distributed version control system that helps you manage
|
||||
## 🛠️ Create a Git Repository
|
||||
|
||||
1. **Sign in to DSM** using an account with administrative privileges.
|
||||
2. Go to:
|
||||
**Control Panel > Terminal & SNMP > Terminal**
|
||||
2. Go to:
|
||||
**Control Panel > Terminal & SNMP > Terminal**
|
||||
→ Enable **SSH service**.
|
||||
3. Go to:
|
||||
**Control Panel > Shared Folder**
|
||||
3. Go to:
|
||||
**Control Panel > Shared Folder**
|
||||
→ Create a shared folder for Git repositories.
|
||||
4. On your computer, access Synology NAS via SSH:
|
||||
|
||||
@@ -63,7 +63,7 @@ Adding new repository on NAS DS223J
|
||||
> chown -R git-boulaht1:users my-repo.git
|
||||
> cd my-repo.git/
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
### ✅ **Steps to Push Local Git Repo to Synology NAS**
|
||||
@@ -110,9 +110,9 @@ and then Permission and then tick boxes apply to sub folder and files
|
||||
(git-boulaht1 should be read write on the folder)
|
||||
|
||||
|
||||
> ⚠️ **Note:**
|
||||
> Do **not** perform the above commands with root permissions.
|
||||
> Git Server no longer supports `git-shell` commands due to security concerns.
|
||||
> ⚠️ **Note:**
|
||||
> Do **not** perform the above commands with root permissions.
|
||||
> Git Server no longer supports `git-shell` commands due to security concerns.
|
||||
> For `git-shell` access, consider using container-based Git services.
|
||||
|
||||
---
|
||||
@@ -149,4 +149,4 @@ and then Permission and then tick boxes apply to sub folder and files
|
||||
cd /volume1/mysharefolder/myrepo1
|
||||
```
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
@@ -191,15 +191,15 @@ Component Structure:
|
||||
return {
|
||||
// ✅ CRITICAL: Inherit base layout state
|
||||
...data(),
|
||||
|
||||
|
||||
// ✅ CRITICAL: Set page identifier
|
||||
currentPage: 'dashboard',
|
||||
|
||||
|
||||
// Page-specific state
|
||||
loading: false,
|
||||
error: null,
|
||||
stats: [],
|
||||
|
||||
|
||||
// Initialization
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
@@ -208,10 +208,10 @@ Component Structure:
|
||||
return;
|
||||
}
|
||||
window._dashboardInitialized = true;
|
||||
|
||||
|
||||
await this.loadStats();
|
||||
},
|
||||
|
||||
|
||||
// Data loading
|
||||
async loadStats() {
|
||||
this.loading = true;
|
||||
@@ -294,22 +294,22 @@ init-alpine.js provides:
|
||||
// Theme state
|
||||
dark: localStorage.getItem('theme') === 'dark',
|
||||
toggleTheme() { /* ... */ },
|
||||
|
||||
|
||||
// Side menu state
|
||||
isSideMenuOpen: false,
|
||||
toggleSideMenu() { /* ... */ },
|
||||
closeSideMenu() { /* ... */ },
|
||||
|
||||
|
||||
// Profile menu state
|
||||
isProfileMenuOpen: false,
|
||||
toggleProfileMenu() { /* ... */ },
|
||||
closeProfileMenu() { /* ... */ },
|
||||
|
||||
|
||||
// Notifications menu state
|
||||
isNotificationsMenuOpen: false,
|
||||
toggleNotificationsMenu() { /* ... */ },
|
||||
closeNotificationsMenu() { /* ... */ },
|
||||
|
||||
|
||||
// Page identifier (override in each page)
|
||||
currentPage: ''
|
||||
};
|
||||
@@ -320,7 +320,7 @@ Your page inherits ALL of this:
|
||||
return {
|
||||
...data(), // ← Spreads all base functionality
|
||||
currentPage: 'stores', // ← Override page identifier
|
||||
|
||||
|
||||
// Your page-specific state
|
||||
stores: [],
|
||||
loading: false
|
||||
@@ -446,10 +446,10 @@ Pattern:
|
||||
dashLog.warn('Already initialized, skipping...');
|
||||
return; // Exit early
|
||||
}
|
||||
|
||||
|
||||
// Set flag BEFORE async operations
|
||||
window._dashboardInitialized = true;
|
||||
|
||||
|
||||
// Safe to proceed
|
||||
await this.loadData();
|
||||
}
|
||||
@@ -485,7 +485,7 @@ Pre-configured Loggers:
|
||||
Usage:
|
||||
// Use pre-configured logger
|
||||
const dashLog = window.LogConfig.loggers.dashboard;
|
||||
|
||||
|
||||
dashLog.info('Dashboard loading...');
|
||||
dashLog.error('Failed to load stats', error);
|
||||
dashLog.debug('Stats data:', statsData);
|
||||
@@ -497,13 +497,13 @@ Advanced Features:
|
||||
dashLog.info('Fetching stats...');
|
||||
dashLog.info('Fetching activity...');
|
||||
dashLog.groupEnd();
|
||||
|
||||
|
||||
// API call logging
|
||||
window.LogConfig.logApiCall('GET', url, data, 'response');
|
||||
|
||||
|
||||
// Performance logging
|
||||
window.LogConfig.logPerformance('Load Stats', duration);
|
||||
|
||||
|
||||
// Error logging
|
||||
window.LogConfig.logError(error, 'Load Stats');
|
||||
|
||||
@@ -527,16 +527,16 @@ CRITICAL: Always use lowercase 'apiClient'
|
||||
|
||||
Usage:
|
||||
const data = await apiClient.get('/api/v1/admin/stores');
|
||||
|
||||
|
||||
await apiClient.post('/api/v1/admin/stores', {
|
||||
name: 'New Store',
|
||||
code: 'NEWSTORE'
|
||||
});
|
||||
|
||||
|
||||
await apiClient.put('/api/v1/admin/stores/123', {
|
||||
name: 'Updated Name'
|
||||
});
|
||||
|
||||
|
||||
await apiClient.delete('/api/v1/admin/stores/123');
|
||||
|
||||
Features:
|
||||
|
||||
@@ -60,7 +60,7 @@ app/
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
[Page Name]
|
||||
</h2>
|
||||
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
@@ -72,7 +72,7 @@ app/
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
@click="openCreateModal()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
@@ -94,7 +94,7 @@ app/
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- ERROR STATE -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div x-show="error && !loading"
|
||||
<div x-show="error && !loading"
|
||||
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start dark:bg-red-900/20 dark:border-red-800 dark:text-red-200">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
@@ -120,7 +120,7 @@ app/
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label class="block text-sm">
|
||||
@@ -136,7 +136,7 @@ app/
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Sort -->
|
||||
<div>
|
||||
<label class="block text-sm">
|
||||
@@ -190,15 +190,15 @@ app/
|
||||
<div class="flex items-center text-sm">
|
||||
<div>
|
||||
<p class="font-semibold" x-text="item.name"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400"
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400"
|
||||
x-text="item.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="item.is_active
|
||||
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
|
||||
:class="item.is_active
|
||||
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
|
||||
: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'"
|
||||
x-text="item.is_active ? 'Active' : 'Inactive'"></span>
|
||||
</td>
|
||||
@@ -234,12 +234,12 @@ app/
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="pagination.totalPages > 1"
|
||||
class="px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
|
||||
<span class="flex items-center col-span-3">
|
||||
Showing
|
||||
Showing
|
||||
<span x-text="pagination.from"></span>
|
||||
-
|
||||
<span x-text="pagination.to"></span>
|
||||
@@ -261,20 +261,20 @@ app/
|
||||
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
|
||||
<template x-for="page in paginationRange" :key="page">
|
||||
<li>
|
||||
<button
|
||||
@click="goToPage(page)"
|
||||
:class="page === pagination.currentPage
|
||||
? 'text-white bg-purple-600'
|
||||
:class="page === pagination.currentPage
|
||||
? 'text-white bg-purple-600'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
class="px-3 py-1 rounded-md focus:outline-none focus:shadow-outline-purple"
|
||||
x-text="page"
|
||||
></button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
|
||||
<li>
|
||||
<button
|
||||
@click="nextPage()"
|
||||
@@ -299,9 +299,9 @@ app/
|
||||
class="fixed inset-0 z-50 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
@click.away="closeModal()"
|
||||
@keydown.escape.window="closeModal()">
|
||||
|
||||
|
||||
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl">
|
||||
|
||||
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-center justify-between pb-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
|
||||
@@ -313,11 +313,11 @@ app/
|
||||
<span x-html="$icon('x', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal body -->
|
||||
<div class="mt-4">
|
||||
<form @submit.prevent="saveItem()">
|
||||
|
||||
|
||||
<!-- Name Field -->
|
||||
<label class="block text-sm mb-4">
|
||||
<span class="text-gray-700 dark:text-gray-400">Name</span>
|
||||
@@ -327,11 +327,11 @@ app/
|
||||
:class="errors.name ? 'border-red-600' : ''"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
<span x-show="errors.name"
|
||||
class="text-xs text-red-600 dark:text-red-400"
|
||||
<span x-show="errors.name"
|
||||
class="text-xs text-red-600 dark:text-red-400"
|
||||
x-text="errors.name"></span>
|
||||
</label>
|
||||
|
||||
|
||||
<!-- Description Field -->
|
||||
<label class="block text-sm mb-4">
|
||||
<span class="text-gray-700 dark:text-gray-400">Description</span>
|
||||
@@ -342,7 +342,7 @@ app/
|
||||
placeholder="Enter description"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
|
||||
<!-- Status Field -->
|
||||
<label class="block text-sm mb-4">
|
||||
<span class="text-gray-700 dark:text-gray-400">Status</span>
|
||||
@@ -354,7 +354,7 @@ app/
|
||||
<option :value="false">Inactive</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
<!-- Modal footer -->
|
||||
<div class="flex flex-col items-center justify-end px-6 py-3 -mx-6 -mb-4 space-y-4 sm:space-y-0 sm:space-x-6 sm:flex-row bg-gray-50 dark:bg-gray-800">
|
||||
<button
|
||||
@@ -413,26 +413,26 @@ function admin[PageName]() {
|
||||
// ✅ CRITICAL: INHERIT BASE LAYOUT
|
||||
// ─────────────────────────────────────────────────────
|
||||
...data(),
|
||||
|
||||
|
||||
// ✅ CRITICAL: SET PAGE IDENTIFIER
|
||||
currentPage: '[page-name]',
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// STATE
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
// Loading states
|
||||
loading: false,
|
||||
saving: false,
|
||||
|
||||
|
||||
// Error handling
|
||||
error: null,
|
||||
errors: {},
|
||||
|
||||
|
||||
// Data
|
||||
items: [],
|
||||
currentItem: null,
|
||||
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
currentPage: 1,
|
||||
@@ -442,14 +442,14 @@ function admin[PageName]() {
|
||||
from: 0,
|
||||
to: 0
|
||||
},
|
||||
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
search: '',
|
||||
status: '',
|
||||
sortBy: 'created_at:desc'
|
||||
},
|
||||
|
||||
|
||||
// Modal
|
||||
showModal: false,
|
||||
modalMode: 'create', // 'create' or 'edit'
|
||||
@@ -458,47 +458,47 @@ function admin[PageName]() {
|
||||
description: '',
|
||||
is_active: true
|
||||
},
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// LIFECYCLE
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* ✅ CRITICAL: Initialize component with guard
|
||||
*/
|
||||
async init() {
|
||||
pageLog.info('=== [PAGE NAME] INITIALIZING ===');
|
||||
|
||||
|
||||
// ✅ CRITICAL: Initialization guard
|
||||
if (window._[pageName]Initialized) {
|
||||
pageLog.warn('Already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._[pageName]Initialized = true;
|
||||
|
||||
|
||||
// Track performance
|
||||
const startTime = performance.now();
|
||||
|
||||
|
||||
try {
|
||||
// Load initial data
|
||||
await this.loadData();
|
||||
|
||||
|
||||
// Log performance
|
||||
const duration = performance.now() - startTime;
|
||||
window.LogConfig.logPerformance('[Page Name] Init', duration);
|
||||
|
||||
|
||||
pageLog.info('=== [PAGE NAME] INITIALIZATION COMPLETE ===');
|
||||
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, '[Page Name] Init');
|
||||
this.error = 'Failed to initialize page';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// DATA LOADING
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Load main data from API
|
||||
*/
|
||||
@@ -506,10 +506,10 @@ function admin[PageName]() {
|
||||
pageLog.info('Loading data...');
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
|
||||
|
||||
// Build query params
|
||||
const params = new URLSearchParams({
|
||||
page: this.pagination.currentPage,
|
||||
@@ -518,18 +518,18 @@ function admin[PageName]() {
|
||||
status: this.filters.status,
|
||||
sort_by: this.filters.sortBy
|
||||
});
|
||||
|
||||
|
||||
const url = `/api/v1/admin/items?${params}`;
|
||||
|
||||
|
||||
// Log API request
|
||||
window.LogConfig.logApiCall('GET', url, null, 'request');
|
||||
|
||||
|
||||
// ✅ CRITICAL: Use lowercase apiClient
|
||||
const response = await apiClient.get(url);
|
||||
|
||||
|
||||
// Log API response
|
||||
window.LogConfig.logApiCall('GET', url, response, 'response');
|
||||
|
||||
|
||||
// Update state
|
||||
this.items = response.items || [];
|
||||
this.pagination.total = response.total || 0;
|
||||
@@ -541,17 +541,17 @@ function admin[PageName]() {
|
||||
this.pagination.currentPage * this.pagination.perPage,
|
||||
this.pagination.total
|
||||
);
|
||||
|
||||
|
||||
// Log performance
|
||||
const duration = performance.now() - startTime;
|
||||
window.LogConfig.logPerformance('Load Data', duration);
|
||||
|
||||
|
||||
pageLog.info(`Data loaded successfully`, {
|
||||
count: this.items.length,
|
||||
duration: `${duration}ms`,
|
||||
page: this.pagination.currentPage
|
||||
});
|
||||
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Load Data');
|
||||
this.error = error.message || 'Failed to load data';
|
||||
@@ -560,7 +560,7 @@ function admin[PageName]() {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Refresh data
|
||||
*/
|
||||
@@ -570,11 +570,11 @@ function admin[PageName]() {
|
||||
await this.loadData();
|
||||
Utils.showToast('Data refreshed', 'success');
|
||||
},
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// FILTERS & SEARCH
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Apply filters and reload data
|
||||
*/
|
||||
@@ -583,7 +583,7 @@ function admin[PageName]() {
|
||||
this.pagination.currentPage = 1; // Reset to first page
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Reset filters to default
|
||||
*/
|
||||
@@ -595,11 +595,11 @@ function admin[PageName]() {
|
||||
};
|
||||
await this.applyFilters();
|
||||
},
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// PAGINATION
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Navigate to specific page
|
||||
*/
|
||||
@@ -608,21 +608,21 @@ function admin[PageName]() {
|
||||
this.pagination.currentPage = page;
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Go to previous page
|
||||
*/
|
||||
async previousPage() {
|
||||
await this.goToPage(this.pagination.currentPage - 1);
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Go to next page
|
||||
*/
|
||||
async nextPage() {
|
||||
await this.goToPage(this.pagination.currentPage + 1);
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Get pagination range for display
|
||||
*/
|
||||
@@ -630,27 +630,27 @@ function admin[PageName]() {
|
||||
const current = this.pagination.currentPage;
|
||||
const total = this.pagination.totalPages;
|
||||
const range = [];
|
||||
|
||||
|
||||
// Show max 5 page numbers
|
||||
let start = Math.max(1, current - 2);
|
||||
let end = Math.min(total, start + 4);
|
||||
|
||||
|
||||
// Adjust start if we're near the end
|
||||
if (end - start < 4) {
|
||||
start = Math.max(1, end - 4);
|
||||
}
|
||||
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
|
||||
return range;
|
||||
},
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// CRUD OPERATIONS
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Open create modal
|
||||
*/
|
||||
@@ -665,36 +665,36 @@ function admin[PageName]() {
|
||||
this.errors = {};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Open edit modal
|
||||
*/
|
||||
async editItem(itemId) {
|
||||
pageLog.info('Opening edit modal for item:', itemId);
|
||||
|
||||
|
||||
try {
|
||||
this.modalMode = 'edit';
|
||||
|
||||
|
||||
// Load item details
|
||||
const url = `/api/v1/admin/items/${itemId}`;
|
||||
window.LogConfig.logApiCall('GET', url, null, 'request');
|
||||
|
||||
|
||||
const item = await apiClient.get(url);
|
||||
|
||||
|
||||
window.LogConfig.logApiCall('GET', url, item, 'response');
|
||||
|
||||
|
||||
// Populate form
|
||||
this.formData = { ...item };
|
||||
this.currentItem = item;
|
||||
this.errors = {};
|
||||
this.showModal = true;
|
||||
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Edit Item');
|
||||
Utils.showToast('Failed to load item', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* View item details
|
||||
*/
|
||||
@@ -703,57 +703,57 @@ function admin[PageName]() {
|
||||
// Navigate to detail page or open view modal
|
||||
window.location.href = `/admin/items/${itemId}`;
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Save item (create or update)
|
||||
*/
|
||||
async saveItem() {
|
||||
pageLog.info('Saving item...');
|
||||
|
||||
|
||||
// Validate form
|
||||
if (!this.validateForm()) {
|
||||
pageLog.warn('Form validation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.saving = true;
|
||||
this.errors = {};
|
||||
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
|
||||
if (this.modalMode === 'create') {
|
||||
// Create new item
|
||||
const url = '/api/v1/admin/items';
|
||||
window.LogConfig.logApiCall('POST', url, this.formData, 'request');
|
||||
|
||||
|
||||
response = await apiClient.post(url, this.formData);
|
||||
|
||||
|
||||
window.LogConfig.logApiCall('POST', url, response, 'response');
|
||||
|
||||
|
||||
pageLog.info('Item created successfully');
|
||||
Utils.showToast('Item created successfully', 'success');
|
||||
|
||||
|
||||
} else {
|
||||
// Update existing item
|
||||
const url = `/api/v1/admin/items/${this.currentItem.id}`;
|
||||
window.LogConfig.logApiCall('PUT', url, this.formData, 'request');
|
||||
|
||||
|
||||
response = await apiClient.put(url, this.formData);
|
||||
|
||||
|
||||
window.LogConfig.logApiCall('PUT', url, response, 'response');
|
||||
|
||||
|
||||
pageLog.info('Item updated successfully');
|
||||
Utils.showToast('Item updated successfully', 'success');
|
||||
}
|
||||
|
||||
|
||||
// Close modal and reload data
|
||||
this.closeModal();
|
||||
await this.loadData();
|
||||
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Save Item');
|
||||
|
||||
|
||||
// Handle validation errors
|
||||
if (error.details && error.details.validation_errors) {
|
||||
this.errors = error.details.validation_errors;
|
||||
@@ -764,38 +764,38 @@ function admin[PageName]() {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Delete item
|
||||
*/
|
||||
async deleteItem(itemId) {
|
||||
pageLog.info('Deleting item:', itemId);
|
||||
|
||||
|
||||
// Confirm deletion
|
||||
if (!confirm('Are you sure you want to delete this item?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const url = `/api/v1/admin/items/${itemId}`;
|
||||
window.LogConfig.logApiCall('DELETE', url, null, 'request');
|
||||
|
||||
|
||||
await apiClient.delete(url);
|
||||
|
||||
|
||||
window.LogConfig.logApiCall('DELETE', url, null, 'response');
|
||||
|
||||
|
||||
pageLog.info('Item deleted successfully');
|
||||
Utils.showToast('Item deleted successfully', 'success');
|
||||
|
||||
|
||||
// Reload data
|
||||
await this.loadData();
|
||||
|
||||
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Delete Item');
|
||||
Utils.showToast('Failed to delete item', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Close modal
|
||||
*/
|
||||
@@ -805,30 +805,30 @@ function admin[PageName]() {
|
||||
this.errors = {};
|
||||
this.currentItem = null;
|
||||
},
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// VALIDATION
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Validate form data
|
||||
*/
|
||||
validateForm() {
|
||||
this.errors = {};
|
||||
|
||||
|
||||
if (!this.formData.name || this.formData.name.trim() === '') {
|
||||
this.errors.name = 'Name is required';
|
||||
}
|
||||
|
||||
|
||||
// Add more validation rules as needed
|
||||
|
||||
|
||||
return Object.keys(this.errors).length === 0;
|
||||
},
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// HELPERS
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
@@ -836,7 +836,7 @@ function admin[PageName]() {
|
||||
if (!dateString) return '-';
|
||||
return Utils.formatDate(dateString);
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Truncate text
|
||||
*/
|
||||
@@ -943,7 +943,7 @@ async loadData() {
|
||||
async init() {
|
||||
if (window._dashboardInitialized) return;
|
||||
window._dashboardInitialized = true;
|
||||
|
||||
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadRecentActivity()
|
||||
@@ -969,7 +969,7 @@ async loadRecentActivity() {
|
||||
async init() {
|
||||
if (window._editPageInitialized) return;
|
||||
window._editPageInitialized = true;
|
||||
|
||||
|
||||
const itemId = this.getItemIdFromUrl();
|
||||
await this.loadItem(itemId);
|
||||
}
|
||||
@@ -982,12 +982,12 @@ async loadItem(id) {
|
||||
|
||||
async saveItem() {
|
||||
if (!this.validateForm()) return;
|
||||
|
||||
|
||||
await apiClient.put(
|
||||
`/api/v1/admin/items/${this.item.id}`,
|
||||
this.formData
|
||||
);
|
||||
|
||||
|
||||
Utils.showToast('Saved successfully', 'success');
|
||||
}
|
||||
```
|
||||
@@ -1017,11 +1017,11 @@ async loadData() {
|
||||
per_page: this.pagination.perPage,
|
||||
...this.filters
|
||||
});
|
||||
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/api/v1/admin/items?${params}`
|
||||
);
|
||||
|
||||
|
||||
this.items = response.items;
|
||||
this.pagination.total = response.total;
|
||||
this.pagination.totalPages = Math.ceil(
|
||||
@@ -1062,7 +1062,7 @@ async init() {
|
||||
return;
|
||||
}
|
||||
window._myPageInitialized = true;
|
||||
|
||||
|
||||
await this.loadData();
|
||||
}
|
||||
```
|
||||
@@ -1345,13 +1345,13 @@ onStoreChange() {
|
||||
// Quick fill from selected store's settings
|
||||
quickFill(language) {
|
||||
if (!this.selectedStore) return;
|
||||
|
||||
|
||||
const urlMap = {
|
||||
'fr': this.selectedStore.letzshop_csv_url_fr,
|
||||
'en': this.selectedStore.letzshop_csv_url_en,
|
||||
'de': this.selectedStore.letzshop_csv_url_de
|
||||
};
|
||||
|
||||
|
||||
if (urlMap[language]) {
|
||||
this.importForm.csv_url = urlMap[language];
|
||||
this.importForm.language = language;
|
||||
@@ -1367,11 +1367,11 @@ async loadJobs() {
|
||||
limit: this.limit,
|
||||
created_by_me: 'true' // Only show jobs I triggered
|
||||
});
|
||||
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/marketplace-import-jobs?${params.toString()}`
|
||||
);
|
||||
|
||||
|
||||
this.jobs = response.items || [];
|
||||
}
|
||||
```
|
||||
@@ -1443,12 +1443,12 @@ filters: {
|
||||
|
||||
async applyFilters() {
|
||||
this.page = 1; // Reset to first page
|
||||
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: this.page,
|
||||
limit: this.limit
|
||||
});
|
||||
|
||||
|
||||
// Add filters
|
||||
if (this.filters.store_id) {
|
||||
params.append('store_id', this.filters.store_id);
|
||||
@@ -1459,7 +1459,7 @@ async applyFilters() {
|
||||
if (this.filters.created_by === 'me') {
|
||||
params.append('created_by_me', 'true');
|
||||
}
|
||||
|
||||
|
||||
await this.loadJobs();
|
||||
await this.loadStats(); // Update stats based on filters
|
||||
}
|
||||
@@ -1472,12 +1472,12 @@ async applyFilters() {
|
||||
<select x-model="filters.store_id" @change="applyFilters()">
|
||||
<option value="">All Stores</option>
|
||||
<template x-for="store in stores" :key="store.id">
|
||||
<option :value="store.id"
|
||||
<option :value="store.id"
|
||||
x-text="`${store.name} (${store.store_code})`">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select x-model="filters.status" @change="applyFilters()">
|
||||
<option value="">All Statuses</option>
|
||||
@@ -1486,7 +1486,7 @@ async applyFilters() {
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
|
||||
|
||||
<!-- Creator Filter -->
|
||||
<select x-model="filters.created_by" @change="applyFilters()">
|
||||
<option value="">All Users</option>
|
||||
@@ -1627,14 +1627,14 @@ See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
|
||||
|
||||
```html
|
||||
<!-- Success -->
|
||||
<div x-show="successMessage" x-transition
|
||||
<div x-show="successMessage" x-transition
|
||||
class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3')"></span>
|
||||
<p class="font-semibold" x-text="successMessage"></p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" x-transition
|
||||
<div x-show="error" x-transition
|
||||
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg dark:bg-red-900/20">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3')"></span>
|
||||
<div>
|
||||
@@ -1682,12 +1682,12 @@ See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
|
||||
<span x-html="$icon('close', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="selectedJob">
|
||||
<!-- Job details grid -->
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button @click="closeJobModal()" class="...">Close</button>
|
||||
@@ -1704,4 +1704,3 @@ See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
|
||||
- [Store Page Templates](../store/page-templates.md) - Store page patterns
|
||||
- [Icons Guide](../../development/icons-guide.md) - Available icons
|
||||
- [Admin Integration Guide](../../backend/admin-integration-guide.md) - Backend integration
|
||||
|
||||
|
||||
@@ -173,11 +173,11 @@ Example:
|
||||
return {
|
||||
loading: false,
|
||||
stats: {},
|
||||
|
||||
|
||||
async init() {
|
||||
await this.loadStats();
|
||||
},
|
||||
|
||||
|
||||
async loadStats() {
|
||||
this.loading = true;
|
||||
try {
|
||||
|
||||
@@ -55,7 +55,7 @@ app/
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
[Page Name]
|
||||
</h2>
|
||||
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
@@ -67,7 +67,7 @@ app/
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
@click="openCreateModal()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
@@ -89,7 +89,7 @@ app/
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- ERROR STATE -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div x-show="error && !loading"
|
||||
<div x-show="error && !loading"
|
||||
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
@@ -115,7 +115,7 @@ app/
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label class="block text-sm">
|
||||
@@ -131,7 +131,7 @@ app/
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Sort -->
|
||||
<div>
|
||||
<label class="block text-sm">
|
||||
@@ -185,15 +185,15 @@ app/
|
||||
<div class="flex items-center text-sm">
|
||||
<div>
|
||||
<p class="font-semibold" x-text="item.name"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400"
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400"
|
||||
x-text="item.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="item.status === 'active'
|
||||
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
|
||||
:class="item.status === 'active'
|
||||
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
|
||||
: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'"
|
||||
x-text="item.status"></span>
|
||||
</td>
|
||||
@@ -229,7 +229,7 @@ app/
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
|
||||
<span class="flex items-center col-span-3">
|
||||
@@ -272,21 +272,21 @@ app/
|
||||
<!-- MODALS (if needed) -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Create/Edit Modal -->
|
||||
<div x-show="showModal"
|
||||
<div x-show="showModal"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
|
||||
@click.self="closeModal()">
|
||||
<div class="relative w-full max-w-lg p-6 mx-auto bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
|
||||
x-text="modalTitle"></h3>
|
||||
<button @click="closeModal()"
|
||||
<button @click="closeModal()"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('x', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal Body -->
|
||||
<form @submit.prevent="saveItem()">
|
||||
<div class="space-y-4">
|
||||
@@ -303,7 +303,7 @@ app/
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex justify-end mt-6 space-x-3">
|
||||
<button
|
||||
@@ -414,7 +414,7 @@ function store[PageName]() {
|
||||
await this.loadData();
|
||||
store[PageName]Log.info('[PageName] page initialized');
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// DATA LOADING
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -453,11 +453,11 @@ function store[PageName]() {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async refresh() {
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
|
||||
updatePagination(response) {
|
||||
this.pagination = {
|
||||
currentPage: response.page || 1,
|
||||
@@ -470,7 +470,7 @@ function store[PageName]() {
|
||||
hasNext: response.page < response.pages
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// FILTERING & PAGINATION
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -478,21 +478,21 @@ function store[PageName]() {
|
||||
this.pagination.currentPage = 1; // Reset to first page
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
|
||||
async previousPage() {
|
||||
if (this.pagination.hasPrevious) {
|
||||
this.pagination.currentPage--;
|
||||
await this.loadData();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async nextPage() {
|
||||
if (this.pagination.hasNext) {
|
||||
this.pagination.currentPage++;
|
||||
await this.loadData();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// CRUD OPERATIONS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -506,12 +506,12 @@ function store[PageName]() {
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
|
||||
async viewItem(id) {
|
||||
// Navigate to detail page or open view modal
|
||||
window.location.href = `/store/${this.storeCode}/[endpoint]/${id}`;
|
||||
},
|
||||
|
||||
|
||||
async editItem(id) {
|
||||
try {
|
||||
// Load item data
|
||||
@@ -577,12 +577,12 @@ function store[PageName]() {
|
||||
alert(error.message || 'Failed to delete item');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.formData = {};
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// UTILITIES
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -595,7 +595,7 @@ function store[PageName]() {
|
||||
day: 'numeric'
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
@@ -1024,7 +1024,7 @@ async startImport() {
|
||||
this.error = 'Please enter a CSV URL';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.importing = true;
|
||||
try {
|
||||
const response = await apiClient.post('/store/marketplace/import', {
|
||||
@@ -1032,7 +1032,7 @@ async startImport() {
|
||||
marketplace: this.importForm.marketplace,
|
||||
batch_size: this.importForm.batch_size
|
||||
});
|
||||
|
||||
|
||||
this.successMessage = `Import job #${response.job_id} started!`;
|
||||
await this.loadJobs(); // Refresh list
|
||||
} catch (error) {
|
||||
@@ -1050,7 +1050,7 @@ startAutoRefresh() {
|
||||
const hasActiveJobs = this.jobs.some(job =>
|
||||
job.status === 'pending' || job.status === 'processing'
|
||||
);
|
||||
|
||||
|
||||
if (hasActiveJobs) {
|
||||
await this.loadJobs();
|
||||
}
|
||||
@@ -1077,7 +1077,7 @@ quickFill(language) {
|
||||
'en': this.storeSettings.letzshop_csv_url_en,
|
||||
'de': this.storeSettings.letzshop_csv_url_de
|
||||
};
|
||||
|
||||
|
||||
if (urlMap[language]) {
|
||||
this.importForm.csv_url = urlMap[language];
|
||||
this.importForm.language = language;
|
||||
@@ -1124,15 +1124,15 @@ formatDate(dateString) {
|
||||
|
||||
calculateDuration(job) {
|
||||
if (!job.started_at) return 'Not started';
|
||||
|
||||
|
||||
const start = new Date(job.started_at);
|
||||
const end = job.completed_at ? new Date(job.completed_at) : new Date();
|
||||
const durationMs = end - start;
|
||||
|
||||
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
@@ -1190,4 +1190,3 @@ calculateDuration(job) {
|
||||
- [Marketplace Integration Guide](../../guides/marketplace-integration.md) - Complete marketplace system documentation
|
||||
- [Admin Page Templates](../admin/page-templates.md) - Admin page patterns
|
||||
- [Icons Guide](../../development/icons-guide.md) - Available icons
|
||||
|
||||
|
||||
@@ -134,8 +134,8 @@ if __name__ == "__main__":
|
||||
curl -X POST "http://localhost:8000/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"username": "newuser",
|
||||
"email": "user@example.com",
|
||||
"username": "newuser",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
@@ -158,7 +158,7 @@ curl -X POST "http://localhost:8000/register" \
|
||||
curl -X POST "http://localhost:8000/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "admin",
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
}'
|
||||
```
|
||||
@@ -202,8 +202,8 @@ curl -X POST "http://localhost:8000/products" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN_HERE" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"marketplace_product_id": "TEST001",
|
||||
"title": "Test MarketplaceProduct",
|
||||
"marketplace_product_id": "TEST001",
|
||||
"title": "Test MarketplaceProduct",
|
||||
"description": "A test product for demonstration",
|
||||
"price": "19.99",
|
||||
"brand": "Test Brand",
|
||||
@@ -267,13 +267,13 @@ Authorization: Bearer <your_jwt_token>
|
||||
def safe_api_call(token, endpoint, method="GET", data=None):
|
||||
"""Make API call with proper error handling"""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
try:
|
||||
if method == "GET":
|
||||
response = requests.get(f"{BASE_URL}{endpoint}", headers=headers)
|
||||
elif method == "POST":
|
||||
response = requests.post(f"{BASE_URL}{endpoint}", json=data, headers=headers)
|
||||
|
||||
|
||||
if response.status_code == 401:
|
||||
print("Authentication failed - token may be expired")
|
||||
return None
|
||||
@@ -285,7 +285,7 @@ def safe_api_call(token, endpoint, method="GET", data=None):
|
||||
else:
|
||||
print(f"API call failed: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Request failed: {e}")
|
||||
return None
|
||||
@@ -340,7 +340,7 @@ class APIClient {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username, password})
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.token = data.access_token;
|
||||
@@ -361,17 +361,17 @@ class APIClient {
|
||||
if (data) config.body = JSON.stringify(data);
|
||||
|
||||
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
||||
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired, redirect to login
|
||||
localStorage.removeItem('jwt_token');
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This authentication system provides secure access control for all API endpoints while maintaining ease of use for developers integrating with the system.
|
||||
This authentication system provides secure access control for all API endpoints while maintaining ease of use for developers integrating with the system.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Analytics Dependency Status: ❌ NOT FIXED ─
|
||||
|
||||
The MetricsProvider pattern exists in contracts/metrics.py, but admin_stores.py still has hard imports:
|
||||
|
||||
|
||||
The MetricsProvider pattern exists in contracts/metrics.py, but admin_stores.py still has hard imports:
|
||||
|
||||
File: app/modules/tenancy/routes/api/admin_stores.py
|
||||
┌──────┬────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────┐
|
||||
│ Line │ Import │ Used In │
|
||||
@@ -914,4 +914,4 @@ Modular Architecture Analysis
|
||||
2. Each module owns its metrics - no cross-module coupling
|
||||
3. Optional modules truly optional - can be removed without breaking app
|
||||
4. Easy to add new metrics - just implement protocol in your module
|
||||
5. Both dashboards supported - store (per-store) and admin (platform-wide)
|
||||
5. Both dashboards supported - store (per-store) and admin (platform-wide)
|
||||
|
||||
Reference in New Issue
Block a user