Add connection overlay and Gitea CI/CD workflow
Show full-screen overlay with spinner when server is unreachable, with periodic health checks that auto-hide on reconnect. Add Gitea Actions workflow for auto-deploy on release tags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
31
.gitea/workflows/deploy.yml
Normal file
31
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to /opt/wled-controller
|
||||||
|
run: |
|
||||||
|
DEPLOY_DIR=/opt/wled-controller
|
||||||
|
|
||||||
|
# Ensure deploy directory exists
|
||||||
|
mkdir -p "$DEPLOY_DIR/data" "$DEPLOY_DIR/logs" "$DEPLOY_DIR/config"
|
||||||
|
|
||||||
|
# Copy server files to deploy directory
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude 'data/' \
|
||||||
|
--exclude 'logs/' \
|
||||||
|
server/ "$DEPLOY_DIR/"
|
||||||
|
|
||||||
|
# Build and restart
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d --build
|
||||||
@@ -105,6 +105,48 @@ h2 {
|
|||||||
50% { opacity: 0.5; }
|
50% { opacity: 0.5; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Connection lost overlay */
|
||||||
|
.connection-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-overlay-content {
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-overlay-content h2 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-overlay-content p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-spinner-lg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: conn-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes conn-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
/* WLED device health indicator */
|
/* WLED device health indicator */
|
||||||
.health-dot {
|
.health-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { apiKey, setApiKey, refreshInterval } from './core/state.js';
|
|||||||
import { Modal } from './core/modal.js';
|
import { Modal } from './core/modal.js';
|
||||||
|
|
||||||
// Layer 1: api, i18n
|
// Layer 1: api, i18n
|
||||||
import { loadServerInfo, loadDisplays, configureApiKey } from './core/api.js';
|
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.js';
|
||||||
import { t, initLocale, changeLocale } from './core/i18n.js';
|
import { t, initLocale, changeLocale } from './core/i18n.js';
|
||||||
|
|
||||||
// Layer 2: ui
|
// Layer 2: ui
|
||||||
@@ -506,6 +506,7 @@ window.addEventListener('beforeunload', () => {
|
|||||||
if (refreshInterval) {
|
if (refreshInterval) {
|
||||||
clearInterval(refreshInterval);
|
clearInterval(refreshInterval);
|
||||||
}
|
}
|
||||||
|
stopConnectionMonitor();
|
||||||
stopEventsWS();
|
stopEventsWS();
|
||||||
disconnectAllKCWebSockets();
|
disconnectAllKCWebSockets();
|
||||||
disconnectAllLedPreviewWS();
|
disconnectAllLedPreviewWS();
|
||||||
@@ -552,6 +553,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Setup form handler
|
// Setup form handler
|
||||||
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
|
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
|
||||||
|
|
||||||
|
// Always monitor server connection (even before login)
|
||||||
|
loadServerInfo();
|
||||||
|
startConnectionMonitor();
|
||||||
|
|
||||||
// Show modal if no API key is stored
|
// Show modal if no API key is stored
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -563,7 +568,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User is logged in, load data
|
// User is logged in, load data
|
||||||
loadServerInfo();
|
|
||||||
loadDisplays();
|
loadDisplays();
|
||||||
loadTargetsTab();
|
loadTargetsTab();
|
||||||
|
|
||||||
|
|||||||
@@ -120,17 +120,56 @@ export function handle401Error() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _connCheckTimer = null;
|
||||||
|
let _serverOnline = null; // null = unknown, true/false
|
||||||
|
|
||||||
|
function _setConnectionState(online) {
|
||||||
|
const changed = _serverOnline !== online;
|
||||||
|
_serverOnline = online;
|
||||||
|
const banner = document.getElementById('connection-overlay');
|
||||||
|
const badge = document.getElementById('server-status');
|
||||||
|
if (online) {
|
||||||
|
if (banner) banner.style.display = 'none';
|
||||||
|
if (badge) badge.className = 'status-badge online';
|
||||||
|
} else {
|
||||||
|
if (banner) banner.style.display = 'flex';
|
||||||
|
if (badge) badge.className = 'status-badge offline';
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadServerInfo() {
|
export async function loadServerInfo() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/health');
|
const response = await fetch('/health', { signal: AbortSignal.timeout(5000) });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
document.getElementById('version-number').textContent = `v${data.version}`;
|
document.getElementById('version-number').textContent = `v${data.version}`;
|
||||||
document.getElementById('server-status').textContent = '●';
|
document.getElementById('server-status').textContent = '●';
|
||||||
document.getElementById('server-status').className = 'status-badge online';
|
const wasOffline = _serverOnline === false;
|
||||||
|
_setConnectionState(true);
|
||||||
|
if (wasOffline) {
|
||||||
|
// Server came back — reload data
|
||||||
|
window.dispatchEvent(new CustomEvent('server:reconnected'));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load server info:', error);
|
console.error('Failed to load server info:', error);
|
||||||
document.getElementById('server-status').className = 'status-badge offline';
|
_setConnectionState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic health checks. Shows/hides the connection banner.
|
||||||
|
* @param {number} interval - Check interval in ms (default 10s)
|
||||||
|
*/
|
||||||
|
export function startConnectionMonitor(interval = 10000) {
|
||||||
|
stopConnectionMonitor();
|
||||||
|
_connCheckTimer = setInterval(loadServerInfo, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopConnectionMonitor() {
|
||||||
|
if (_connCheckTimer) {
|
||||||
|
clearInterval(_connCheckTimer);
|
||||||
|
_connCheckTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
"app.title": "LED Grab",
|
"app.title": "LED Grab",
|
||||||
"app.version": "Version:",
|
"app.version": "Version:",
|
||||||
"app.api_docs": "API Documentation",
|
"app.api_docs": "API Documentation",
|
||||||
|
"app.connection_lost": "Server unreachable",
|
||||||
|
"app.connection_retrying": "Attempting to reconnect…",
|
||||||
"theme.toggle": "Toggle theme",
|
"theme.toggle": "Toggle theme",
|
||||||
"accent.title": "Accent color",
|
"accent.title": "Accent color",
|
||||||
"accent.custom": "Custom",
|
"accent.custom": "Custom",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
"app.title": "LED Grab",
|
"app.title": "LED Grab",
|
||||||
"app.version": "Версия:",
|
"app.version": "Версия:",
|
||||||
"app.api_docs": "Документация API",
|
"app.api_docs": "Документация API",
|
||||||
|
"app.connection_lost": "Сервер недоступен",
|
||||||
|
"app.connection_retrying": "Попытка переподключения…",
|
||||||
"theme.toggle": "Переключить тему",
|
"theme.toggle": "Переключить тему",
|
||||||
"accent.title": "Цвет акцента",
|
"accent.title": "Цвет акцента",
|
||||||
"accent.custom": "Свой",
|
"accent.custom": "Свой",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
"app.title": "LED Grab",
|
"app.title": "LED Grab",
|
||||||
"app.version": "版本:",
|
"app.version": "版本:",
|
||||||
"app.api_docs": "API 文档",
|
"app.api_docs": "API 文档",
|
||||||
|
"app.connection_lost": "服务器不可达",
|
||||||
|
"app.connection_retrying": "正在尝试重新连接…",
|
||||||
"theme.toggle": "切换主题",
|
"theme.toggle": "切换主题",
|
||||||
"accent.title": "主题色",
|
"accent.title": "主题色",
|
||||||
"accent.custom": "自定义",
|
"accent.custom": "自定义",
|
||||||
|
|||||||
@@ -29,6 +29,13 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body style="visibility: hidden;">
|
<body style="visibility: hidden;">
|
||||||
|
<div id="connection-overlay" class="connection-overlay" style="display:none">
|
||||||
|
<div class="connection-overlay-content">
|
||||||
|
<div class="connection-spinner-lg"></div>
|
||||||
|
<h2 data-i18n="app.connection_lost">Server unreachable</h2>
|
||||||
|
<p data-i18n="app.connection_retrying">Attempting to reconnect…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<header>
|
<header>
|
||||||
<div class="header-title">
|
<div class="header-title">
|
||||||
<span id="server-status" class="status-badge">●</span>
|
<span id="server-status" class="status-badge">●</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user