tam
This commit is contained in:
@@ -578,6 +578,7 @@ func (a *App) Logout() {
|
|||||||
blocker.Instance.Stop()
|
blocker.Instance.Stop()
|
||||||
a.disconnectWS()
|
a.disconnectWS()
|
||||||
a.stopScreenshotStream()
|
a.stopScreenshotStream()
|
||||||
|
a.stopWebcamStream()
|
||||||
|
|
||||||
guard.SuppressFor(5 * time.Second)
|
guard.SuppressFor(5 * time.Second)
|
||||||
runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'")
|
runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'")
|
||||||
@@ -1157,7 +1158,15 @@ func (a *App) connectWS() {
|
|||||||
log.Println("[WS] Connected to proctor websocket hub.")
|
log.Println("[WS] Connected to proctor websocket hub.")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer a.disconnectWS()
|
defer func() {
|
||||||
|
a.disconnectWS(conn)
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if a.isMonitoringActive() && a.CheckLoginStatus() {
|
||||||
|
a.connectWS()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}()
|
||||||
for {
|
for {
|
||||||
var msg struct {
|
var msg struct {
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
@@ -1214,15 +1223,28 @@ func (a *App) connectWS() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) disconnectWS() {
|
func (a *App) disconnectWS(expectedConn ...*websocket.Conn) {
|
||||||
a.mu.Lock()
|
var shouldStopStreams bool
|
||||||
defer a.mu.Unlock()
|
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
if len(expectedConn) > 0 && expectedConn[0] != nil {
|
||||||
|
if a.wsConn != expectedConn[0] {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if a.wsConn != nil {
|
if a.wsConn != nil {
|
||||||
_ = a.wsConn.Close()
|
_ = a.wsConn.Close()
|
||||||
a.wsConn = nil
|
a.wsConn = nil
|
||||||
}
|
}
|
||||||
a.wsConnected = false
|
a.wsConnected = false
|
||||||
|
shouldStopStreams = a.isStreamingSc || a.isStreamingCam
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if shouldStopStreams {
|
||||||
|
a.stopScreenshotStream()
|
||||||
|
a.stopWebcamStream()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) startScreenshotStream() {
|
func (a *App) startScreenshotStream() {
|
||||||
|
|||||||
@@ -22,30 +22,49 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const subscribedRef = useRef<Set<number>>(new Set());
|
||||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const studentIdsString = useMemo(
|
const studentIdsString = useMemo(
|
||||||
() => students.map((s) => s.studentRkId).join(','),
|
() => students.map((s) => s.studentRkId).join(','),
|
||||||
[students]
|
[students]
|
||||||
);
|
);
|
||||||
|
const onlineKey = useMemo(() => onlineIds.join(','), [onlineIds]);
|
||||||
|
|
||||||
|
const syncSubscriptions = (ws: WebSocket) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const target = new Set(onlineIds);
|
||||||
|
const prev = subscribedRef.current;
|
||||||
|
|
||||||
|
prev.forEach((id) => {
|
||||||
|
if (!target.has(id)) {
|
||||||
|
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
|
||||||
|
prev.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onlineIds.forEach((id) => {
|
||||||
|
if (!prev.has(id)) {
|
||||||
|
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } }));
|
||||||
|
prev.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (students.length === 0) return;
|
if (students.length === 0) return;
|
||||||
|
|
||||||
const wsUrl = getWsUrl('/ws?role=teacher');
|
const wsUrl = getWsUrl('/ws?role=teacher');
|
||||||
|
let closed = false;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
subscribedRef.current = new Set();
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => syncSubscriptions(ws);
|
||||||
students.forEach((s) => {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
event: 'teacher:subscribe',
|
|
||||||
data: { studentId: s.studentRkId },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -74,21 +93,36 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
subscribedRef.current = new Set();
|
||||||
|
if (!closed) {
|
||||||
|
retryTimer = setTimeout(connect, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
closed = true;
|
||||||
students.forEach((s) => {
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
ws.send(
|
const ws = wsRef.current;
|
||||||
JSON.stringify({
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
event: 'teacher:unsubscribe',
|
subscribedRef.current.forEach((id) => {
|
||||||
data: { studentId: s.studentRkId },
|
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ws.close();
|
ws?.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
subscribedRef.current = new Set();
|
||||||
};
|
};
|
||||||
}, [studentIdsString]);
|
}, [studentIdsString]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (ws) syncSubscriptions(ws);
|
||||||
|
}, [onlineKey]);
|
||||||
|
|
||||||
// Clean up frames when students are removed or go offline
|
// Clean up frames when students are removed or go offline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setScreenFrames((prev) => {
|
setScreenFrames((prev) => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wsUrl = getWsUrl('/ws?role=teacher');
|
const wsUrl = getWsUrl('/ws?role=teacher');
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
intentionalClose.current = false;
|
intentionalClose.current = false;
|
||||||
hasOpened.current = false;
|
hasOpened.current = false;
|
||||||
@@ -41,9 +42,6 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
setScreenFrame(null);
|
setScreenFrame(null);
|
||||||
setWebcamFrame(null);
|
setWebcamFrame(null);
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
const markStreaming = () => {
|
const markStreaming = () => {
|
||||||
if (!hasFrames.current) {
|
if (!hasFrames.current) {
|
||||||
hasFrames.current = true;
|
hasFrames.current = true;
|
||||||
@@ -52,9 +50,16 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (intentionalClose.current) return;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
hasOpened.current = true;
|
hasOpened.current = true;
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
|
setErrorMessage(null);
|
||||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
|
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,15 +90,23 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
setStreaming(false);
|
setStreaming(false);
|
||||||
if (!hasOpened.current && !hasFrames.current) {
|
if (!hasOpened.current && !hasFrames.current) {
|
||||||
setErrorMessage('Không thể kết nối máy chủ giám sát');
|
setErrorMessage('Không thể kết nối máy chủ giám sát');
|
||||||
|
} else {
|
||||||
|
setErrorMessage('Mất kết nối giám sát — đang thử lại...');
|
||||||
}
|
}
|
||||||
|
retryTimer = setTimeout(connect, 3000);
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
intentionalClose.current = true;
|
intentionalClose.current = true;
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
|
||||||
}
|
}
|
||||||
ws.close();
|
wsRef.current?.close();
|
||||||
|
wsRef.current = null;
|
||||||
};
|
};
|
||||||
}, [studentId]);
|
}, [studentId]);
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/websocket/v2"
|
"github.com/gofiber/websocket/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
internalDb "server/internal/db"
|
internalDb "server/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wsPingInterval = 30 * time.Second
|
||||||
|
wsPongWait = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type SocketMsg struct {
|
type SocketMsg struct {
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
Data map[string]any `json:"data"`
|
Data map[string]any `json:"data"`
|
||||||
@@ -32,6 +38,12 @@ func (c *SocketClient) WriteJSON(v any) error {
|
|||||||
return c.Conn.WriteJSON(v)
|
return c.Conn.WriteJSON(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *SocketClient) WriteRaw(msg []byte) error {
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
return c.Conn.WriteMessage(websocket.TextMessage, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
type WsHub struct {
|
type WsHub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -133,6 +145,11 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
|||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
if c.Role == "student" {
|
if c.Role == "student" {
|
||||||
|
current, exists := h.students[c.StudentID]
|
||||||
|
if !exists || current != c {
|
||||||
|
log.Printf("[WS] Student %d stale disconnect ignored (replaced by newer connection)", c.StudentID)
|
||||||
|
return
|
||||||
|
}
|
||||||
delete(h.students, c.StudentID)
|
delete(h.students, c.StudentID)
|
||||||
log.Printf("[WS] Student %d disconnected", c.StudentID)
|
log.Printf("[WS] Student %d disconnected", c.StudentID)
|
||||||
|
|
||||||
@@ -146,7 +163,6 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete(h.subscribers, c.StudentID)
|
|
||||||
}
|
}
|
||||||
} else if c.Role == "teacher" {
|
} else if c.Role == "teacher" {
|
||||||
delete(h.teachers, c.Addr)
|
delete(h.teachers, c.Addr)
|
||||||
@@ -248,12 +264,15 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
|||||||
// Chuyển tiếp frame ảnh từ Student đến các Teacher đã subscribe
|
// Chuyển tiếp frame ảnh từ Student đến các Teacher đã subscribe
|
||||||
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
|
||||||
|
|
||||||
teachersList, exists := h.subscribers[studentID]
|
teachersList, exists := h.subscribers[studentID]
|
||||||
if !exists || len(teachersList) == 0 {
|
if !exists || len(teachersList) == 0 {
|
||||||
|
h.mu.RUnlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Snapshot subscriber addresses while holding read lock
|
||||||
|
addrs := make([]string, len(teachersList))
|
||||||
|
copy(addrs, teachersList)
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
relayEvent := "teacher:screenshot-stream-frame"
|
relayEvent := "teacher:screenshot-stream-frame"
|
||||||
if event == "webcam_stream_frame" {
|
if event == "webcam_stream_frame" {
|
||||||
@@ -267,10 +286,18 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
|||||||
"imageBuffer": data["imageBuffer"],
|
"imageBuffer": data["imageBuffer"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
msgBytes, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for _, addr := range teachersList {
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for _, addr := range addrs {
|
||||||
if t, found := h.teachers[addr]; found {
|
if t, found := h.teachers[addr]; found {
|
||||||
_ = t.WriteJSON(msg)
|
if err := t.WriteRaw(msgBytes); err != nil {
|
||||||
|
log.Printf("[WS] Relay to teacher %s failed: %v", addr, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,11 +335,37 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
|||||||
c.Close()
|
c.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
c.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
|
c.SetPongHandler(func(string) error {
|
||||||
|
return c.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
|
})
|
||||||
|
|
||||||
|
pingDone := make(chan struct{})
|
||||||
|
defer close(pingDone)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(wsPingInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-pingDone:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
client.writeMu.Lock()
|
||||||
|
err := c.WriteMessage(websocket.PingMessage, nil)
|
||||||
|
client.writeMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
_, msgBytes, err := c.ReadMessage()
|
_, msgBytes, err := c.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
_ = c.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||||
|
|
||||||
var msg SocketMsg
|
var msg SocketMsg
|
||||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user