diff --git a/docs/dashboards.md b/docs/dashboards.md index baa271f7..b003941e 100644 --- a/docs/dashboards.md +++ b/docs/dashboards.md @@ -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 @@ -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/'} +] +``` diff --git a/learning_observer/learning_observer/routes.py b/learning_observer/learning_observer/routes.py index 2a49cd77..5034b81b 100644 --- a/learning_observer/learning_observer/routes.py +++ b/learning_observer/learning_observer/routes.py @@ -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 @@ -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): diff --git a/modules/lo_event/lo_event/lo_assess/components/buildConfig.js b/modules/lo_event/lo_event/lo_assess/components/buildConfig.js index 2cd7678e..7b56dc57 100644 --- a/modules/lo_event/lo_event/lo_assess/components/buildConfig.js +++ b/modules/lo_event/lo_event/lo_assess/components/buildConfig.js @@ -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", }, diff --git a/modules/lo_event/lo_event/lo_assess/components/buildlib.js b/modules/lo_event/lo_event/lo_assess/components/buildlib.js index 8a84c092..29985771 100644 --- a/modules/lo_event/lo_event/lo_assess/components/buildlib.js +++ b/modules/lo_event/lo_event/lo_assess/components/buildlib.js @@ -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"; diff --git a/modules/lo_event/lo_event/lo_assess/components/constants/LO_CONNECTION_STATUS.jsx b/modules/lo_event/lo_event/lo_assess/components/constants/LO_CONNECTION_STATUS.jsx new file mode 100644 index 00000000..92179bcd --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/constants/LO_CONNECTION_STATUS.jsx @@ -0,0 +1,7 @@ +export const LO_CONNECTION_STATUS = { + UNINSTANTIATED: -1, + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3 +} diff --git a/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx b/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx new file mode 100644 index 00000000..1c9bbb00 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx @@ -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 ( ); + * ``` + */ +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 ( +
+ + {showText ? {titles[connectionStatus]} : ''} + {lastUpdatedMessage} +
+ ); +}; diff --git a/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnection.jsx b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnection.jsx new file mode 100644 index 00000000..c6f65caf --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnection.jsx @@ -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 }; +}; diff --git a/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnectionDataManager.jsx b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnectionDataManager.jsx new file mode 100644 index 00000000..1bcb1a2b --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnectionDataManager.jsx @@ -0,0 +1,134 @@ +/** + * useLOConnectionDataManager handles storing and processing incoming + * messages from the communication protocol websocket connection. + * This hook wraps the useLOConnection hook to fetch updates. + * The communication protocol sends batches of updates to apply + * to a clientside data object. + * + * When the internal data is updated, we call the `onDataUpdate` + * parameter so parents can update accordingly. + * + * useLOConnectionDataManager exposes the following items: + * - `data`: current overall data received from websocket messages + * - `errors`: information about any errors received + * - `connection`: all returned items from useLOConnection + * + * Usage: + * ```js + const { data, errors, sendMessage } = useLOConnectionDataManager({ url, dataScope }); + + return ( +
+
+

User Data

+ {Object.keys(data).length > 0 ? ( +
{JSON.stringify(data, null, 2)}
+ ) : ( +

No user data available.

+ )} +
+ {Object.keys(errors).length > 0 && ( +
+

Errors

+
{JSON.stringify(errors, null, 2)}
+
+ )} +
+ ); + * ``` + */ +import { useReducer, useEffect } from 'react'; +import { useLOConnection } from './useLOConnection'; // Assuming LOConnection is renamed to useLOConnection + +// Reducer function for managing state updates +const dataReducer = (state, action) => { + switch (action.type) { + case 'update': { + const { path, value } = action.payload; + const pathKeys = path.split('.'); + + // Create a new `data` object with the updated value at the correct path + const newData = { ...state.data }; + let current = newData; // Start at the top-level copy + for (let i = 0; i < pathKeys.length - 1; i++) { + const key = pathKeys[i]; + if (!(key in current)) { + current[key] = {}; // Create path if it doesn't exist + } else { + current[key] = { ...current[key] }; // Copy the existing nested object + } + current = current[key]; + } + + const finalKey = pathKeys[pathKeys.length - 1]; + // TODO this doesn't handle a deep merge + current[finalKey] = { + ...current[finalKey], // Existing data + ...value, // New data (overwrites where necessary) + }; + + return { + ...state, + data: newData, + }; + } + case 'error': { + const { path, value } = action.payload; + return { + ...state, + errors: { + ...state.errors, + [path]: value, + }, + }; + } + case 'clearError': { + const { path } = action.payload; + const newErrors = { ...state.errors }; + delete newErrors[path]; + return { + ...state, + errors: newErrors, + }; + } + default: + console.warn(`Unhandled action type: ${action.type}`); + return state; + } +}; + +// Initial state for the reducer +const initialState = { + data: {}, + errors: {}, +}; + +export const useLOConnectionDataManager = ({ url, dataScope }) => { + const { message, ...connection } = useLOConnection({ url, dataScope }); + const [state, dispatch] = useReducer(dataReducer, initialState); + + useEffect(() => { + if (message) { + try { + const messages = JSON.parse(message); + + messages.forEach((msg) => { + if ('error' in msg.value) { + dispatch({ type: 'error', payload: { path: msg.path, value: msg.value } }); + } else { + dispatch({ type: 'clearError', payload: { path: msg.path } }); + dispatch({ type: msg.op, payload: { path: msg.path, value: msg.value } }); + } + }); + } catch (e) { + console.error('Failed to parse incoming message:', e); + } + } + }, [message]); + + return { + connection, + data: state.data, + errors: state.errors, + }; +}; diff --git a/modules/lo_event/lo_event/util.js b/modules/lo_event/lo_event/util.js index ee53636f..b74e7b86 100644 --- a/modules/lo_event/lo_event/util.js +++ b/modules/lo_event/lo_event/util.js @@ -426,3 +426,34 @@ export function dispatchCustomEvent(eventName, detail) { console.warn("Event dispatching is not supported in this environment."); } } + +/** + * Convert seconds to a time string. + * + * Compact representation. + * 10 ==> 10s + * 125 ==> 2m + * 3600 ==> 1h + * 7601 ==> 2h + * 764450 ==> 8d + */ +export function renderTime (t) { + const seconds = Math.floor(t) % 60; + const minutes = Math.floor(t / 60) % 60; + const hours = Math.floor(t / 3600) % 60; + const days = Math.floor(t / 3600 / 24); + + if (days > 0) { + return String(days) + 'd'; + } + if (hours > 0) { + return String(hours) + 'h'; + } + if (minutes > 0) { + return String(minutes) + 'm'; + } + if (seconds > 0) { + return String(seconds) + 's'; + } + return '-'; +} diff --git a/modules/toy-assess/lo_event.sh b/modules/toy-assess/lo_event.sh index 47a29fdf..4efe395b 100755 --- a/modules/toy-assess/lo_event.sh +++ b/modules/toy-assess/lo_event.sh @@ -11,4 +11,4 @@ pushd ../lo_event/ npm run prebuild npm pack popd -npm install ../lo_event/lo_event-0.0.2.tgz --no-save +npm install ../lo_event/lo_event-0.0.3.tgz --no-save diff --git a/modules/toy-assess/next.config.js b/modules/toy-assess/next.config.js index 5d65a05a..f7e13b93 100644 --- a/modules/toy-assess/next.config.js +++ b/modules/toy-assess/next.config.js @@ -11,7 +11,7 @@ const nextConfig = { crypto: 'crypto', ws: 'ws', 'indexeddb-js': 'indexeddb-js' - }) + }); config.module.rules.push({ test: /\.jsx?$/, // This regex matches both .js and .jsx files include: [/node_modules\/lo_event/], // Include only the lo_event module @@ -19,12 +19,18 @@ const nextConfig = { loader: 'babel-loader', options: { // Add your babel presets here (e.g., @babel/preset-react) - presets: ['@babel/preset-react'], // Assuming you use React + presets: ['@babel/preset-react'] // Assuming you use React }, }, - }) - return config + }); + return config; }, -} + eslint: { + ignoreDuringBuilds: true, + }, + output: 'export', + // TODO this is only needed when building for use within LO + // basePath: '/_next/learning_observer_template/toy_assess' +}; -module.exports = nextConfig +module.exports = nextConfig; diff --git a/modules/toy-assess/package.json b/modules/toy-assess/package.json index 8834e660..3d749bb6 100644 --- a/modules/toy-assess/package.json +++ b/modules/toy-assess/package.json @@ -16,7 +16,7 @@ "bufferutil": "^4.0.8", "indexeddb-js": "^0.0.14", "jasmine": "^5.1.0", - "lo_event": "file:../lo_event/lo_event-0.0.2.tgz", + "lo_event": "file:../lo_event/lo_event-0.0.3.tgz", "mapbox": "^1.0.0-beta10", "mock-aws-s3": "^4.0.2", "next": "13.5.5", diff --git a/modules/toy-assess/src/app/changer/page.js b/modules/toy-assess/src/app/changer/page.js index dd529bf4..e5c6e7a0 100644 --- a/modules/toy-assess/src/app/changer/page.js +++ b/modules/toy-assess/src/app/changer/page.js @@ -1,4 +1,4 @@ -// This is a little demo which changes the style of text +// This is a little demo that retypes text as different characters. 'use client'; // @refresh reset diff --git a/modules/toy-assess/src/app/lib/azureInterface.js b/modules/toy-assess/src/app/lib/azureInterface.js index ce09c45d..33e317cd 100644 --- a/modules/toy-assess/src/app/lib/azureInterface.js +++ b/modules/toy-assess/src/app/lib/azureInterface.js @@ -4,7 +4,7 @@ const { OpenAIClient, AzureKeyCredential } = require("@azure/openai"); const resource=await process.env.OPENAI_URL; const deploymentID=process.env.OPENAI_DEPLOYMENT_ID; -const key = await process.env.OPENAI_API_KEY; +const key = await process.env.OPENAI_API_KEY || 'EMPTY'; console.log("In azureInterface"); console.log("key: " + key); diff --git a/modules/toy-assess/src/app/websocket/page.js b/modules/toy-assess/src/app/websocket/page.js new file mode 100644 index 00000000..0453bee7 --- /dev/null +++ b/modules/toy-assess/src/app/websocket/page.js @@ -0,0 +1,56 @@ +// Demo to show the Websocket + +'use client'; +// @refresh reset + +// We need to import this to call init() for now +import { } from '../components.js'; + +import React, { useState, useEffect } from 'react'; +import { LOConnectionLastUpdated, useLOConnectionDataManager, LO_CONNECTION_STATUS, Button } from 'lo_event/lo_event/lo_assess/components/components.jsx'; + +export default function Home ({ children }) { + const decoded = {}; + decoded.course_id = '123456'; + const dataScope = { + wo: { + execution_dag: 'writing_observer', + target_exports: ['docs_with_nlp_annotations'], + kwargs: decoded + } + }; + + const { data, errors, connection } = useLOConnectionDataManager({ url: 'ws://localhost:8888/wsapi/communication_protocol', dataScope }); + return ( +
+

WebSocket Connection Page

+
+ +
+
+ + + +
+
+

User Data

+ {Object.keys(data).length > 0 + ? (
{JSON.stringify(data, null, 2)}
) + : (

No user data available.

) + } +
+ {Object.keys(errors).length > 0 && ( +
+

Errors

+
{JSON.stringify(errors, null, 2)}
+
+ )} +
+ ); +};