Files
invyone/frontend/app/(mobile)/mobile/MobileAlarmClient.tsx
T
gbpark 7635412b7b feat: SCADA 시연용 모바일 알람 동반 화면 + Spring WebSocket
- 작업자 폰(/mobile)을 SCADA 데모와 ws 로 연결, 알람 발생 시 풀스크린 푸시
  · v5 솔리드+글로우 톤, 진동/Web Audio 비프/Wake Lock/auto reconnect
  · 시연 안전망: ?test=1 자동 발동, 우상단 hidden 트리거
- backend: com.erp.alarm 신규 패키지 (WebSocketConfig + Handshake + Handler + Controller)
  · JWT 토큰 핸드셰이크 검증, userId 기반 채널 매핑 (멀티 디바이스 지원)
  · spring-boot-starter-websocket 의존성 추가
  · path 를 /api/demo/* 안에 두어 Traefik 라우트 추가 불필요 + 정식 알람과 분리
- SCADA scenario.js 의 emergency 시퀀스(2700ms)에 fetch('/api/demo/alarm/trigger') 배선
  · /scada?worker=<user_id> query 로 target user 지정 (iframe src 로 전달)
- 운영 시연 URL: siflex.invyone.com/mobile (siflex_user) ↔ /scada?worker=siflex_user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:18:12 +09:00

265 lines
9.3 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { TokenManager } from "@/lib/auth/tokenManager";
import "@/styles/mobile-alarm.css";
type Severity = "CRITICAL" | "WARNING" | "INFO";
type AlarmPayload = {
code: string;
severity: Severity;
title: string;
message: string;
location: string;
comp?: string;
ts: number;
};
const MOCK_ALARM: AlarmPayload = {
code: "P-IN-HH",
severity: "CRITICAL",
title: "BW-A1 펌프 과압 / 누설 의심",
message:
"BW-A1 토출 압력이 정상 운전 범위(4.0~5.0 bar)를 초과했습니다.\n현장 점검이 즉시 필요합니다.",
location: "펌프룸 A · BW-A1 (원수 취수펌프 #1)",
comp: "bw-a1",
ts: 0,
};
const RECONNECT_DELAY_MS = 3000;
// Wake Lock API — 표준이지만 일부 브라우저에서 미지원이라 옵셔널 체이닝.
type WakeLockSentinel = { release: () => Promise<void> } & EventTarget;
type WakeLockNavigator = Navigator & {
wakeLock?: { request: (type: "screen") => Promise<WakeLockSentinel> };
};
export function MobileAlarmClient() {
const { user } = useAuth();
const [mode, setMode] = useState<"idle" | "alarm">("idle");
const [alarm, setAlarm] = useState<AlarmPayload | null>(null);
const [clock, setClock] = useState("--:--:--");
const [connected, setConnected] = useState(false);
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
// ─── 시계 ────────────────────────────────────────────────────────
useEffect(() => {
const tick = () => {
const d = new Date();
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
setClock(`${hh}:${mm}:${ss}`);
};
tick();
const t = setInterval(tick, 1000);
return () => clearInterval(t);
}, []);
// ─── 알람음 (Web Audio API 비프, 외부 파일 의존성 없음) ───────────
const playAlarmBeep = useCallback(() => {
try {
let ctx = audioCtxRef.current;
if (!ctx) {
const Ctor = (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext);
ctx = new Ctor();
audioCtxRef.current = ctx;
}
// iOS 등은 사용자 제스처 없이 시작된 ctx 가 suspended 상태일 수 있음
if (ctx.state === "suspended") void ctx.resume();
const now = ctx.currentTime;
// 3회 반복 비프 (650Hz / 0.18s on, 0.12s off)
for (let i = 0; i < 3; i++) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "square";
osc.frequency.value = 650;
const start = now + i * 0.3;
const stop = start + 0.18;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.35, start + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, stop);
osc.connect(gain).connect(ctx.destination);
osc.start(start);
osc.stop(stop + 0.02);
}
} catch {
// 오디오 실패해도 알람 표시는 정상 동작 — silent fail
}
}, []);
// ─── 알람 표시 ───────────────────────────────────────────────────
const showAlarm = useCallback(
(payload: AlarmPayload) => {
setAlarm({ ...payload, ts: payload.ts || Date.now() });
setMode("alarm");
if (typeof navigator !== "undefined" && navigator.vibrate) {
navigator.vibrate([400, 120, 400, 120, 800]);
}
playAlarmBeep();
},
[playAlarmBeep],
);
const ackAlarm = useCallback(() => {
setMode("idle");
setAlarm(null);
if (typeof navigator !== "undefined" && navigator.vibrate) {
navigator.vibrate(0);
}
}, []);
// ─── Wake Lock (화면 잠금 회피) ───────────────────────────────────
useEffect(() => {
const nav = navigator as WakeLockNavigator;
if (!nav.wakeLock) return;
const acquire = async () => {
try {
if (wakeLockRef.current) return;
wakeLockRef.current = await nav.wakeLock!.request("screen");
wakeLockRef.current.addEventListener("release", () => {
wakeLockRef.current = null;
});
} catch {
// 사용자 권한 없거나 미지원 — 무시
}
};
void acquire();
// 탭 복귀 시 lock 이 풀려있을 수 있으므로 재획득
const onVisibility = () => {
if (document.visibilityState === "visible") void acquire();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
void wakeLockRef.current?.release().catch(() => {});
wakeLockRef.current = null;
};
}, []);
// ─── WebSocket 연결 (auto reconnect) ─────────────────────────────
useEffect(() => {
if (typeof window === "undefined") return;
const token = TokenManager.getToken();
if (!token) return;
const proto = window.location.protocol === "https:" ? "wss" : "ws";
// /api/demo/* 는 시연 전용 path. 운영 Traefik 의 PathPrefix(`/api`) 라우트로 backend 까지 도달.
const url = `${proto}://${window.location.host}/api/demo/ws/alarm?token=${encodeURIComponent(token)}`;
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const connect = () => {
if (cancelled) return;
ws = new WebSocket(url);
ws.onopen = () => setConnected(true);
ws.onclose = () => {
setConnected(false);
if (!cancelled) {
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
}
};
ws.onerror = () => {
// close 이벤트가 곧 따라옴 — 거기서 재연결 처리
};
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data?.type === "alarm" && data?.payload) {
showAlarm(data.payload as AlarmPayload);
}
} catch {
// 파싱 실패는 silent
}
};
};
connect();
return () => {
cancelled = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws && ws.readyState <= 1) ws.close();
};
}, [showAlarm]);
// ─── 시연 안전망 — ?test=1 자동 발동 ──────────────────────────────
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
if (params.get("test") === "1") {
const t = setTimeout(() => showAlarm(MOCK_ALARM), 800);
return () => clearTimeout(t);
}
}, [showAlarm]);
return (
<div className={`m-shell ${mode === "alarm" ? "m-shell-alarm" : ""}`}>
<header className="m-head">
<span className="m-brand">🛡 INVYONE EHS</span>
<span className={`m-conn ${connected ? "ok" : "off"}`}>
<span className="m-dot" />
{connected ? "실시간 연결됨" : "오프라인 (재연결 중...)"}
</span>
</header>
{mode === "idle" && (
<main className="m-idle">
<div className="m-glow-orb" />
<div className="m-clock">{clock}</div>
<div className="m-user">{user?.user_name ?? "작업자"}</div>
{user?.dept_name && <div className="m-user-dept">{user.dept_name}</div>}
<div className="m-idle-hint">
<br />
</div>
</main>
)}
{mode === "alarm" && alarm && (
<main className="m-alarm">
<div className="m-alarm-bg" />
<div className="m-alarm-card">
<div className="m-alarm-sev">{alarm.severity}</div>
<div className="m-alarm-icon"></div>
<div className="m-alarm-code">{alarm.code}</div>
<div className="m-alarm-title">{alarm.title}</div>
<div className="m-alarm-loc">📍 {alarm.location}</div>
<div className="m-alarm-msg">{alarm.message}</div>
<div className="m-alarm-actions">
<button className="m-btn-ack" onClick={ackAlarm}>
(ACK)
</button>
<button className="m-btn-skip" onClick={ackAlarm}>
</button>
</div>
<div className="m-alarm-time">
: {new Date(alarm.ts).toLocaleTimeString("ko-KR")}
</div>
</div>
</main>
)}
{/* 시연 안전망 — 우상단 점 클릭으로 mock 알람 트리거 */}
<button
className="m-test-trigger"
onClick={() => (mode === "idle" ? showAlarm(MOCK_ALARM) : ackAlarm())}
aria-label="test trigger"
title="test trigger"
/>
</div>
);
}