Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion docs/dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ We can create custom dashboards for the system.

Dash is a package for writing and serving web applications directly in Python. In Dash, there are 2 primary items, 1) page components such as headers, divs, spans, etc. and 2) callbacks.

### Getting started
### Getting Started with Dash

Page components can be set up similar to other `html` layouts, like so

Expand Down Expand Up @@ -180,3 +180,38 @@ DASH_PAGES = [
}
]
```

## NextJS

NextJS is web framework for building React-based web applications along with additional server-side functionality.

### Getting Started with NextJS

Follow the [Getting Started Guide](https://nextjs.org/docs/app/getting-started) in the official NextJS documentation.

### NextJS in the Learning Observer

Before add a NextJS application can be built and added to the system, a few configurations changes need to be made. The built application will not access the server-side code. Any server-side API endpoints need to be implemented in Python. The code that calls these endpoints will need to be updated to point to the correct path.

Additionally, we need to add a `basePath` to our `next.config.js` file. When building the application, this replaces prefixes all paths with the defined base path. This allows links to function appropriately while being served from Learning Observer.

```js
const nextConfig = {
// ... the rest of your config
basePath: '/_next/moduleName/pathOfBuiltApplication'
```

To add a NextJS project to a module, first the code needs to be built with `npm run build`. A directory named `out` will be created and the built application will be placed there. Copy this directoy to a module within Learning Observer.

Next, add the path to the built application to the module's `module.py` file

```python
# module.py
# ...other definitions
'''
Next js dashboards
'''
NEXTJS_PAGES = [
{'path': 'pathOfBuiltApplication/'}
]
```
9 changes: 8 additions & 1 deletion learning_observer/learning_observer/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,11 @@ def register_extra_views(app):

def create_nextjs_handler(path):
async def _nextjs_handler(request):
return aiohttp.web.FileResponse(os.path.join(path, 'index.html'))
sub_url = request.match_info.get('tail')
if sub_url is None:
return aiohttp.web.FileResponse(os.path.join(path, 'index.html'))
# TODO will this handle multi-layered sub-urls? /foo/bar
return aiohttp.web.FileResponse(os.path.join(path, f'{sub_url}.html'))
return _nextjs_handler


Expand All @@ -467,6 +471,9 @@ def register_nextjs_routes(app):
static_path = f'/_next{page_path}_next/static/'
app.router.add_static(static_path, os.path.join(full_path, '_next', 'static'))
app.router.add_get(page_path, create_nextjs_handler(full_path))
# The tail path handles suburls
tail_path = page_path + '{tail:.*}'
app.router.add_get(tail_path, create_nextjs_handler(full_path))


def register_wsgi_routes(app):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function getTemplatePath(templateFileName) {

const _baseConfig = {
loa: {
jsPattern: './lo_event/lo_assess/components/**/[A-Z]*.jsx',
jsPattern: './lo_event/lo_assess/components/**/{[A-Z]*,use[A-Z]*}.jsx',
xmlPattern: './lo_event/lo_assess/components/**/[A-Z]*.xml',
componentsFile: "./lo_event/lo_assess/components/components.jsx",
},
Expand Down
2 changes: 1 addition & 1 deletion modules/lo_event/lo_event/lo_assess/components/buildlib.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function parsePath(path) {

const lastSlashIndex = path.lastIndexOf('/');
const fileName = path.substring(lastSlashIndex + 1).split('.')[0];
const componentName = fileName.charAt(0).toUpperCase() + fileName.slice(1);
const componentName = fileName;
const fullPathWithoutExtension = path.split('.').slice(0, -1).join('.');
const jsxFullPath = fullPathWithoutExtension + ".jsx";
const xmlFullPath = fullPathWithoutExtension + ".xml";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const LO_CONNECTION_STATUS = {
UNINSTANTIATED: -1,
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* LOConnectionLastUpdated is a helper function for displaying
* connection information and last received message information
* about a websocket.
*
* Usage:
* ```js
* const { connectionStatus, message, } = LOConnection({ url, dataScope }); // or some other websocket
* return ( <LOConnectionLastUpdated message={message} connectionStatus={connectionStatus} /> );
* ```
*/
import React from 'react';
import { useState, useEffect } from 'react';
import { LO_CONNECTION_STATUS } from '../constants/LO_CONNECTION_STATUS';
import { renderTime } from '../../../util';

function renderReadableTimeSinceUpdate (timeDifference) {
if (timeDifference < 5) {
return 'Just now';
}
return `${renderTime(timeDifference)} ago`;
}

export const LOConnectionLastUpdated = ({ message, connectionStatus, showText=false }) => {
const [lastUpdated, setLastUpdated] = useState(null);
const [lastUpdatedMessage, setLastUpdatedMessage] = useState('');

const icons = {
[LO_CONNECTION_STATUS.UNINSTANTIATED]: 'fas fa-circle',
[LO_CONNECTION_STATUS.CONNECTING]: 'fas fa-sync-alt',
[LO_CONNECTION_STATUS.OPEN]: 'fas fa-check text-success',
[LO_CONNECTION_STATUS.CLOSING]: 'fas fa-sync-alt',
[LO_CONNECTION_STATUS.CLOSED]: 'fas fa-times text-danger'
};
const titles = {
[LO_CONNECTION_STATUS.UNINSTANTIATED]: 'Uninstantiated',
[LO_CONNECTION_STATUS.CONNECTING]: 'Connecting to server',
[LO_CONNECTION_STATUS.OPEN]: 'Connected to server',
[LO_CONNECTION_STATUS.CLOSING]: 'Closing connection',
[LO_CONNECTION_STATUS.CLOSED]: 'Disconnected from server'
};

// Set last updated time when new message arrives
useEffect(() => {
if (message) {
setLastUpdated(new Date());
}
}, [message]);

// Every second update last updated message
useEffect(() => {
const interval = setInterval(() => {
if (lastUpdated) {
const now = new Date();
const timeDifference = Math.floor((now - lastUpdated) / 1000); // Time difference in seconds
setLastUpdatedMessage(renderReadableTimeSinceUpdate(timeDifference));
} else { setLastUpdatedMessage('Never'); }
}, 1000);
return () => clearInterval(interval); // Cleanup interval on unmount
}, [lastUpdated]);

return (
<div title={titles[connectionStatus]}>
<i className={icons[connectionStatus]} />
{showText ? <span className='mx-1'>{titles[connectionStatus]}</span> : ''}
<span className='ms-1'>{lastUpdatedMessage}</span>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* useLOConnection is a websocket hook used for connecting to
* the communication protocl on Learning Observer.
*
* The server expects some data before it will start sending messages.
* When the connection opens, useLOConnection will send the `dataScope`,
* if available, to initiate receiving messages from the server.
* Otherwise, users should use the `sendMessage` function to provide
* data to LO.
*
* useLOConnection exposes the following items:
* - `sendMessage`: function to send messages to the server
* - `message`: the most recent message received
* - `error`: any errors that occured
* - `connectionStatus`: the current status of the websocket connection
* - `openConnection`: function that opens the connection when called
* - `closeConnection`: function that closes the connection when called
*/
import React from 'react';
import { useEffect, useRef, useState } from 'react';
import { LO_CONNECTION_STATUS } from '../constants/LO_CONNECTION_STATUS';

export const useLOConnection = ({
url, dataScope
}) => {
const [connectionStatus, setConnectionStatus] = useState(LO_CONNECTION_STATUS.CLOSED);
const [message, setMessage] = useState(null);
const [error, setError] = useState(null);
const clientRef = useRef(null);

// Function to open the WebSocket connection
const openConnection = () => {
// Prevent opening a new connection if one is already open or connecting
if (clientRef.current && (clientRef.current.readyState === LO_CONNECTION_STATUS.OPEN || clientRef.current.readyState === LO_CONNECTION_STATUS.CONNECTING)) {
console.warn("WebSocket connection is already open or in progress.");
return;
}

const protocol = { 'http:': 'ws:', 'https:': 'wss:' }[window.location.protocol];
const newUrl = url || `${protocol}//${window.location.hostname}:${window.location.port}/wsapi/communication_protocol`;
const client = new WebSocket(newUrl);
clientRef.current = client;

client.onopen = () => {
setConnectionStatus(LO_CONNECTION_STATUS.OPEN);
setError(null); // Clear any previous errors upon a successful connection
if (typeof dataScope !== 'undefined') {
client.send(JSON.stringify(dataScope));
}
};

client.onmessage = (event) => {
setMessage(event.data);
};

client.onerror = (event) => {
setError(event.message);
};

client.onclose = () => {
setConnectionStatus(LO_CONNECTION_STATUS.CLOSED);
};
};

// Function to close the WebSocket connection manually
const closeConnection = () => {
if (clientRef.current && connectionStatus === LO_CONNECTION_STATUS.OPEN) {
clientRef.current.close();
clientRef.current = null;
} else {
console.warn("WebSocket is not open; no connection to close.");
}
};

// Automatically attempt to open connection on mount
useEffect(() => {
openConnection();

// Cleanup on unmount
return () => {
closeConnection();
};
}, [url]); // Include `url` as a dependency in case it changes and requires a reconnection

const messageQueue = [];

// Send any messages on the queue
const processQueue = () => {
while (messageQueue.length > 0) {
if (clientRef.current && connectionStatus === LO_CONNECTION_STATUS.OPEN) {
const message = messageQueue.shift();
clientRef.current.send(message);
} else {
break;
}
}
};

// Start processing the queue when the connection opens
useEffect(() => {
if (connectionStatus === LO_CONNECTION_STATUS.OPEN) {
processQueue();
}
}, [connectionStatus]);

// Function to send a message via WebSocket
const sendMessage = (message) => {
if (clientRef.current && connectionStatus === LO_CONNECTION_STATUS.OPEN) {
clientRef.current.send(message);
} else {
messageQueue.push(message);
}
};

return { sendMessage, message, error, connectionStatus, openConnection, closeConnection };
};
Loading