diff --git a/Nginx/dev.conf.d/local.conf b/Nginx/dev.conf.d/local.conf index 54e052edc..3b497b3b3 100644 --- a/Nginx/dev.conf.d/local.conf +++ b/Nginx/dev.conf.d/local.conf @@ -54,6 +54,17 @@ server { alias /usr/share/nginx/django_file_storage/; } + location /ws/ { + proxy_pass http://django_server; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 86400; + } + location /sockjs-node { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ec4b67a96..1f9c3fb4d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -60,7 +60,7 @@ services: django: image: "docker.pkg.github.com/frg-fossee/esim-cloud/django:dev" build: ./esim-cloud-backend/ - command: "python3 manage.py runserver 0.0.0.0:8000" + command: "daphne -b 0.0.0.0 -p 8000 esimCloud.asgi:application" ports: - "8000:8000" volumes: @@ -117,7 +117,7 @@ services: # - ./mysql_data:/var/lib/mysql db: - image: postgres + image: postgres:13 volumes: - ./postgres_data:/var/lib/postgresql/data/ env_file: diff --git a/eda-frontend/src/components/SchematicEditor/Header.js b/eda-frontend/src/components/SchematicEditor/Header.js index 7d6c227c9..609615132 100644 --- a/eda-frontend/src/components/SchematicEditor/Header.js +++ b/eda-frontend/src/components/SchematicEditor/Header.js @@ -419,7 +419,7 @@ function Header ({ gridRef }) { {shared === true ? : <> Turn On sharing diff --git a/eda-frontend/src/components/SchematicEditor/Helper/SideBar.js b/eda-frontend/src/components/SchematicEditor/Helper/SideBar.js index 383d8212b..e5ae24e93 100644 --- a/eda-frontend/src/components/SchematicEditor/Helper/SideBar.js +++ b/eda-frontend/src/components/SchematicEditor/Helper/SideBar.js @@ -28,6 +28,9 @@ export function AddComponent (component, imgref) { return null } var funct = function (graph, evt, target, x, y) { + // Viewer mode: editing is disabled — reject component drops silently + if (!graph.isEnabled()) return + var parent = graph.getDefaultParent() var model = graph.getModel() diff --git a/eda-frontend/src/components/SchematicEditor/Helper/ToolbarTools.js b/eda-frontend/src/components/SchematicEditor/Helper/ToolbarTools.js index 7b7da98da..629df2b1b 100644 --- a/eda-frontend/src/components/SchematicEditor/Helper/ToolbarTools.js +++ b/eda-frontend/src/components/SchematicEditor/Helper/ToolbarTools.js @@ -1063,6 +1063,42 @@ export function renderGalleryXML(xml) { var xmlDoc = mxUtils.parseXml(xml) parseXmlToGraph(xmlDoc, graph) } + +/** + * Register a callback that fires whenever the graph model changes. + * Returns an unregister function — call it on cleanup to avoid leaks. + * Returns a no-op if called before the graph is initialised. + * + * Used by Phase 2 live sync to debounce-and-broadcast XML snapshots. + * Phase 3 incremental events can register additional listeners the same way. + */ +export function registerChangeListener(callback) { + if (!graph) return function () {} + var listener = function () { callback() } + graph.getModel().addListener(mxEvent.CHANGE, listener) + return function unregister() { + graph.getModel().removeListener(listener) + } +} + +/** + * Enable or disable viewer mode on the graph. + * + * Viewer mode (isViewer = true): + * - Disables cell selection, moving, connecting, and deleting + * - Keeps panning active so the viewer can navigate + * - Zoom toolbar buttons continue to work (they call graph.zoomIn/Out directly) + * - Keyboard shortcuts that call graph.isEnabled() are blocked automatically + * + * Editor mode (isViewer = false): restores full editing capability. + */ +export function setViewerMode(isViewer) { + if (!graph) return + graph.setEnabled(!isViewer) + if (isViewer) { + graph.setPanning(true) + } +} // Certain Variables need to be Defined before Saving the Circuit, XML Wire Connections does that function XMLWireConnections() { var erc = true diff --git a/eda-frontend/src/components/SchematicEditor/PresencePanel.js b/eda-frontend/src/components/SchematicEditor/PresencePanel.js new file mode 100644 index 000000000..b37638c27 --- /dev/null +++ b/eda-frontend/src/components/SchematicEditor/PresencePanel.js @@ -0,0 +1,183 @@ +import React, { useEffect } from 'react' +import { + Chip, + Collapse, + Divider, + IconButton, + List, + ListItem, + ListItemIcon, + ListItemText, + Snackbar, + Tooltip, + Typography +} from '@material-ui/core' +import ExpandLessIcon from '@material-ui/icons/ExpandLess' +import ExpandMoreIcon from '@material-ui/icons/ExpandMore' +import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord' +import PersonIcon from '@material-ui/icons/Person' +import CloseIcon from '@material-ui/icons/Close' +import { makeStyles } from '@material-ui/core/styles' +import { useSelector, useDispatch } from 'react-redux' +import { presenceClearNotification } from '../../redux/actions/collaborationActions' +import { useIsEditor } from '../../utils/useIsEditor' + +const NOTIFICATION_DURATION_MS = 4000 + +const useStyles = makeStyles((theme) => ({ + root: { + borderTop: `1px solid ${theme.palette.divider}`, + marginTop: theme.spacing(1) + }, + header: { + display: 'flex', + alignItems: 'center', + padding: theme.spacing(0.5, 1), + cursor: 'pointer', + userSelect: 'none', + '&:hover': { + backgroundColor: theme.palette.action.hover + } + }, + dot: { + fontSize: 10, + marginRight: theme.spacing(0.75), + flexShrink: 0 + }, + dotOnline: { color: '#52c41a' }, + dotOffline: { color: '#bfbfbf' }, + countText: { + fontWeight: 600, + fontSize: '0.82rem', + flexGrow: 1 + }, + userItem: { + paddingTop: 2, + paddingBottom: 2, + paddingLeft: theme.spacing(3) + }, + userIcon: { + minWidth: 28, + color: theme.palette.text.secondary + }, + userName: { + fontSize: '0.80rem' + }, + guestName: { + fontSize: '0.80rem', + color: theme.palette.text.disabled, + fontStyle: 'italic' + }, + emptyText: { + padding: theme.spacing(0.5, 3), + fontSize: '0.78rem', + color: theme.palette.text.disabled, + fontStyle: 'italic' + }, + roleChip: { + height: 18, + fontSize: '0.68rem', + marginLeft: theme.spacing(0.75) + } +})) + +export default function PresencePanel () { + const classes = useStyles() + const dispatch = useDispatch() + const { connected, users, notification } = useSelector(state => state.presenceReducer) + const saveId = useSelector(state => state.saveSchematicReducer.details.save_id) + const isEditor = useIsEditor() + + const [expanded, setExpanded] = React.useState(true) + + // Auto-dismiss notifications + useEffect(() => { + if (!notification) return + const timer = setTimeout(() => { + dispatch(presenceClearNotification()) + }, NOTIFICATION_DURATION_MS) + return () => clearTimeout(timer) + }, [notification, dispatch]) + + // Don't render until a schematic is saved/loaded (no room to join) + if (!saveId) return null + + const onlineCount = users.length + const statusLabel = connected + ? `${onlineCount} Online` + : 'Connecting...' + + const notificationMessage = notification + ? notification.type === 'joined' + ? `${notification.username} joined` + : `${notification.username} left` + : '' + + return ( +
+
setExpanded(prev => !prev)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && setExpanded(prev => !prev)} + > + + + + {statusLabel} + {saveId && ( + + )} + {expanded ? : } +
+ + + + {users.length === 0 ? ( + No viewers + ) : ( + users.map((u, idx) => ( + + + + + + + )) + )} + + + + + + {/* Join / leave toast */} + dispatch(presenceClearNotification())} + > + + + } + /> +
+ ) +} diff --git a/eda-frontend/src/pages/SchematiEditor.js b/eda-frontend/src/pages/SchematiEditor.js index 464d8118f..493cd4f74 100644 --- a/eda-frontend/src/pages/SchematiEditor.js +++ b/eda-frontend/src/pages/SchematiEditor.js @@ -13,9 +13,13 @@ import RightSidebar from '../components/SchematicEditor/RightSidebar' import PropertiesSidebar from '../components/SchematicEditor/PropertiesSidebar' import LoadGrid from '../components/SchematicEditor/Helper/ComponentDrag.js' import ComponentProperties from '../components/SchematicEditor/ComponentProperties' +import PresencePanel from '../components/SchematicEditor/PresencePanel' import '../components/SchematicEditor/Helper/SchematicEditor.css' import { fetchSchematic, fetchGallerySchematic } from '../redux/actions/index' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' +import { usePresence } from '../utils/usePresence' +import { useCircuitSync } from '../utils/useCircuitSync' +import { useIsEditor } from '../utils/useIsEditor' const useStyles = makeStyles((theme) => ({ root: { @@ -36,6 +40,12 @@ export default function SchematiEditor (props) { const [mobileOpen, setMobileOpen] = React.useState(false) const [ltiSimResult, setLtiSimResult] = React.useState(false) + const saveId = useSelector(state => state.saveSchematicReducer.details.save_id) + const isEditor = useIsEditor() + + usePresence(saveId) + useCircuitSync(saveId, isEditor) + const handleDrawerToggle = () => { setMobileOpen(!mobileOpen) } @@ -95,6 +105,7 @@ export default function SchematiEditor (props) { {/* Schematic editor Right side pane */} + diff --git a/eda-frontend/src/redux/actions/actions.js b/eda-frontend/src/redux/actions/actions.js index 25f09b8d8..605d25001 100644 --- a/eda-frontend/src/redux/actions/actions.js +++ b/eda-frontend/src/redux/actions/actions.js @@ -76,3 +76,11 @@ export const FETCH_REPORTS = 'FETCH_REPORTS' export const RESOLVE_REPORTS = 'RESOLVE_REPORTS' export const GET_STATES = 'GET_STATES' export const SET_STATE = 'SET_STATE' + +// Collaboration — Presence +export const PRESENCE_CONNECTED = 'PRESENCE_CONNECTED' +export const PRESENCE_DISCONNECTED = 'PRESENCE_DISCONNECTED' +export const PRESENCE_LIST_UPDATED = 'PRESENCE_LIST_UPDATED' +export const PRESENCE_USER_JOINED = 'PRESENCE_USER_JOINED' +export const PRESENCE_USER_LEFT = 'PRESENCE_USER_LEFT' +export const PRESENCE_CLEAR_NOTIFICATION = 'PRESENCE_CLEAR_NOTIFICATION' diff --git a/eda-frontend/src/redux/actions/collaborationActions.js b/eda-frontend/src/redux/actions/collaborationActions.js new file mode 100644 index 000000000..66f5c4a6a --- /dev/null +++ b/eda-frontend/src/redux/actions/collaborationActions.js @@ -0,0 +1,28 @@ +import * as actions from './actions' + +export const presenceConnected = () => ({ + type: actions.PRESENCE_CONNECTED +}) + +export const presenceDisconnected = () => ({ + type: actions.PRESENCE_DISCONNECTED +}) + +export const presenceListUpdated = (users) => ({ + type: actions.PRESENCE_LIST_UPDATED, + payload: { users } +}) + +export const presenceUserJoined = (user, users) => ({ + type: actions.PRESENCE_USER_JOINED, + payload: { user, users } +}) + +export const presenceUserLeft = (user, users) => ({ + type: actions.PRESENCE_USER_LEFT, + payload: { user, users } +}) + +export const presenceClearNotification = () => ({ + type: actions.PRESENCE_CLEAR_NOTIFICATION +}) diff --git a/eda-frontend/src/redux/reducers/index.js b/eda-frontend/src/redux/reducers/index.js index 3338cca02..6b6e02ef7 100644 --- a/eda-frontend/src/redux/reducers/index.js +++ b/eda-frontend/src/redux/reducers/index.js @@ -9,6 +9,7 @@ import dashboardReducer from './dashboardReducer' import accountReducer from './accountReducer' import projectReducer from './projectReducer' import galleryReducer from './galleryReducer' +import presenceReducer from './presenceReducer' export default combineReducers({ schematicEditorReducer, componentPropertiesReducer, @@ -19,5 +20,6 @@ export default combineReducers({ dashboardReducer, accountReducer, projectReducer, - galleryReducer + galleryReducer, + presenceReducer }) diff --git a/eda-frontend/src/redux/reducers/presenceReducer.js b/eda-frontend/src/redux/reducers/presenceReducer.js new file mode 100644 index 000000000..599476451 --- /dev/null +++ b/eda-frontend/src/redux/reducers/presenceReducer.js @@ -0,0 +1,40 @@ +import * as actions from '../actions/actions' + +const initialState = { + connected: false, + users: [], + notification: null // { type: 'joined' | 'left', username: string } +} + +export default function presenceReducer (state = initialState, action) { + switch (action.type) { + case actions.PRESENCE_CONNECTED: + return { ...state, connected: true } + + case actions.PRESENCE_DISCONNECTED: + return { ...state, connected: false, users: [] } + + case actions.PRESENCE_LIST_UPDATED: + return { ...state, users: action.payload.users } + + case actions.PRESENCE_USER_JOINED: + return { + ...state, + users: action.payload.users, + notification: { type: 'joined', username: action.payload.user.username } + } + + case actions.PRESENCE_USER_LEFT: + return { + ...state, + users: action.payload.users, + notification: { type: 'left', username: action.payload.user.username } + } + + case actions.PRESENCE_CLEAR_NOTIFICATION: + return { ...state, notification: null } + + default: + return state + } +} diff --git a/eda-frontend/src/utils/useCircuitSync.js b/eda-frontend/src/utils/useCircuitSync.js new file mode 100644 index 000000000..dfcfc17fa --- /dev/null +++ b/eda-frontend/src/utils/useCircuitSync.js @@ -0,0 +1,176 @@ +import { useEffect, useRef } from 'react' +import { useSelector } from 'react-redux' +import { + Save, + renderGalleryXML, + registerChangeListener, + setViewerMode +} from '../components/SchematicEditor/Helper/ToolbarTools' + +// ─── tuneable constants ──────────────────────────────────────────────────────── + +// How long after the last graph change before broadcasting. +// Short enough to feel live; long enough to avoid flooding during drags. +const DEBOUNCE_MS = 300 + +const PING_INTERVAL_MS = 25000 // keepalive — same as usePresence +const RECONNECT_BASE_MS = 2000 +const RECONNECT_MAX_MS = 30000 + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function buildSyncWsUrl (saveId, token) { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const base = `${proto}://${window.location.host}/ws/sync/${saveId}/` + return token ? `${base}?token=${encodeURIComponent(token)}` : base +} + +// ─── hook ───────────────────────────────────────────────────────────────────── + +/** + * Phase 2 — Live Shared Viewing. + * + * Editor (isEditor = true): + * • Listens to mxGraph model changes via registerChangeListener. + * • Debounces at DEBOUNCE_MS, then serialises the graph with Save() and + * sends { type: "PROJECT_SNAPSHOT", xml } over the sync WebSocket. + * • Enables graph editing (setViewerMode(false)). + * + * Viewer (isEditor = false): + * • Receives PROJECT_SNAPSHOT messages and calls renderGalleryXML(xml). + * View transform (zoom / pan) is preserved across redraws — mxGraph does + * not reset graph.view.scale or graph.view.translate on removeCells. + * • Disables graph editing (setViewerMode(true)) while keeping pan active. + * + * Failure model: if the WebSocket cannot connect or drops, editing and + * viewing continue without sync — all existing functionality is unaffected. + * Reconnection uses exponential back-off up to RECONNECT_MAX_MS. + * + * Phase 3 note: incremental events (COMPONENT_ADDED, COMPONENT_MOVED, …) will + * be dispatched via the same /ws/sync// channel and handled in the + * onmessage switch below without any structural changes to this hook. + */ +export function useCircuitSync (saveId, isEditor) { + const token = useSelector((state) => state.authReducer.token) + + // Refs hold mutable state that must not trigger re-renders + const wsRef = useRef(null) + const pingRef = useRef(null) + const reconnectRef = useRef(null) + const debounceRef = useRef(null) + const delayRef = useRef(RECONNECT_BASE_MS) + const activeRef = useRef(false) // false → cleanup running, suppress reconnect + const unregRef = useRef(null) // cleanup fn returned by registerChangeListener + + useEffect(() => { + if (!saveId) return + + activeRef.current = true + + // Apply role immediately — graph must be initialised (LoadGrid must have run) + setViewerMode(!isEditor) + + // ── WebSocket teardown ──────────────────────────────────────────────────── + function teardownWs () { + clearInterval(pingRef.current) + clearTimeout(reconnectRef.current) + clearTimeout(debounceRef.current) + + if (unregRef.current) { + unregRef.current() + unregRef.current = null + } + + if (wsRef.current) { + wsRef.current.onopen = null + wsRef.current.onmessage = null + wsRef.current.onerror = null + wsRef.current.onclose = null + if (wsRef.current.readyState < WebSocket.CLOSING) { + wsRef.current.close() + } + wsRef.current = null + } + } + + // ── WebSocket connect ───────────────────────────────────────────────────── + function connect () { + if (!activeRef.current) return + teardownWs() + + const socket = new WebSocket(buildSyncWsUrl(saveId, token)) + wsRef.current = socket + + socket.onopen = () => { + if (!activeRef.current) { socket.close(); return } + delayRef.current = RECONNECT_BASE_MS + + if (isEditor) { + // Register change listener — fires on every model mutation + unregRef.current = registerChangeListener(() => { + clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + const xml = Save() + wsRef.current.send(JSON.stringify({ type: 'PROJECT_SNAPSHOT', xml })) + } + }, DEBOUNCE_MS) + }) + } + + pingRef.current = setInterval(() => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'PING' })) + } + }, PING_INTERVAL_MS) + } + + socket.onmessage = (evt) => { + let msg + try { msg = JSON.parse(evt.data) } catch (_) { return } + + switch (msg.type) { + case 'PROJECT_SNAPSHOT': + // Only viewers apply incoming snapshots — editors ignore their own echo + if (!isEditor) { + renderGalleryXML(msg.xml) + } + break + + // Phase 3 incremental events (COMPONENT_ADDED, COMPONENT_MOVED, etc.) + // will be handled here with additional case branches. + + default: + break // PONG and unknown types ignored + } + } + + socket.onerror = () => { + // onclose always fires after onerror; reconnect logic lives there + } + + socket.onclose = () => { + clearInterval(pingRef.current) + if (unregRef.current) { + unregRef.current() + unregRef.current = null + } + if (!activeRef.current) return + reconnectRef.current = setTimeout(() => { + delayRef.current = Math.min(delayRef.current * 2, RECONNECT_MAX_MS) + connect() + }, delayRef.current) + } + } + + connect() + + return () => { + activeRef.current = false + teardownWs() + // Restore full editor mode on unmount so a remount starts clean + setViewerMode(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [saveId, isEditor, token]) +} diff --git a/eda-frontend/src/utils/useCircuitSync.js.tmp.962.34f1636168a0 b/eda-frontend/src/utils/useCircuitSync.js.tmp.962.34f1636168a0 new file mode 100644 index 000000000..732d135ff --- /dev/null +++ b/eda-frontend/src/utils/useCircuitSync.js.tmp.962.34f1636168a0 @@ -0,0 +1,176 @@ +import { useEffect, useRef } from 'react' +import { useSelector } from 'react-redux' +import { + Save, + renderGalleryXML, + registerChangeListener, + setViewerMode +} from '../components/SchematicEditor/Helper/ToolbarTools' + +// ─── tuneable constants ──────────────────────────────────────────────────────── + +// How long after the last graph change before broadcasting. +// Short enough to feel live; long enough to avoid flooding during drags. +const DEBOUNCE_MS = 300 + +const PING_INTERVAL_MS = 25000 // keepalive — same as usePresence +const RECONNECT_BASE_MS = 2000 +const RECONNECT_MAX_MS = 30000 + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function buildSyncWsUrl (saveId, token) { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const base = `${proto}://${window.location.host}/ws/sync/${saveId}/` + return token ? `${base}?token=${encodeURIComponent(token)}` : base +} + +// ─── hook ───────────────────────────────────────────────────────────────────── + +/** + * Phase 2 — Live Shared Viewing. + * + * Editor (isEditor = true): + * • Listens to mxGraph model changes via registerChangeListener. + * • Debounces at DEBOUNCE_MS, then serialises the graph with Save() and + * sends { type: "PROJECT_SNAPSHOT", xml } over the sync WebSocket. + * • Enables graph editing (setViewerMode(false)). + * + * Viewer (isEditor = false): + * • Receives PROJECT_SNAPSHOT messages and calls renderGalleryXML(xml). + * View transform (zoom / pan) is preserved across redraws — mxGraph does + * not reset graph.view.scale or graph.view.translate on removeCells. + * • Disables graph editing (setViewerMode(true)) while keeping pan active. + * + * Failure model: if the WebSocket cannot connect or drops, editing and + * viewing continue without sync — all existing functionality is unaffected. + * Reconnection uses exponential back-off up to RECONNECT_MAX_MS. + * + * Phase 3 note: incremental events (COMPONENT_ADDED, COMPONENT_MOVED, …) will + * be dispatched via the same /ws/sync// channel and handled in the + * onmessage switch below without any structural changes to this hook. + */ +export function useCircuitSync (saveId, isEditor) { + const token = useSelector((state) => state.authReducer.token) + + // Refs hold mutable state that must not trigger re-renders + const wsRef = useRef(null) + const pingRef = useRef(null) + const reconnectRef = useRef(null) + const debounceRef = useRef(null) + const delayRef = useRef(RECONNECT_BASE_MS) + const activeRef = useRef(false) // false → cleanup running, suppress reconnect + const unregRef = useRef(null) // cleanup fn returned by registerChangeListener + + useEffect(() => { + if (!saveId) return + + activeRef.current = true + + // Apply role immediately — graph must be initialised (LoadGrid must have run) + setViewerMode(!isEditor) + + // ── WebSocket teardown ──────────────────────────────────────────────────── + function teardownWs () { + clearInterval(pingRef.current) + clearTimeout(reconnectRef.current) + clearTimeout(debounceRef.current) + + if (unregRef.current) { + unregRef.current() + unregRef.current = null + } + + if (wsRef.current) { + wsRef.current.onopen = null + wsRef.current.onmessage = null + wsRef.current.onerror = null + wsRef.current.onclose = null + if (wsRef.current.readyState < WebSocket.CLOSING) { + wsRef.current.close() + } + wsRef.current = null + } + } + + // ── WebSocket connect ───────────────────────────────────────────────────── + function connect () { + if (!activeRef.current) return + teardownWs() + + const socket = new WebSocket(buildSyncWsUrl(saveId, token)) + wsRef.current = socket + + socket.onopen = () => { + if (!activeRef.current) { socket.close(); return } + delayRef.current = RECONNECT_BASE_MS + + if (isEditor) { + // Register change listener — fires on every model mutation + unregRef.current = registerChangeListener(() => { + clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + const xml = Save() + wsRef.current.send(JSON.stringify({ type: 'PROJECT_SNAPSHOT', xml })) + } + }, DEBOUNCE_MS) + }) + } + + pingRef.current = setInterval(() => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'PING' })) + } + }, PING_INTERVAL_MS) + } + + socket.onmessage = (evt) => { + let msg + try { msg = JSON.parse(evt.data) } catch (_) { return } + + switch (msg.type) { + case 'PROJECT_SNAPSHOT': + // Only viewers apply incoming snapshots — editors ignore their own echo + if (!isEditor) { + renderGalleryXML(msg.xml) + } + break + + // Phase 3 incremental events (COMPONENT_ADDED, COMPONENT_MOVED, etc.) + // will be handled here with additional case branches. + + default: + break // PONG and unknown types ignored + } + } + + socket.onerror = () => { + // onclose always fires after onerror; reconnect logic lives there + } + + socket.onclose = () => { + clearInterval(pingRef.current) + if (unregRef.current) { + unregRef.current() + unregRef.current = null + } + if (!activeRef.current) return + reconnectRef.current = setTimeout(() => { + delayRef.current = Math.min(delayRef.current * 2, RECONNECT_MAX_MS) + connect() + }, delayRef.current) + } + } + + connect() + + return () => { + activeRef.current = false + teardownWs() + // Restore full editor mode on unmount so a remount starts clean + setViewerMode(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [saveId, isEditor, token]) +} diff --git a/eda-frontend/src/utils/useIsEditor.js b/eda-frontend/src/utils/useIsEditor.js new file mode 100644 index 000000000..ba6f2dee7 --- /dev/null +++ b/eda-frontend/src/utils/useIsEditor.js @@ -0,0 +1,25 @@ +import { useSelector } from 'react-redux' + +/** + * Returns true when the authenticated user is the owner of the currently + * loaded schematic — i.e. they have editor privileges in a collaboration room. + * + * The API returns `owner` as a username string (views.py replaces the integer + * PK with owner_name.username before responding), so we compare by username. + * + * Returns false for: + * - anonymous users + * - authenticated users who are not the owner + * - any session where no schematic is loaded (saveId absent) + */ +export function useIsEditor () { + const saveId = useSelector(state => state.saveSchematicReducer.details.save_id) + const owner = useSelector(state => state.saveSchematicReducer.details.owner) + const auth = useSelector(state => state.authReducer) + return !!( + auth.isAuthenticated && + auth.user && + saveId && + owner === auth.user.username + ) +} diff --git a/eda-frontend/src/utils/usePresence.js b/eda-frontend/src/utils/usePresence.js new file mode 100644 index 000000000..50f077da1 --- /dev/null +++ b/eda-frontend/src/utils/usePresence.js @@ -0,0 +1,114 @@ +import { useEffect, useRef } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + presenceConnected, + presenceDisconnected, + presenceUserJoined, + presenceUserLeft, + presenceListUpdated +} from '../redux/actions/collaborationActions' + +const PING_INTERVAL_MS = 25000 // keepalive: server drops idle WS after 30 s on some proxies +const RECONNECT_BASE_MS = 2000 +const RECONNECT_MAX_MS = 30000 + +function buildWsUrl (saveId, token) { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const base = `${proto}://${window.location.host}/ws/presence/${saveId}/` + return token ? `${base}?token=${encodeURIComponent(token)}` : base +} + +/** + * Manages the WebSocket lifecycle for presence in a project room. + * Call inside the schematic editor page when a save_id is known. + * Reconnects automatically with exponential back-off. + * Safe to call with a null/undefined saveId — does nothing until one is provided. + */ +export function usePresence (saveId) { + const dispatch = useDispatch() + const token = useSelector(state => state.authReducer.token) + + const wsRef = useRef(null) + const pingRef = useRef(null) + const reconnectRef = useRef(null) + const delayRef = useRef(RECONNECT_BASE_MS) + const activeRef = useRef(false) // becomes false on cleanup → suppresses reconnect + + useEffect(() => { + if (!saveId) return + + activeRef.current = true + + function teardown () { + clearInterval(pingRef.current) + clearTimeout(reconnectRef.current) + if (wsRef.current) { + // Null handlers before close to prevent stale callbacks firing + wsRef.current.onopen = null + wsRef.current.onmessage = null + wsRef.current.onerror = null + wsRef.current.onclose = null + if (wsRef.current.readyState < WebSocket.CLOSING) { + wsRef.current.close() + } + wsRef.current = null + } + } + + function connect () { + if (!activeRef.current) return + teardown() + + const socket = new WebSocket(buildWsUrl(saveId, token)) + wsRef.current = socket + + socket.onopen = () => { + if (!activeRef.current) { socket.close(); return } + dispatch(presenceConnected()) + delayRef.current = RECONNECT_BASE_MS + pingRef.current = setInterval(() => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'PING' })) + } + }, PING_INTERVAL_MS) + } + + socket.onmessage = (evt) => { + let msg + try { msg = JSON.parse(evt.data) } catch (_) { return } + switch (msg.type) { + case 'USER_JOINED': + dispatch(presenceUserJoined(msg.user, msg.users)); break + case 'USER_LEFT': + dispatch(presenceUserLeft(msg.user, msg.users)); break + case 'PRESENCE_LIST': + dispatch(presenceListUpdated(msg.users)); break + default: break // PONG — no action needed + } + } + + socket.onerror = () => { + // onclose always fires after onerror; let onclose handle reconnect + } + + socket.onclose = () => { + clearInterval(pingRef.current) + dispatch(presenceDisconnected()) + if (!activeRef.current) return + reconnectRef.current = setTimeout(() => { + delayRef.current = Math.min(delayRef.current * 2, RECONNECT_MAX_MS) + connect() + }, delayRef.current) + } + } + + connect() + + return () => { + activeRef.current = false + teardown() + dispatch(presenceDisconnected()) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [saveId, token]) +} diff --git a/esim-cloud-backend/Dockerfile.daphne b/esim-cloud-backend/Dockerfile.daphne new file mode 100644 index 000000000..a789c2a7e --- /dev/null +++ b/esim-cloud-backend/Dockerfile.daphne @@ -0,0 +1,2 @@ +FROM docker.pkg.github.com/frg-fossee/esim-cloud/django:dev +RUN pip install daphne==3.0.2 channels==3.0.5 channels-redis==3.4.1 diff --git a/esim-cloud-backend/collaborationAPI/__init__.py b/esim-cloud-backend/collaborationAPI/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/esim-cloud-backend/collaborationAPI/apps.py b/esim-cloud-backend/collaborationAPI/apps.py new file mode 100644 index 000000000..51990ea84 --- /dev/null +++ b/esim-cloud-backend/collaborationAPI/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CollaborationConfig(AppConfig): + name = 'collaborationAPI' + verbose_name = 'Collaboration' diff --git a/esim-cloud-backend/collaborationAPI/auth.py b/esim-cloud-backend/collaborationAPI/auth.py new file mode 100644 index 000000000..85b83667e --- /dev/null +++ b/esim-cloud-backend/collaborationAPI/auth.py @@ -0,0 +1,17 @@ +from django.contrib.auth.models import AnonymousUser + + +def get_user_from_token(token_key): + """Resolve a DRF auth token string to a User instance. + + Returns AnonymousUser when the token is absent or invalid so the + consumer can handle authenticated and anonymous viewers uniformly. + """ + if not token_key: + return AnonymousUser() + try: + from rest_framework.authtoken.models import Token + token = Token.objects.select_related('user').get(key=token_key) + return token.user + except Exception: + return AnonymousUser() diff --git a/esim-cloud-backend/collaborationAPI/consumers.py b/esim-cloud-backend/collaborationAPI/consumers.py new file mode 100644 index 000000000..478dff95b --- /dev/null +++ b/esim-cloud-backend/collaborationAPI/consumers.py @@ -0,0 +1,211 @@ +import json +import logging +import urllib.parse + +from asgiref.sync import sync_to_async +from channels.generic.websocket import AsyncWebsocketConsumer + +from .auth import get_user_from_token +from .presence import PresenceStore + +logger = logging.getLogger(__name__) + + +class PresenceConsumer(AsyncWebsocketConsumer): + """ + Tracks which users are currently viewing a specific project. + + URL route: /ws/presence// + Query param: token= (omit for anonymous) + + Channel group: presence__ + All consumers in the group receive presence updates and forward them + to their respective WebSocket clients. + + Protocol (server → client): + { "type": "USER_JOINED", "user": {...}, "users": [...] } + { "type": "USER_LEFT", "user": {...}, "users": [...] } + { "type": "PRESENCE_LIST", "users": [...] } + { "type": "PONG" } + + Protocol (client → server): + { "type": "PING" } + """ + + async def connect(self): + self.save_id = self.scope['url_route']['kwargs']['save_id'] + # Channel group name — hyphens are valid per channels-redis spec + self.room_group = f'presence__{self.save_id}' + + token = self._parse_token( + self.scope.get('query_string', b'').decode('utf-8') + ) + self.user = await sync_to_async(get_user_from_token)(token) + + if self.user.is_anonymous: + # Unique key per connection so anonymous users are tracked individually + self.user_key = f'anon__{self.channel_name}' + self.user_info = {'username': 'Guest', 'is_anonymous': True} + else: + # Deduplicate across tabs: one user_key per authenticated user + self.user_key = f'user__{self.user.pk}' + self.user_info = { + 'username': self.user.username, + 'is_anonymous': False, + } + + await sync_to_async(PresenceStore.add)( + self.save_id, self.channel_name, self.user_key, self.user_info + ) + + await self.channel_layer.group_add(self.room_group, self.channel_name) + await self.accept() + + users = await sync_to_async(PresenceStore.get_users)(self.save_id) + await self.channel_layer.group_send( + self.room_group, + { + 'type': 'presence.broadcast', + 'event': 'USER_JOINED', + 'user': self.user_info, + 'users': users, + }, + ) + + async def disconnect(self, close_code): + user_fully_left = await sync_to_async(PresenceStore.remove)( + self.save_id, self.channel_name, self.user_key + ) + + if user_fully_left: + users = await sync_to_async(PresenceStore.get_users)(self.save_id) + await self.channel_layer.group_send( + self.room_group, + { + 'type': 'presence.broadcast', + 'event': 'USER_LEFT', + 'user': self.user_info, + 'users': users, + }, + ) + + await self.channel_layer.group_discard(self.room_group, self.channel_name) + + async def receive(self, text_data): + try: + msg = json.loads(text_data) + except (json.JSONDecodeError, TypeError): + return + + if msg.get('type') == 'PING': + await self.send(json.dumps({'type': 'PONG'})) + + # ------------------------------------------------------------------ # + # Group message handler — name maps to type 'presence.broadcast' # + # ------------------------------------------------------------------ # + + async def presence_broadcast(self, event): + """Forward a presence event to this consumer's WebSocket client.""" + await self.send( + json.dumps( + { + 'type': event['event'], + 'user': event['user'], + 'users': event['users'], + } + ) + ) + + # ------------------------------------------------------------------ # + # Helpers # + # ------------------------------------------------------------------ # + + @staticmethod + def _parse_token(query_string): + """Extract 'token' from a URL query string.""" + params = urllib.parse.parse_qs(query_string) + values = params.get('token', []) + return values[0] if values else None + + +class SyncConsumer(AsyncWebsocketConsumer): + """ + Relays circuit sync events between an editor and viewers in a project room. + Deliberately isolated from PresenceConsumer — no presence side-effects. + + URL route: /ws/sync// + Query param: token= (omit for anonymous) + + Channel group: sync__ + + Protocol (client → server): + { "type": "PING" } + { "type": "PROJECT_SNAPSHOT", "xml": "..." } + + Protocol (server → client): + { "type": "PONG" } + { "type": "PROJECT_SNAPSHOT", "xml": "..." } + + The consumer that sent PROJECT_SNAPSHOT does NOT receive its own echo. + Future incremental events (COMPONENT_ADDED, COMPONENT_MOVED, …) follow + the same relay pattern via sync_relay without requiring consumer changes. + """ + + async def connect(self): + self.save_id = self.scope['url_route']['kwargs']['save_id'] + self.room_group = f'sync__{self.save_id}' + + token = self._parse_token( + self.scope.get('query_string', b'').decode('utf-8') + ) + self.user = await sync_to_async(get_user_from_token)(token) + + await self.channel_layer.group_add(self.room_group, self.channel_name) + await self.accept() + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.room_group, self.channel_name) + + async def receive(self, text_data): + try: + msg = json.loads(text_data) + except (json.JSONDecodeError, TypeError): + return + + msg_type = msg.get('type') + + if msg_type == 'PING': + await self.send(json.dumps({'type': 'PONG'})) + + elif msg_type == 'PROJECT_SNAPSHOT': + xml = msg.get('xml', '') + if xml: + await self.channel_layer.group_send( + self.room_group, + { + 'type': 'sync.relay', + 'event_type': 'PROJECT_SNAPSHOT', + 'payload': {'xml': xml}, + 'sender_channel': self.channel_name, + } + ) + + # Phase 3 hook: COMPONENT_ADDED, COMPONENT_MOVED, PROPERTY_CHANGED, etc. + # will be handled here with the same group_send / sync.relay pattern. + + async def sync_relay(self, event): + """Forward a sync event to this consumer's client, skipping the sender.""" + if event.get('sender_channel') == self.channel_name: + return + await self.send( + json.dumps({ + 'type': event['event_type'], + **event['payload'], + }) + ) + + @staticmethod + def _parse_token(query_string): + params = urllib.parse.parse_qs(query_string) + values = params.get('token', []) + return values[0] if values else None diff --git a/esim-cloud-backend/collaborationAPI/presence.py b/esim-cloud-backend/collaborationAPI/presence.py new file mode 100644 index 000000000..003670500 --- /dev/null +++ b/esim-cloud-backend/collaborationAPI/presence.py @@ -0,0 +1,97 @@ +import json +import logging + +import redis as sync_redis +from django.conf import settings + +logger = logging.getLogger(__name__) + +_client = None + + +def _get_client(): + global _client + if _client is None: + url = getattr(settings, 'REDIS_URL', 'redis://redis:6379') + _client = sync_redis.from_url(url, decode_responses=True) + return _client + + +def _channel_key(save_id): + return f'presence:channels:{save_id}' + + +def _user_key(save_id): + return f'presence:users:{save_id}' + + +_TTL = 86400 # 24 h — refreshed on every join + + +class PresenceStore: + """ + Redis-backed presence store. + + Two hashes per room: + presence:channels: → { channel_name: user_key } + presence:users: → { user_key: user_info_json } + + A user appears in the presence list as long as they have ≥ 1 active + channel (i.e. ≥ 1 open browser tab). Closing a tab removes its channel + entry; the user entry is removed only when no channel maps to them. + """ + + @classmethod + def add(cls, save_id, channel_name, user_key, user_info): + """Register channel_name for user_key in save_id's room.""" + try: + r = _get_client() + ck = _channel_key(save_id) + uk = _user_key(save_id) + pipe = r.pipeline() + pipe.hset(ck, channel_name, user_key) + pipe.hset(uk, user_key, json.dumps(user_info)) + pipe.expire(ck, _TTL) + pipe.expire(uk, _TTL) + pipe.execute() + except Exception: + logger.exception('PresenceStore.add failed') + + @classmethod + def remove(cls, save_id, channel_name, user_key): + """Remove channel_name from the room. + + Returns True when the user has no remaining channels (last tab closed) + so the caller knows to broadcast USER_LEFT. + Returns False when the user still has other open tabs. + """ + try: + r = _get_client() + ck = _channel_key(save_id) + uk = _user_key(save_id) + r.hdel(ck, channel_name) + remaining = r.hvals(ck) + if user_key not in remaining: + r.hdel(uk, user_key) + return True + return False + except Exception: + logger.exception('PresenceStore.remove failed') + return True + + @classmethod + def get_users(cls, save_id): + """Return list of user_info dicts for every user currently in the room.""" + try: + r = _get_client() + raw_values = r.hvals(_user_key(save_id)) + users = [] + for v in raw_values: + try: + users.append(json.loads(v)) + except (json.JSONDecodeError, TypeError): + pass + return users + except Exception: + logger.exception('PresenceStore.get_users failed') + return [] diff --git a/esim-cloud-backend/collaborationAPI/routing.py b/esim-cloud-backend/collaborationAPI/routing.py new file mode 100644 index 000000000..114a14637 --- /dev/null +++ b/esim-cloud-backend/collaborationAPI/routing.py @@ -0,0 +1,14 @@ +from django.urls import re_path + +from . import consumers + +websocket_urlpatterns = [ + re_path( + r'^ws/presence/(?P[0-9a-f-]+)/$', + consumers.PresenceConsumer.as_asgi(), + ), + re_path( + r'^ws/sync/(?P[0-9a-f-]+)/$', + consumers.SyncConsumer.as_asgi(), + ), +] diff --git a/esim-cloud-backend/esimCloud/asgi.py b/esim-cloud-backend/esimCloud/asgi.py index 96e05871e..849c5531b 100644 --- a/esim-cloud-backend/esimCloud/asgi.py +++ b/esim-cloud-backend/esimCloud/asgi.py @@ -1,16 +1,16 @@ -""" -ASGI config for esimCloud project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ -""" - import os +import django -from django.core.asgi import get_asgi_application +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'esimCloud.settings') +django.setup() + +from collaborationAPI.routing import websocket_urlpatterns # noqa: E402 -application = get_asgi_application() +application = ProtocolTypeRouter({ + 'websocket': AuthMiddlewareStack( + URLRouter(websocket_urlpatterns) + ), +}) diff --git a/esim-cloud-backend/esimCloud/settings.py b/esim-cloud-backend/esimCloud/settings.py index 14542bf9f..0cff82458 100644 --- a/esim-cloud-backend/esimCloud/settings.py +++ b/esim-cloud-backend/esimCloud/settings.py @@ -44,6 +44,7 @@ 'social_django', 'inline_actions', 'djoser', + 'channels', 'simulationAPI', 'authAPI', 'libAPI', @@ -52,6 +53,7 @@ 'arduinoAPI', 'workflowAPI', 'ltiAPI', + 'collaborationAPI', ] MIDDLEWARE = [ @@ -85,6 +87,18 @@ ] WSGI_APPLICATION = 'esimCloud.wsgi.application' +ASGI_APPLICATION = 'esimCloud.asgi.application' + +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': { + 'hosts': [os.environ.get('REDIS_URL', 'redis://redis:6379')], + }, + }, +} + +REDIS_URL = os.environ.get('REDIS_URL', 'redis://redis:6379') AUTH_USER_MODEL = 'authAPI.User' # Database config Defaults to sqlite3 if not provided in environment files diff --git a/esim-cloud-backend/requirements.txt b/esim-cloud-backend/requirements.txt index f3b0b8ebc..f6c433493 100644 --- a/esim-cloud-backend/requirements.txt +++ b/esim-cloud-backend/requirements.txt @@ -1,3 +1,6 @@ +channels==3.0.5 +channels-redis==3.4.1 +daphne==3.0.2 amqp==2.5.2 asgiref==3.2.7 billiard==3.6.3.0