Compare commits

...

6 Commits

6 changed files with 642 additions and 48 deletions

View File

@@ -6,6 +6,8 @@ import (
"embed"
"encoding/json"
"flag"
"fmt"
"io"
"io/fs"
"log"
"net/http"
@@ -16,6 +18,7 @@ import (
)
// ===== EMBED WEB =====
//
//go:embed web/*
var webFiles embed.FS
@@ -75,8 +78,76 @@ func (a *API) HandleHistory(w http.ResponseWriter, r *http.Request) {
})
}
// ===== PIPE READER =====
func (a *API) HandleStream(w http.ResponseWriter, r *http.Request) {
// Проверяем доступность go2rtc
resp, err := http.Get("http://localhost:1984/api/streams?src=cam")
camAvailable := err == nil && resp.StatusCode == 200
if resp != nil {
resp.Body.Close()
}
writeJSON(w, map[string]interface{}{
"cam": "/api/cam",
"available": camAvailable,
"source": fmt.Sprintf("http://%s:1984/api/stream.mjpeg?src=cam", r.Host),
})
}
// Прокси для MJPEG потока с go2rtc
func handleCamProxy(w http.ResponseWriter, r *http.Request) {
log.Printf("[cam] proxy request from %s", r.RemoteAddr)
resp, err := http.Get(
"http://127.0.0.1:1984/api/stream.mjpeg?src=cam",
)
if err != nil {
log.Printf("[cam] go2rtc unavailable: %v", err)
http.Error(w, "camera unavailable", 503)
return
}
defer resp.Body.Close()
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(resp.StatusCode)
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", 500)
return
}
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, err = w.Write(buf[:n])
if err != nil {
log.Printf("[cam] client disconnected")
return
}
flusher.Flush()
}
if err != nil {
if err != io.EOF {
log.Printf("[cam] stream ended: %v", err)
}
return
}
}
}
// ===== PIPE READER =====
func startPipeReader(ctx context.Context, buf *adapter.RingBuffer, path string) {
go func() {
buffer := make([]byte, 64)
@@ -116,6 +187,14 @@ func startPipeReader(ctx context.Context, buf *adapter.RingBuffer, path string)
func cors(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(200)
return
}
next(w, r)
}
}
@@ -137,7 +216,6 @@ func main() {
// ===== API =====
http.Handle("/api/", http.StripPrefix("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/latest":
cors(api.HandleLatest)(w, r)
@@ -145,6 +223,10 @@ func main() {
cors(api.HandleHistory)(w, r)
case "/health":
cors(api.HandleHealth)(w, r)
case "/stream":
cors(api.HandleStream)(w, r)
case "/cam":
handleCamProxy(w, r)
default:
http.Error(w, "not found", 404)
}
@@ -158,6 +240,7 @@ func main() {
http.Handle("/", http.FileServer(http.FS(webFS)))
log.Println("server :8080")
log.Println("Server started on :8080")
log.Println("Camera proxy available at /api/cam")
log.Fatal(http.ListenAndServe(":8080", nil))
}

View File

@@ -10,10 +10,44 @@ body {
padding: 12px;
}
/* ===== HEADER BAR ===== */
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
gap: 16px;
}
h1 {
text-align: center;
font-size: 22px;
margin: 0 0 15px;
margin: 0;
flex: 1;
}
/* ===== CAMERA BUTTON (теперь в header) ===== */
.cam-btn {
padding: 8px 16px;
background: rgba(0, 0, 0, 0.6);
color: #00ff88;
border: 1px solid #00ff88;
border-radius: 8px;
cursor: pointer;
font-family: monospace;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
flex-shrink: 0;
}
.cam-btn:hover {
background: rgba(0, 255, 136, 0.2);
}
.cam-btn-icon {
font-size: 16px;
}
/* ===== GRID ===== */
@@ -21,6 +55,7 @@ h1 {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
align-items: stretch;
}
@media (min-width: 900px) {
@@ -39,6 +74,10 @@ h1 {
padding: 14px;
background: rgba(0,255,136,0.05);
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
}
.card h3 {
@@ -53,7 +92,6 @@ h1 {
margin-bottom: 8px;
}
/* СТАТУСЫ - ЦВЕТА */
.status-ok {
color: #00ff88 !important;
}
@@ -113,7 +151,6 @@ h1 {
margin-right: 10px;
}
/* ЗАЛИВКА ПРОГРЕСС-БАРА */
.fill {
height: 100%;
width: 0%;
@@ -124,7 +161,6 @@ h1 {
transition: width 0.25s linear;
}
/* Заливка при потере связи */
.fill.no-connection {
background: #555555 !important;
box-shadow: none !important;
@@ -154,13 +190,11 @@ h1 {
.sig-bar-2 { height: 66%; }
.sig-bar-3 { height: 100%; }
/* Неактивные сигнальные бары */
.sig-bar.dim {
background-color: transparent;
border-color: #1a3a1a;
}
/* Активные сигнальные бары */
.sig-bar.active-1,
.sig-bar.active-2,
.sig-bar.active-3 {
@@ -168,7 +202,6 @@ h1 {
border-color: #c46b1f;
}
/* Нет связи - сигнальные бары */
.sig-bar.no-connection {
background-color: #555555 !important;
border-color: #555555 !important;
@@ -224,8 +257,162 @@ h1 {
opacity: 0;
}
/* ===== SPLIT LAYOUT (FIXED) ===== */
.layout {
display: flex;
height: calc(100vh - 100px);
width: 100%;
margin: 0;
padding: 0;
}
/* панели */
.panel {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
/* LEFT */
.left {
flex: 1 1 auto;
min-width: 300px;
padding: 10px;
}
/* RIGHT */
.right {
flex: 0 0 40%;
min-width: 0;
background: black;
position: relative;
overflow: hidden;
}
.right.hidden {
flex: 0 0 0 !important;
width: 0 !important;
min-width: 0 !important;
opacity: 0;
pointer-events: none;
}
/* ===== RESIZER ===== */
.resizer {
width: 8px;
min-width: 8px;
cursor: col-resize;
background: #00ff88;
opacity: 0.3;
flex-shrink: 0;
z-index: 10;
}
.resizer:hover {
opacity: 0.8;
}
.resizer.hidden {
width: 0 !important;
min-width: 0 !important;
opacity: 0;
pointer-events: none;
}
/* камера */
.cam-modal {
height: 100%;
display: flex;
flex-direction: column;
background: black;
}
.cam-header {
display: flex;
justify-content: space-between;
padding: 8px;
background: #111;
color: #00ff88;
}
.cam-frame {
flex: 1;
width: 100%;
object-fit: contain;
background: black;
}
/* ===== CAMERA OVERLAY ===== */
.cam-frame-wrapper {
position: relative;
flex: 1;
width: 100%;
background: black;
overflow: hidden;
}
.cam-frame {
width: 100%;
height: 100%;
object-fit: contain;
background: black;
}
/* Перекрестие (crosshair) */
.cam-crosshair {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
/* Вертикальная линия */
.crosshair-v {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 1px;
height: 100%;
background: #00ff88;
box-shadow: 0 0 4px rgba(0, 255, 136, 0.3);
}
/* Горизонтальная линия */
.crosshair-h {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
width: 100%;
height: 1px;
background: #00ff88;
box-shadow: 0 0 4px rgba(0, 255, 136, 0.3);
}
/* Центральный квадрат */
.crosshair-box {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 120px;
height: 120px;
border: 2px solid #00ff88;
box-shadow:
0 0 10px rgba(0, 255, 136, 0.3),
inset 0 0 10px rgba(0, 255, 136, 0.1);
}
/* ===== MOBILE ===== */
@media (max-width: 500px) {
.header-bar {
flex-direction: column;
gap: 8px;
}
h1 {
font-size: 18px;
}
@@ -248,4 +435,22 @@ h1 {
line-height: 1.5;
max-height: 55vh;
}
.layout {
flex-direction: column;
height: auto;
}
.left {
min-height: 50vh;
}
.right {
flex: 0 0 50vh !important;
min-width: 100% !important;
}
.resizer {
display: none;
}
}

View File

@@ -11,57 +11,94 @@
<body>
<h1>GPIO МОНИТОРИНГ В РЕАЛЬНОМ ВРЕМЕНИ</h1>
<div class="header-bar">
<h1>GPIO МОНИТОРИНГ В РЕАЛЬНОМ ВРЕМЕНИ</h1>
<button id="camToggle" class="cam-btn">
<span class="cam-btn-icon">📷</span>
<span class="cam-btn-text">Камера</span>
</button>
</div>
<div class="grid">
<div class="layout">
<!-- LEFT: графики / дашборд -->
<div class="panel left" id="leftPanel">
<div class="grid">
<div class="card">
<h3>СОСТОЯНИЕ</h3>
<div id="status">...</div>
<div id="meta"></div>
</div>
<div class="card wide">
<h3>СОСТОЯНИЕ</h3>
<div id="status">...</div>
<div id="meta"></div>
</div>
<div class="card wide">
<h3>УРОВНИ КАНАЛОВ</h3>
<div class="card wide">
<h3>УРОВНИ КАНАЛОВ</h3>
<div class="progress-row">
<div class="progress-label">Канал 1 — <span id="last-byte">0</span></div>
<div class="progress-with-signal">
<div class="progress progress-90">
<div class="fill" id="bar1"></div>
<div class="progress-row">
<div class="progress-label">Канал 1 — <span id="last-byte">0</span></div>
<div class="progress-with-signal">
<div class="progress progress-90">
<div class="fill" id="bar1"></div>
</div>
<div class="signal-icon" id="signal-icon" aria-label="Уровень сигнала" role="img">
<div class="sig-bar sig-bar-1"></div>
<div class="sig-bar sig-bar-2"></div>
<div class="sig-bar sig-bar-3"></div>
</div>
</div>
</div>
<div class="signal-icon" id="signal-icon" aria-label="Уровень сигнала" role="img">
<div class="sig-bar sig-bar-1"></div>
<div class="sig-bar sig-bar-2"></div>
<div class="sig-bar sig-bar-3"></div>
<div class="progress-row">
<div class="progress-label">Звук</div>
<div class="progress">
<div class="fill" id="bar2"></div>
</div>
</div>
<div class="progress-row">
<div class="progress-label">Высота</div>
<div class="progress">
<div class="fill" id="bar3"></div>
</div>
</div>
</div>
</div>
<div class="progress-row">
<div class="progress-label">Звук</div>
<div class="progress">
<div class="fill" id="bar2"></div>
<div class="card wide">
<h3>ГРАФИК КАНАЛА 1</h3>
<canvas id="signalChart" height="120"></canvas>
</div>
</div>
<div class="progress-row">
<div class="progress-label">Высота</div>
<div class="progress">
<div class="fill" id="bar3"></div>
<div class="card wide">
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
<div class="accordion-body" id="history-body">
<div id="history"></div>
</div>
</div>
</div>
</div>
<div class="card wide">
<h3>ГРАФИК КАНАЛА 1</h3>
<canvas id="signalChart" height="120"></canvas>
</div>
<!-- DRAG HANDLE -->
<div class="resizer" id="dragBar"></div>
<div class="card wide">
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
<div class="accordion-body" id="history-body">
<div id="history"></div>
<!-- RIGHT: камера -->
<div class="panel right" id="rightPanel">
<div class="cam-modal">
<div class="cam-header">
<span>Видео с камеры</span>
</div>
<div class="cam-frame-wrapper">
<img id="camImage" src="" alt="Camera" class="cam-frame">
<!-- ПЕРЕКРЕСТИЕ -->
<div class="cam-crosshair" id="crosshair">
<!-- Основные линии -->
<div class="crosshair-v"></div>
<div class="crosshair-h"></div>
<!-- Центральный квадрат -->
<div class="crosshair-box"></div>
</div>
</div>
</div>
</div>
@@ -70,4 +107,4 @@
<script type="module" src="/js/app.js"></script>
</body>
</html>
</html>

View File

@@ -12,6 +12,8 @@ import {
import { initAccordion } from "./accordion.js";
import { drawChart } from "./chart.js";
import { initResize } from "./layout.js";
import { initCamera } from "./camera.js";
let lastChart = 0;
let connectionLost = false;
@@ -23,6 +25,29 @@ let pollTimer = null;
window.addEventListener("DOMContentLoaded", () => {
initDOM();
initAccordion();
initResize();
initCamera();
});
document.addEventListener("keydown", (e) => {
switch(e.key.toLowerCase()) {
case 'c':
// Переключение камеры
const camBtn = document.getElementById('camToggle');
if (camBtn) camBtn.click();
break;
case 'f':
// На весь экран для левой панели
if (leftPanel) {
leftPanel.style.width = "100%";
if (rightPanel) rightPanel.style.display = "none";
}
break;
case 'escape':
if (rightPanel) rightPanel.style.display = "flex";
applyRatio(0.6); // 60% левая, 40% правая
break;
}
});
// ================= UPDATE =================

161
cmd/server/web/js/camera.js Normal file
View File

@@ -0,0 +1,161 @@
// camera.js
import { updateResizerVisibility } from './layout.js';
let rightPanel;
let camImage;
let crosshair;
let isOpen = false;
let streamCheckInterval = null;
export function initCamera() {
rightPanel = document.getElementById("rightPanel");
camImage = document.getElementById("camImage");
crosshair = document.getElementById("crosshair");
// Проверяем доступность потока
checkStreamAvailability();
const btn = document.getElementById("camToggle");
const streamUrl = "/api/cam";
function open() {
if (isOpen) return;
isOpen = true;
rightPanel.classList.remove("hidden");
// Показываем перекрестие
if (crosshair) {
crosshair.style.display = "block";
}
camImage.src = streamUrl;
if (btn) {
btn.querySelector('.cam-btn-text').textContent = 'Скрыть';
}
console.log("[camera] opened");
updateResizerVisibility();
// Запускаем мониторинг потока
startStreamMonitoring();
}
function close() {
if (!isOpen) return;
isOpen = false;
// Скрываем перекрестие
if (crosshair) {
crosshair.style.display = "none";
}
// остановка stream
camImage.src = "";
rightPanel.classList.add("hidden");
if (btn) {
btn.querySelector('.cam-btn-text').textContent = 'Камера';
}
console.log("[camera] closed");
updateResizerVisibility();
// Останавливаем мониторинг
stopStreamMonitoring();
}
function toggle() {
if (isOpen) {
close();
} else {
open();
}
}
camImage.onload = () => {
console.log("[camera] stream started");
if (crosshair) {
crosshair.style.opacity = '1';
}
};
camImage.onerror = (e) => {
console.error("[camera] stream error", e);
if (crosshair) {
crosshair.style.opacity = '0.3';
}
};
btn?.addEventListener("click", toggle);
// Проверка доступности камеры
async function checkStreamAvailability() {
try {
const response = await fetch('/api/stream');
const data = await response.json();
if (data.available && data.cam) {
console.log("[camera] stream available:", data.source);
// Автозапуск если камера доступна
if (!isOpen) {
open();
}
} else {
console.log("[camera] stream not available");
if (crosshair) {
crosshair.style.display = 'none';
}
}
} catch (error) {
console.log("[camera] check failed:", error);
}
}
// Мониторинг состояния потока
function startStreamMonitoring() {
stopStreamMonitoring();
streamCheckInterval = setInterval(() => {
if (isOpen && camImage) {
if (!camImage.complete || camImage.naturalWidth === 0) {
if (crosshair) {
crosshair.style.opacity = '0.3';
}
} else {
if (crosshair) {
crosshair.style.opacity = '1';
}
}
}
}, 2000);
}
function stopStreamMonitoring() {
if (streamCheckInterval) {
clearInterval(streamCheckInterval);
streamCheckInterval = null;
}
}
}
// Экспортируем для возможности ручного управления
export function showCrosshair(show = true) {
const crosshair = document.getElementById("crosshair");
if (crosshair) {
crosshair.style.display = show ? "block" : "none";
}
}
export function setCrosshairOpacity(opacity) {
const crosshair = document.getElementById("crosshair");
if (crosshair) {
crosshair.style.opacity = opacity;
}
}

View File

@@ -0,0 +1,83 @@
//layout.js
let leftPanel, rightPanel, dragBar;
let isDragging = false;
let pendingX = null;
export function initResize() {
leftPanel = document.getElementById("leftPanel");
rightPanel = document.getElementById("rightPanel");
dragBar = document.getElementById("dragBar");
if (!leftPanel || !rightPanel || !dragBar) return;
const savedRatio = localStorage.getItem('layoutRatio');
if (savedRatio) {
applyRatio(parseFloat(savedRatio));
}
dragBar.addEventListener("mousedown", (e) => {
if (rightPanel.classList.contains("hidden")) return;
isDragging = true;
document.body.style.cursor = "col-resize";
e.preventDefault();
});
document.addEventListener("mouseup", () => {
if (!isDragging) return;
isDragging = false;
document.body.style.cursor = "default";
if (!rightPanel.classList.contains("hidden")) {
const total = window.innerWidth;
const leftWidth = leftPanel.getBoundingClientRect().width;
localStorage.setItem('layoutRatio', leftWidth / total);
}
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
if (rightPanel.classList.contains("hidden")) return;
pendingX = e.clientX;
requestAnimationFrame(() => {
if (pendingX === null) return;
const total = window.innerWidth;
let leftWidth = pendingX;
const min = 300;
if (leftWidth < min) leftWidth = min;
if (leftWidth > total - min) leftWidth = total - min;
const rightWidth = total - leftWidth - dragBar.offsetWidth;
leftPanel.style.flex = `0 0 ${leftWidth}px`;
rightPanel.style.flex = `0 0 ${rightWidth}px`;
pendingX = null;
});
});
}
function applyRatio(ratio) {
const total = window.innerWidth;
const leftWidth = Math.floor(total * ratio);
const rightWidth = total - leftWidth;
leftPanel.style.flex = `0 0 ${leftWidth}px`;
rightPanel.style.flex = `0 0 ${rightWidth}px`;
}
export function updateResizerVisibility() {
if (!dragBar || !rightPanel) return;
if (rightPanel.classList.contains("hidden")) {
dragBar.classList.add("hidden");
leftPanel.style.flex = "1";
} else {
dragBar.classList.remove("hidden");
}
}