Add UI sync status indicator with pending badge
- Create htmx-powered partial template for sync status display - Show Online/Offline indicator with color coding (green/red) - Display pending changes count badge when there are unsynced items - Add Sync button to push pending changes (appears only when needed) - Auto-refresh every 30 seconds via htmx polling - Replace JavaScript-based sync indicator with server-rendered partial - Integrate SyncStatusPartial handler with template rendering Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -331,7 +331,10 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
|||||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||||
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
||||||
syncHandler := handlers.NewSyncHandler(local, syncService, db)
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, db, "web/templates")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Setup handler (for reconfiguration)
|
// Setup handler (for reconfiguration)
|
||||||
setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
|
setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
|
||||||
@@ -418,6 +421,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
|||||||
partials := router.Group("/partials")
|
partials := router.Group("/partials")
|
||||||
{
|
{
|
||||||
partials.GET("/components", webHandler.ComponentsPartial)
|
partials.GET("/components", webHandler.ComponentsPartial)
|
||||||
|
partials.GET("/sync-status", syncHandler.SyncStatusPartial)
|
||||||
}
|
}
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -16,15 +18,24 @@ type SyncHandler struct {
|
|||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
syncService *sync.Service
|
syncService *sync.Service
|
||||||
mariaDB *gorm.DB
|
mariaDB *gorm.DB
|
||||||
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSyncHandler creates a new sync handler
|
// NewSyncHandler creates a new sync handler
|
||||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB) *SyncHandler {
|
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB, templatesPath string) (*SyncHandler, error) {
|
||||||
|
// Load sync_status partial template
|
||||||
|
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||||
|
tmpl, err := template.ParseFiles(partialPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &SyncHandler{
|
return &SyncHandler{
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
mariaDB: mariaDB,
|
mariaDB: mariaDB,
|
||||||
}
|
tmpl: tmpl,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncStatusResponse represents the sync status
|
// SyncStatusResponse represents the sync status
|
||||||
@@ -270,3 +281,24 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
|||||||
"changes": changes,
|
"changes": changes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncStatusPartial renders the sync status partial for htmx
|
||||||
|
// GET /partials/sync-status
|
||||||
|
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||||
|
// Check online status
|
||||||
|
isOffline, _ := c.Get("is_offline")
|
||||||
|
|
||||||
|
// Get pending count
|
||||||
|
pendingCount := h.localDB.GetPendingCount()
|
||||||
|
|
||||||
|
data := gin.H{
|
||||||
|
"IsOffline": isOffline.(bool),
|
||||||
|
"PendingCount": pendingCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
|
||||||
|
slog.Error("failed to render sync_status template", "error", err)
|
||||||
|
c.String(http.StatusInternalServerError, "Template error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<!-- Sync Status Indicator -->
|
<!-- Sync Status Indicator (htmx-powered) -->
|
||||||
<div id="sync-indicator" class="flex items-center space-x-2">
|
<div id="sync-status"
|
||||||
|
hx-get="/partials/sync-status"
|
||||||
|
hx-trigger="load, refresh from:body, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
<span class="animate-pulse text-gray-400 text-xs">Загрузка...</span>
|
<span class="animate-pulse text-gray-400 text-xs">Загрузка...</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="db-user" class="text-sm text-gray-600"></span>
|
<span id="db-user" class="text-sm text-gray-600"></span>
|
||||||
@@ -57,72 +60,6 @@
|
|||||||
setTimeout(() => el.innerHTML = '', 3000);
|
setTimeout(() => el.innerHTML = '', 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkSyncStatus() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/sync/status');
|
|
||||||
const data = await resp.json();
|
|
||||||
updateSyncIndicator(data);
|
|
||||||
} catch(e) {
|
|
||||||
console.error('Failed to check sync status:', e);
|
|
||||||
const indicator = document.getElementById('sync-indicator');
|
|
||||||
if (indicator) {
|
|
||||||
indicator.innerHTML = '<span class="w-2 h-2 rounded-full bg-red-500"></span><span class="text-xs text-red-600">Offline</span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSyncIndicator(data) {
|
|
||||||
const indicator = document.getElementById('sync-indicator');
|
|
||||||
if (!indicator) return;
|
|
||||||
|
|
||||||
const statusColor = data.is_online ? 'bg-green-500' : 'bg-red-500';
|
|
||||||
const statusText = data.is_online ? 'Online' : 'Offline';
|
|
||||||
const textColor = data.is_online ? 'text-green-700' : 'text-red-700';
|
|
||||||
|
|
||||||
const needSync = data.need_component_sync || data.need_pricelist_sync;
|
|
||||||
const syncWarning = needSync ? '<span class="text-yellow-600 ml-1" title="Требуется синхронизация">⚠</span>' : '';
|
|
||||||
|
|
||||||
let html = `
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span class="w-2 h-2 rounded-full ${statusColor}" title="${statusText}"></span>
|
|
||||||
<span class="text-xs ${textColor}">${statusText}</span>
|
|
||||||
${syncWarning}
|
|
||||||
${data.is_online ? `
|
|
||||||
<button onclick="syncAll()"
|
|
||||||
class="text-xs px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
|
||||||
title="Синхронизировать все">
|
|
||||||
Sync
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
indicator.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncAll() {
|
|
||||||
const btn = event.target;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/sync/all', { method: 'POST' });
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
showToast(`Синхронизация завершена: компоненты ${data.components_synced}, прайслисты ${data.pricelists_synced}`, 'success');
|
|
||||||
checkSyncStatus();
|
|
||||||
} else {
|
|
||||||
showToast('Ошибка синхронизации: ' + (data.error || 'неизвестная ошибка'), 'error');
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
showToast('Ошибка синхронизации: ' + e.message, 'error');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Sync';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkDbStatus() {
|
async function checkDbStatus() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/db-status');
|
const resp = await fetch('/api/db-status');
|
||||||
@@ -162,9 +99,6 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
checkDbStatus();
|
checkDbStatus();
|
||||||
checkWritePermission();
|
checkWritePermission();
|
||||||
checkSyncStatus();
|
|
||||||
// Auto-refresh sync status every 30 seconds
|
|
||||||
setInterval(checkSyncStatus, 30000);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
37
web/templates/partials/sync_status.html
Normal file
37
web/templates/partials/sync_status.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{{define "sync_status"}}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{{if .IsOffline}}
|
||||||
|
<span class="flex items-center gap-1 text-red-600" title="Offline">
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full"></span>
|
||||||
|
<span class="text-xs">Offline</span>
|
||||||
|
</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="flex items-center gap-1 text-green-600" title="Online">
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||||
|
<span class="text-xs">Online</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if gt .PendingCount 0}}
|
||||||
|
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium">
|
||||||
|
{{.PendingCount}} pending
|
||||||
|
</span>
|
||||||
|
<button hx-post="/api/sync/push"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="
|
||||||
|
if(event.detail.successful) {
|
||||||
|
const resp = JSON.parse(event.detail.xhr.response);
|
||||||
|
if(resp.success) {
|
||||||
|
showToast('Синхронизировано: ' + resp.synced + ' изменений', 'success');
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка: ' + (resp.error || 'неизвестная ошибка'), 'error');
|
||||||
|
}
|
||||||
|
htmx.trigger('#sync-status', 'refresh');
|
||||||
|
}
|
||||||
|
"
|
||||||
|
class="text-blue-600 hover:text-blue-800 text-xs underline cursor-pointer">
|
||||||
|
Sync
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user