Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c06b7851ce | ||
|
|
818e7d5818 | ||
|
|
e34c261c25 | ||
|
|
1908bd1eca | ||
|
|
68e6ac5ac1 | ||
|
|
d3ec77b828 |
BIN
assets/pasted-20260407-150901-3e5110b3.png
Normal file
BIN
assets/pasted-20260407-150901-3e5110b3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@ -6,7 +6,6 @@ const passport = require('passport');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const bodyParser = require('body-parser');
|
||||
const db = require('./db/models');
|
||||
const config = require('./config');
|
||||
const swaggerUI = require('swagger-ui-express');
|
||||
const swaggerJsDoc = require('swagger-jsdoc');
|
||||
@ -50,6 +49,8 @@ const media_streamsRoutes = require('./routes/media_streams');
|
||||
const announcementsRoutes = require('./routes/announcements');
|
||||
|
||||
const assetsRoutes = require('./routes/assets');
|
||||
const maiServerRoutes = require('./routes/maiServer');
|
||||
const maiServerPublicRoutes = require('./routes/maiServerPublic');
|
||||
|
||||
|
||||
const getBaseUrl = (url) => {
|
||||
@ -139,6 +140,9 @@ app.use('/api/announcements', passport.authenticate('jwt', {session: false}), an
|
||||
|
||||
app.use('/api/assets', passport.authenticate('jwt', {session: false}), assetsRoutes);
|
||||
|
||||
app.use('/api/mai-server-public', maiServerPublicRoutes);
|
||||
app.use('/api/mai-server', passport.authenticate('jwt', {session: false}), maiServerRoutes);
|
||||
|
||||
app.use(
|
||||
'/api/openai',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
|
||||
@ -1,26 +1,23 @@
|
||||
const express = require('express');
|
||||
const config = require('../config');
|
||||
const path = require('path');
|
||||
const passport = require('passport');
|
||||
const services = require('../services/file');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/download', (req, res) => {
|
||||
if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) {
|
||||
if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
|
||||
services.downloadGCloud(req, res);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
services.downloadLocal(req, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/upload/:table/:field', passport.authenticate('jwt', {session: false}), (req, res) => {
|
||||
router.post('/upload/:table/:field', passport.authenticate('jwt', { session: false }), (req, res) => {
|
||||
const fileName = `${req.params.table}/${req.params.field}`;
|
||||
|
||||
if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) {
|
||||
if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
|
||||
services.uploadGCloud(fileName, req, res);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
services.uploadLocal(fileName, {
|
||||
entity: null,
|
||||
maxFileSize: 10 * 1024 * 1024,
|
||||
@ -29,4 +26,39 @@ router.post('/upload/:table/:field', passport.authenticate('jwt', {session: fals
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/upload-public/:table/:field', (req, res) => {
|
||||
const fileName = `${req.params.table}/${req.params.field}`;
|
||||
|
||||
if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
|
||||
services.uploadGCloud(fileName, req, res);
|
||||
} else {
|
||||
services.uploadLocal(fileName, {
|
||||
entity: null,
|
||||
maxFileSize: 50 * 1024 * 1024,
|
||||
folderIncludesAuthenticationUid: false,
|
||||
allowAnonymous: true,
|
||||
allowedMimePrefixes: ['audio/', 'video/'],
|
||||
})(req, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/fetch-public-playlist', express.json({ limit: '100kb' }), async (req, res) => {
|
||||
const playlistUrl = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
|
||||
|
||||
if (!playlistUrl) {
|
||||
res.status(400).send({ message: 'A playlist URL is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await services.fetchPublicTextFile(playlistUrl);
|
||||
res.status(200).send(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch public playlist URL:', { url: playlistUrl, error: error.message || error });
|
||||
res.status(error.statusCode || 500).send({
|
||||
message: error.message || 'Failed to fetch the remote playlist URL.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
47
backend/src/routes/maiServer.js
Normal file
47
backend/src/routes/maiServer.js
Normal file
@ -0,0 +1,47 @@
|
||||
const express = require('express');
|
||||
|
||||
const MaiServerService = require('../services/maiServer');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function getHostFromRequest(req) {
|
||||
const referer = req.headers.referer || `${req.protocol}://${req.get('host')}${req.originalUrl}`;
|
||||
return new URL(referer).host;
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/overview',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await MaiServerService.getOverview(req.currentUser, { range: req.query.range });
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/enable-authentication',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await MaiServerService.enableAuthentication(req.currentUser, getHostFromRequest(req));
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/send-verification-email',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await MaiServerService.sendVerificationEmail(req.currentUser, getHostFromRequest(req));
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/send-password-reset-email',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await MaiServerService.sendPasswordResetEmail(req.currentUser, getHostFromRequest(req));
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
18
backend/src/routes/maiServerPublic.js
Normal file
18
backend/src/routes/maiServerPublic.js
Normal file
@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
|
||||
const MaiServerService = require('../services/maiServer');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/overview',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await MaiServerService.getPublicOverview({ range: req.query.range });
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
@ -1,8 +1,13 @@
|
||||
const axios = require('axios');
|
||||
const dns = require('dns').promises;
|
||||
const formidable = require('formidable');
|
||||
const fs = require('fs');
|
||||
const net = require('net');
|
||||
const config = require('../config');
|
||||
const path = require('path');
|
||||
const { format } = require("util");
|
||||
const { format } = require('util');
|
||||
|
||||
const MAX_REMOTE_PLAYLIST_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const ensureDirectoryExistence = (filePath) => {
|
||||
const dirname = path.dirname(filePath);
|
||||
@ -13,7 +18,39 @@ const ensureDirectoryExistence = (filePath) => {
|
||||
|
||||
ensureDirectoryExistence(dirname);
|
||||
fs.mkdirSync(dirname);
|
||||
}
|
||||
};
|
||||
|
||||
const getUploadedFile = (files) => {
|
||||
if (!files || !files.file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Array.isArray(files.file) ? files.file[0] : files.file;
|
||||
};
|
||||
|
||||
const getUploadedFilePath = (file) => {
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return file.filepath || file.path || null;
|
||||
};
|
||||
|
||||
const getUploadedFileType = (file) => {
|
||||
if (!file) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return file.mimetype || file.type || '';
|
||||
};
|
||||
|
||||
const isMimeTypeAllowed = (mimeType, validations) => {
|
||||
if (!validations || !validations.allowedMimePrefixes || validations.allowedMimePrefixes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return validations.allowedMimePrefixes.some((prefix) => mimeType.startsWith(prefix));
|
||||
};
|
||||
|
||||
const uploadLocal = (
|
||||
folder,
|
||||
@ -21,30 +58,24 @@ const uploadLocal = (
|
||||
entity: null,
|
||||
maxFileSize: null,
|
||||
folderIncludesAuthenticationUid: false,
|
||||
allowAnonymous: false,
|
||||
allowedMimePrefixes: null,
|
||||
},
|
||||
) => {
|
||||
return (req, res) => {
|
||||
if (!req.currentUser) {
|
||||
if (!req.currentUser && !validations.allowAnonymous) {
|
||||
res.sendStatus(403);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
validations.entity
|
||||
) {
|
||||
if (validations.entity) {
|
||||
res.sendStatus(403);
|
||||
return;
|
||||
}
|
||||
|
||||
if (validations.folderIncludesAuthenticationUid) {
|
||||
folder = folder.replace(
|
||||
':userId',
|
||||
req.currentUser.authenticationUid,
|
||||
);
|
||||
if (
|
||||
!req.currentUser.authenticationUid ||
|
||||
!folder.includes(req.currentUser.authenticationUid)
|
||||
) {
|
||||
folder = folder.replace(':userId', req.currentUser.authenticationUid);
|
||||
if (!req.currentUser.authenticationUid || !folder.includes(req.currentUser.authenticationUid)) {
|
||||
res.sendStatus(403);
|
||||
return;
|
||||
}
|
||||
@ -58,140 +89,311 @@ const uploadLocal = (
|
||||
}
|
||||
|
||||
form.parse(req, function (err, fields, files) {
|
||||
const filename = String(fields.filename);
|
||||
const fileTempUrl = files.file.path;
|
||||
if (err) {
|
||||
console.error('File upload parse failed:', err);
|
||||
res.status(500).send(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filename) {
|
||||
const filename = String(fields.filename);
|
||||
const uploadedFile = getUploadedFile(files);
|
||||
const fileTempUrl = getUploadedFilePath(uploadedFile);
|
||||
const mimeType = getUploadedFileType(uploadedFile);
|
||||
|
||||
if (!fileTempUrl || !uploadedFile) {
|
||||
res.status(400).send({ message: 'Please upload a file.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMimeTypeAllowed(mimeType, validations)) {
|
||||
fs.unlinkSync(fileTempUrl);
|
||||
res.status(400).send({ message: 'Invalid file type.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filename || filename === 'undefined') {
|
||||
fs.unlinkSync(fileTempUrl);
|
||||
res.sendStatus(500);
|
||||
return;
|
||||
}
|
||||
|
||||
const privateUrl = path.join(
|
||||
form.uploadDir,
|
||||
folder,
|
||||
filename,
|
||||
);
|
||||
ensureDirectoryExistence(privateUrl);
|
||||
fs.renameSync(fileTempUrl, privateUrl);
|
||||
res.sendStatus(200);
|
||||
const privateUrl = path.join(folder, filename);
|
||||
const destinationPath = path.join(form.uploadDir, privateUrl);
|
||||
ensureDirectoryExistence(destinationPath);
|
||||
fs.renameSync(fileTempUrl, destinationPath);
|
||||
res.status(200).send({ privateUrl });
|
||||
});
|
||||
|
||||
form.on('error', function (err) {
|
||||
console.error('File upload failed:', err);
|
||||
res.status(500).send(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const downloadLocal = async (req, res) => {
|
||||
const privateUrl = req.query.privateUrl;
|
||||
if (!privateUrl) {
|
||||
return res.sendStatus(404);
|
||||
const privateUrl = req.query.privateUrl;
|
||||
if (!privateUrl) {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
return res.download(path.join(config.uploadDir, privateUrl));
|
||||
};
|
||||
|
||||
const isPrivateIpv4 = (ip) => {
|
||||
const [firstOctet, secondOctet] = ip.split('.').map((segment) => Number(segment));
|
||||
|
||||
if (firstOctet === 10 || firstOctet === 127 || firstOctet === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (firstOctet === 169 && secondOctet === 254) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (firstOctet === 172 && secondOctet >= 16 && secondOctet <= 31) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (firstOctet === 192 && secondOctet === 168) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isPrivateIpv6 = (ip) => {
|
||||
const loweredIp = ip.toLowerCase();
|
||||
|
||||
return (
|
||||
loweredIp === '::1' ||
|
||||
loweredIp === '::' ||
|
||||
loweredIp.startsWith('fc') ||
|
||||
loweredIp.startsWith('fd') ||
|
||||
loweredIp.startsWith('fe80')
|
||||
);
|
||||
};
|
||||
|
||||
const isPrivateIp = (ip) => {
|
||||
const family = net.isIP(ip);
|
||||
|
||||
if (family === 4) {
|
||||
return isPrivateIpv4(ip);
|
||||
}
|
||||
|
||||
if (family === 6) {
|
||||
return isPrivateIpv6(ip);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const assertRemoteUrlIsPublic = async (remoteUrl) => {
|
||||
let parsedUrl;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(remoteUrl);
|
||||
} catch (error) {
|
||||
const invalidUrlError = new Error('Use a valid http:// or https:// playlist URL.');
|
||||
invalidUrlError.statusCode = 400;
|
||||
throw invalidUrlError;
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
const invalidProtocolError = new Error('Only http:// and https:// playlist URLs are supported.');
|
||||
invalidProtocolError.statusCode = 400;
|
||||
throw invalidProtocolError;
|
||||
}
|
||||
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
if (hostname === 'localhost' || hostname.endsWith('.local')) {
|
||||
const localhostError = new Error('Local network playlist URLs are not allowed.');
|
||||
localhostError.statusCode = 400;
|
||||
throw localhostError;
|
||||
}
|
||||
|
||||
if (net.isIP(hostname) && isPrivateIp(hostname)) {
|
||||
const privateIpError = new Error('Private network playlist URLs are not allowed.');
|
||||
privateIpError.statusCode = 400;
|
||||
throw privateIpError;
|
||||
}
|
||||
|
||||
const resolvedAddresses = await dns.lookup(parsedUrl.hostname, { all: true });
|
||||
|
||||
if (!resolvedAddresses.length) {
|
||||
const dnsError = new Error('Could not resolve the playlist host.');
|
||||
dnsError.statusCode = 400;
|
||||
throw dnsError;
|
||||
}
|
||||
|
||||
if (resolvedAddresses.some((entry) => isPrivateIp(entry.address))) {
|
||||
const resolvedPrivateIpError = new Error('Playlist host resolves to a private network address.');
|
||||
resolvedPrivateIpError.statusCode = 400;
|
||||
throw resolvedPrivateIpError;
|
||||
}
|
||||
|
||||
return parsedUrl.toString();
|
||||
};
|
||||
|
||||
const fetchPublicTextFile = async (remoteUrl) => {
|
||||
const safeUrl = await assertRemoteUrlIsPublic(remoteUrl);
|
||||
|
||||
try {
|
||||
const response = await axios.get(safeUrl, {
|
||||
responseType: 'text',
|
||||
timeout: 15000,
|
||||
maxContentLength: MAX_REMOTE_PLAYLIST_BYTES,
|
||||
maxBodyLength: MAX_REMOTE_PLAYLIST_BYTES,
|
||||
transformResponse: [(data) => data],
|
||||
headers: {
|
||||
Accept: 'application/json, application/x-mpegURL, application/vnd.apple.mpegurl, audio/mpegurl, text/plain;q=0.9,*/*;q=0.5',
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof response.data !== 'string') {
|
||||
const responseTypeError = new Error('The remote playlist must return text-based JSON, M3U, or M3U8 content.');
|
||||
responseTypeError.statusCode = 400;
|
||||
throw responseTypeError;
|
||||
}
|
||||
res.download(path.join(config.uploadDir, privateUrl));
|
||||
}
|
||||
|
||||
return {
|
||||
url: safeUrl,
|
||||
body: response.data,
|
||||
contentType: response.headers['content-type'] || '',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
console.error('Remote playlist fetch failed:', {
|
||||
url: safeUrl,
|
||||
status: error.response.status,
|
||||
data: error.response.data,
|
||||
});
|
||||
const remoteResponseError = new Error(`Remote server responded with status ${error.response.status}.`);
|
||||
remoteResponseError.statusCode = error.response.status;
|
||||
throw remoteResponseError;
|
||||
}
|
||||
|
||||
if (error.code === 'ERR_BAD_RESPONSE' || error.code === 'ERR_BAD_REQUEST') {
|
||||
console.error('Remote playlist response handling failed:', { url: safeUrl, message: error.message });
|
||||
const remoteBodyError = new Error('The remote playlist response could not be processed.');
|
||||
remoteBodyError.statusCode = 400;
|
||||
throw remoteBodyError;
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
console.error('Remote playlist request timed out:', { url: safeUrl, message: error.message });
|
||||
const timeoutError = new Error('Timed out while fetching the remote playlist.');
|
||||
timeoutError.statusCode = 504;
|
||||
throw timeoutError;
|
||||
}
|
||||
|
||||
if (error.message && error.message.includes('maxContentLength')) {
|
||||
console.error('Remote playlist exceeded size limit:', { url: safeUrl, message: error.message });
|
||||
const sizeError = new Error('Remote playlist is larger than the 2 MB import limit.');
|
||||
sizeError.statusCode = 413;
|
||||
throw sizeError;
|
||||
}
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Remote playlist fetch failed:', { url: safeUrl, error });
|
||||
const unknownError = new Error('Failed to fetch the remote playlist URL.');
|
||||
unknownError.statusCode = 500;
|
||||
throw unknownError;
|
||||
}
|
||||
};
|
||||
|
||||
const initGCloud = () => {
|
||||
const processFile = require("../middlewares/upload");
|
||||
const { Storage } = require("@google-cloud/storage");
|
||||
const processFile = require('../middlewares/upload');
|
||||
const { Storage } = require('@google-cloud/storage');
|
||||
|
||||
const crypto = require('crypto')
|
||||
const hash = config.gcloud.hash
|
||||
|
||||
const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, "\n");
|
||||
const hash = config.gcloud.hash;
|
||||
const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, '\n');
|
||||
|
||||
const storage = new Storage({
|
||||
projectId: process.env.GC_PROJECT_ID,
|
||||
credentials: {
|
||||
client_email: process.env.GC_CLIENT_EMAIL,
|
||||
private_key: privateKey
|
||||
}
|
||||
projectId: process.env.GC_PROJECT_ID,
|
||||
credentials: {
|
||||
client_email: process.env.GC_CLIENT_EMAIL,
|
||||
private_key: privateKey,
|
||||
},
|
||||
});
|
||||
|
||||
const bucket = storage.bucket(config.gcloud.bucket);
|
||||
return {hash, bucket, processFile};
|
||||
}
|
||||
return { hash, bucket, processFile };
|
||||
};
|
||||
|
||||
const uploadGCloud = async (folder, req, res) => {
|
||||
try {
|
||||
const {hash, bucket, processFile} = initGCloud();
|
||||
const { hash, bucket, processFile } = initGCloud();
|
||||
await processFile(req, res);
|
||||
let buffer = await req.file.buffer;
|
||||
let filename = await req.body.filename;
|
||||
const buffer = await req.file.buffer;
|
||||
const filename = await req.body.filename;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).send({ message: "Please upload a file!" });
|
||||
return res.status(400).send({ message: 'Please upload a file!' });
|
||||
}
|
||||
|
||||
let path = `${hash}/${folder}/${filename}`;
|
||||
let blob = bucket.file(path);
|
||||
|
||||
console.log(path);
|
||||
const fullPath = `${hash}/${folder}/${filename}`;
|
||||
const blob = bucket.file(fullPath);
|
||||
|
||||
const blobStream = blob.createWriteStream({
|
||||
resumable: false,
|
||||
});
|
||||
|
||||
blobStream.on("error", (err) => {
|
||||
console.log('Upload error');
|
||||
console.log(err.message);
|
||||
blobStream.on('error', (err) => {
|
||||
console.error('Upload error:', err.message);
|
||||
res.status(500).send({ message: err.message });
|
||||
});
|
||||
|
||||
console.log(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);
|
||||
|
||||
blobStream.on("finish", async (data) => {
|
||||
const publicUrl = format(
|
||||
`https://storage.googleapis.com/${bucket.name}/${blob.name}`
|
||||
);
|
||||
blobStream.on('finish', async () => {
|
||||
const publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);
|
||||
|
||||
res.status(200).send({
|
||||
message: "Uploaded the file successfully: " + path,
|
||||
message: `Uploaded the file successfully: ${fullPath}`,
|
||||
url: publicUrl,
|
||||
});
|
||||
});
|
||||
|
||||
blobStream.end(buffer)
|
||||
blobStream.end(buffer);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
console.error('Could not upload the file:', err);
|
||||
res.status(500).send({
|
||||
message: `Could not upload the file. ${err}`
|
||||
message: `Could not upload the file. ${err}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const downloadGCloud = async (req, res) => {
|
||||
try {
|
||||
const {hash, bucket, processFile} = initGCloud();
|
||||
const { hash, bucket } = initGCloud();
|
||||
|
||||
const privateUrl = await req.query.privateUrl;
|
||||
const filePath = `${hash}/${privateUrl}`;
|
||||
const file = bucket.file(filePath)
|
||||
const file = bucket.file(filePath);
|
||||
const fileExists = await file.exists();
|
||||
|
||||
if (fileExists[0]) {
|
||||
const stream = file.createReadStream();
|
||||
stream.pipe(res);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
res.status(404).send({
|
||||
message: "Could not download the file. " + err,
|
||||
});
|
||||
message: 'Could not download the requested file.',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(404).send({
|
||||
message: "Could not download the file. " + err,
|
||||
message: `Could not download the file. ${err}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGCloud = async (privateUrl) => {
|
||||
try {
|
||||
const {hash, bucket, processFile} = initGCloud();
|
||||
const { hash, bucket } = initGCloud();
|
||||
const filePath = `${hash}/${privateUrl}`;
|
||||
|
||||
const file = bucket.file(filePath)
|
||||
const file = bucket.file(filePath);
|
||||
const fileExists = await file.exists();
|
||||
|
||||
if (fileExists[0]) {
|
||||
@ -200,7 +402,7 @@ const deleteGCloud = async (privateUrl) => {
|
||||
} catch (err) {
|
||||
console.log(`Cannot find the file ${privateUrl}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
initGCloud,
|
||||
@ -208,6 +410,6 @@ module.exports = {
|
||||
downloadLocal,
|
||||
deleteGCloud,
|
||||
uploadGCloud,
|
||||
downloadGCloud
|
||||
}
|
||||
|
||||
downloadGCloud,
|
||||
fetchPublicTextFile,
|
||||
};
|
||||
|
||||
651
backend/src/services/maiServer.js
Normal file
651
backend/src/services/maiServer.js
Normal file
@ -0,0 +1,651 @@
|
||||
const db = require('../db/models');
|
||||
const UsersDBApi = require('../db/api/users');
|
||||
const UsersService = require('./users');
|
||||
const AuthService = require('./auth');
|
||||
const EmailSender = require('./email');
|
||||
|
||||
const { QueryTypes, Op } = db.Sequelize;
|
||||
|
||||
const PERMISSIONS = {
|
||||
users: 'READ_USERS',
|
||||
conversations: 'READ_CONVERSATIONS',
|
||||
messages: 'READ_MESSAGES',
|
||||
announcements: 'READ_ANNOUNCEMENTS',
|
||||
projects: 'READ_PROJECTS',
|
||||
pages: 'READ_PAGES',
|
||||
linkCollections: 'READ_LINK_COLLECTIONS',
|
||||
externalLinks: 'READ_EXTERNAL_LINKS',
|
||||
widgets: 'READ_WIDGETS',
|
||||
agentEndpoints: 'READ_AGENT_ENDPOINTS',
|
||||
mediaChannels: 'READ_MEDIA_CHANNELS',
|
||||
mediaStreams: 'READ_MEDIA_STREAMS',
|
||||
assets: 'READ_ASSETS',
|
||||
};
|
||||
|
||||
const ALLOWED_RANGES = new Set([7, 30, 90]);
|
||||
|
||||
function hasPermission(user, permissionName) {
|
||||
if (!user?.app_role?.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!permissionName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user.app_role.name === 'Administrator') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const permissions = new Set([
|
||||
...(Array.isArray(user.custom_permissions) ? user.custom_permissions : []).map((permission) => permission.name),
|
||||
...(Array.isArray(user.app_role_permissions) ? user.app_role_permissions : []).map((permission) => permission.name),
|
||||
]);
|
||||
|
||||
return permissions.has(permissionName);
|
||||
}
|
||||
|
||||
function normalizeRange(range) {
|
||||
const parsedRange = Number.parseInt(range, 10);
|
||||
return ALLOWED_RANGES.has(parsedRange) ? parsedRange : 7;
|
||||
}
|
||||
|
||||
function formatDateKey(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDateLabel(date) {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
function buildDateWindow(days = 7) {
|
||||
const dates = [];
|
||||
const cursor = new Date();
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
cursor.setUTCDate(cursor.getUTCDate() - (days - 1));
|
||||
|
||||
for (let index = 0; index < days; index += 1) {
|
||||
dates.push(new Date(cursor));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function buildRange(days) {
|
||||
return {
|
||||
days,
|
||||
label: `Last ${days} days`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuthStatus(user) {
|
||||
const emailConfigured = EmailSender.isConfigured;
|
||||
const isAuthenticationEnabled = !user?.disabled && (user?.emailVerified || !emailConfigured);
|
||||
const fullName = [user?.firstName, user?.lastName].filter(Boolean).join(' ').trim();
|
||||
|
||||
return {
|
||||
displayName: fullName || user?.email || 'Authenticated user',
|
||||
email: user?.email || '',
|
||||
provider: user?.provider || 'local',
|
||||
roleName: user?.app_role?.name || 'Workspace member',
|
||||
country: typeof user?.country === 'string' && user.country ? user.country : 'Not configured',
|
||||
emailVerified: Boolean(user?.emailVerified),
|
||||
disabled: Boolean(user?.disabled),
|
||||
emailConfigured,
|
||||
isAuthenticationEnabled,
|
||||
authenticationStatusLabel: isAuthenticationEnabled ? 'Enabled for this account' : 'Action required',
|
||||
verificationStatusLabel: user?.emailVerified
|
||||
? 'Verified'
|
||||
: emailConfigured
|
||||
? 'Verification pending'
|
||||
: 'Verification bypassed (email disabled)',
|
||||
actionLabel: isAuthenticationEnabled ? 'Authentication enabled' : 'Enable authentication',
|
||||
};
|
||||
}
|
||||
|
||||
async function countByDate(tableName, startDate) {
|
||||
return db.sequelize.query(
|
||||
`
|
||||
SELECT DATE("createdAt")::text AS bucket, COUNT(*)::int AS count
|
||||
FROM "${tableName}"
|
||||
WHERE "deletedAt" IS NULL
|
||||
AND "createdAt" >= :startDate
|
||||
GROUP BY DATE("createdAt")
|
||||
ORDER BY DATE("createdAt") ASC
|
||||
`,
|
||||
{
|
||||
replacements: { startDate },
|
||||
type: QueryTypes.SELECT,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mapSeriesToWindow(windowDates, rows) {
|
||||
const counts = new Map(rows.map((row) => [row.bucket, Number(row.count || 0)]));
|
||||
|
||||
return windowDates.map((date) => counts.get(formatDateKey(date)) || 0);
|
||||
}
|
||||
|
||||
async function getGroupedCounts(model, field, startDate, extraWhere = {}) {
|
||||
return model.findAll({
|
||||
attributes: [
|
||||
[db.sequelize.col(field), 'label'],
|
||||
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'value'],
|
||||
],
|
||||
where: {
|
||||
deletedAt: null,
|
||||
createdAt: {
|
||||
[Op.gte]: startDate,
|
||||
},
|
||||
...extraWhere,
|
||||
},
|
||||
group: [field],
|
||||
raw: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function countSince(model, startDate, extraWhere = {}) {
|
||||
return model.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
createdAt: {
|
||||
[Op.gte]: startDate,
|
||||
},
|
||||
...extraWhere,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeGroupedRows(rows) {
|
||||
return rows
|
||||
.map((row) => ({
|
||||
label: row.label || 'Unspecified',
|
||||
value: Number(row.value || 0),
|
||||
}))
|
||||
.filter((row) => row.value > 0)
|
||||
.sort((left, right) => right.value - left.value);
|
||||
}
|
||||
|
||||
function formatRecentProfiles(rows) {
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: [row.firstName, row.lastName].filter(Boolean).join(' ').trim() || row.email,
|
||||
email: row.email,
|
||||
provider: row.provider || 'local',
|
||||
createdAt: row.createdAt,
|
||||
statusLabel: row.disabled ? 'Disabled' : row.emailVerified ? 'Verified' : 'Pending verification',
|
||||
}));
|
||||
}
|
||||
|
||||
function formatRecentProjects(rows) {
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title || 'Untitled project',
|
||||
visibility: row.visibility || 'private',
|
||||
publishedAt: row.published_at,
|
||||
updatedAt: row.updatedAt,
|
||||
statusLabel: row.published_at ? 'Published' : 'Draft setup',
|
||||
}));
|
||||
}
|
||||
|
||||
function formatRecentAgentEndpoints(rows) {
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name || 'Untitled endpoint',
|
||||
provider: row.provider || 'custom',
|
||||
status: row.status || 'inactive',
|
||||
requiresAuth: Boolean(row.requires_auth),
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPublicPermissions() {
|
||||
return Object.keys(PERMISSIONS).reduce((accumulator, key) => {
|
||||
accumulator[key] = true;
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildPermissions(user) {
|
||||
return Object.entries(PERMISSIONS).reduce((accumulator, [key, permissionName]) => {
|
||||
accumulator[key] = hasPermission(user, permissionName);
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildHandoff(authStatus, overviewData) {
|
||||
const items = [
|
||||
{
|
||||
key: 'authentication',
|
||||
label: 'Authentication is enabled',
|
||||
description: 'The current operator can access the MAi Server without a blocked account state.',
|
||||
status: Boolean(authStatus?.isAuthenticationEnabled),
|
||||
value: authStatus?.authenticationStatusLabel || 'Unknown',
|
||||
},
|
||||
{
|
||||
key: 'verification',
|
||||
label: 'Verification path is ready',
|
||||
description: 'Email verification is completed or intentionally bypassed because email is not configured.',
|
||||
status: Boolean(authStatus?.emailVerified || !authStatus?.emailConfigured),
|
||||
value: authStatus?.verificationStatusLabel || 'Unknown',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email delivery is configured',
|
||||
description: 'Operational emails can be sent for verification, password reset, and onboarding.',
|
||||
status: Boolean(authStatus?.emailConfigured),
|
||||
value: authStatus?.emailConfigured ? 'Configured' : 'Not configured',
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
label: 'Project workspace exists',
|
||||
description: 'At least one project exists in the workspace.',
|
||||
status: Number(overviewData.businessSummary.projects || 0) > 0,
|
||||
value: `${overviewData.businessSummary.projects || 0} project(s)`,
|
||||
},
|
||||
{
|
||||
key: 'pages',
|
||||
label: 'Published content is available',
|
||||
description: 'A public-facing page is available for launch or review.',
|
||||
status: Number(overviewData.businessSummary.publishedPages || 0) > 0,
|
||||
value: `${overviewData.businessSummary.publishedPages || 0} published page(s)`,
|
||||
},
|
||||
{
|
||||
key: 'endpoints',
|
||||
label: 'Agent endpoints are connected',
|
||||
description: 'At least one active AI endpoint is available.',
|
||||
status: Number(overviewData.businessSummary.activeAgentEndpoints || 0) > 0,
|
||||
value: `${overviewData.businessSummary.activeAgentEndpoints || 0} active endpoint(s)`,
|
||||
},
|
||||
{
|
||||
key: 'activity',
|
||||
label: 'Usage analytics are flowing',
|
||||
description: 'Conversations or messages are present in the selected reporting window.',
|
||||
status: Number(overviewData.windowSummary.conversations || 0) + Number(overviewData.windowSummary.messages || 0) > 0,
|
||||
value: `${overviewData.windowSummary.conversations || 0} conversations • ${overviewData.windowSummary.messages || 0} messages`,
|
||||
},
|
||||
{
|
||||
key: 'announcements',
|
||||
label: 'Announcement channel is prepared',
|
||||
description: 'Announcements exist for launches, incidents, or product updates.',
|
||||
status: Number(overviewData.summary.announcements || 0) > 0,
|
||||
value: `${overviewData.summary.announcements || 0} announcement(s)`,
|
||||
},
|
||||
];
|
||||
|
||||
const readyCount = items.filter((item) => item.status).length;
|
||||
const totalCount = items.length;
|
||||
|
||||
return {
|
||||
readyCount,
|
||||
totalCount,
|
||||
completionRate: totalCount ? Math.round((readyCount / totalCount) * 100) : 0,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function maskValueIfHidden(value, canRead) {
|
||||
return canRead ? value : null;
|
||||
}
|
||||
|
||||
function maskRowsIfHidden(rows, canRead) {
|
||||
return canRead ? rows : [];
|
||||
}
|
||||
|
||||
async function buildOverviewData(options = {}) {
|
||||
const days = normalizeRange(options.range);
|
||||
const windowDates = buildDateWindow(days);
|
||||
const startDate = windowDates[0];
|
||||
const labels = windowDates.map(formatDateLabel);
|
||||
|
||||
const [
|
||||
totalProfiles,
|
||||
verifiedProfiles,
|
||||
disabledProfiles,
|
||||
totalConversations,
|
||||
totalMessages,
|
||||
totalAnnouncements,
|
||||
totalProjects,
|
||||
totalPages,
|
||||
publishedPages,
|
||||
totalLinkCollections,
|
||||
publicCollections,
|
||||
totalExternalLinks,
|
||||
totalWidgets,
|
||||
activeWidgets,
|
||||
totalAgentEndpoints,
|
||||
activeAgentEndpoints,
|
||||
securedAgentEndpoints,
|
||||
totalMediaChannels,
|
||||
totalMediaStreams,
|
||||
activeMediaStreams,
|
||||
totalAssets,
|
||||
profilesInRange,
|
||||
conversationsInRange,
|
||||
messagesInRange,
|
||||
announcementsInRange,
|
||||
userSeriesRows,
|
||||
conversationSeriesRows,
|
||||
messageSeriesRows,
|
||||
channelRows,
|
||||
announcementRows,
|
||||
conversationStatusRows,
|
||||
messageSenderRows,
|
||||
endpointProviderRows,
|
||||
widgetTypeRows,
|
||||
assetTypeRows,
|
||||
recentProfilesRows,
|
||||
recentProjectsRows,
|
||||
recentAgentEndpointRows,
|
||||
] = await Promise.all([
|
||||
db.users.count({ where: { deletedAt: null } }),
|
||||
db.users.count({ where: { deletedAt: null, emailVerified: true } }),
|
||||
db.users.count({ where: { deletedAt: null, disabled: true } }),
|
||||
db.conversations.count({ where: { deletedAt: null } }),
|
||||
db.messages.count({ where: { deletedAt: null } }),
|
||||
db.announcements.count({ where: { deletedAt: null } }),
|
||||
db.projects.count({ where: { deletedAt: null } }),
|
||||
db.pages.count({ where: { deletedAt: null } }),
|
||||
db.pages.count({ where: { deletedAt: null, status: 'published' } }),
|
||||
db.link_collections.count({ where: { deletedAt: null } }),
|
||||
db.link_collections.count({ where: { deletedAt: null, visibility: 'public' } }),
|
||||
db.external_links.count({ where: { deletedAt: null } }),
|
||||
db.widgets.count({ where: { deletedAt: null } }),
|
||||
db.widgets.count({ where: { deletedAt: null, status: 'active' } }),
|
||||
db.agent_endpoints.count({ where: { deletedAt: null } }),
|
||||
db.agent_endpoints.count({ where: { deletedAt: null, status: 'active' } }),
|
||||
db.agent_endpoints.count({ where: { deletedAt: null, requires_auth: true } }),
|
||||
db.media_channels.count({ where: { deletedAt: null } }),
|
||||
db.media_streams.count({ where: { deletedAt: null } }),
|
||||
db.media_streams.count({ where: { deletedAt: null, status: 'active' } }),
|
||||
db.assets.count({ where: { deletedAt: null } }),
|
||||
countSince(db.users, startDate),
|
||||
countSince(db.conversations, startDate),
|
||||
countSince(db.messages, startDate),
|
||||
countSince(db.announcements, startDate),
|
||||
countByDate(db.users.getTableName(), startDate),
|
||||
countByDate(db.conversations.getTableName(), startDate),
|
||||
countByDate(db.messages.getTableName(), startDate),
|
||||
getGroupedCounts(db.conversations, 'channel', startDate),
|
||||
getGroupedCounts(db.announcements, 'status', startDate),
|
||||
getGroupedCounts(db.conversations, 'status', startDate),
|
||||
getGroupedCounts(db.messages, 'sender_type', startDate),
|
||||
getGroupedCounts(db.agent_endpoints, 'provider', startDate),
|
||||
getGroupedCounts(db.widgets, 'widget_type', startDate),
|
||||
getGroupedCounts(db.assets, 'asset_type', startDate),
|
||||
db.users.findAll({
|
||||
attributes: ['id', 'firstName', 'lastName', 'email', 'provider', 'emailVerified', 'disabled', 'createdAt'],
|
||||
where: {
|
||||
deletedAt: null,
|
||||
createdAt: {
|
||||
[Op.gte]: startDate,
|
||||
},
|
||||
},
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: 6,
|
||||
raw: true,
|
||||
}),
|
||||
db.projects.findAll({
|
||||
attributes: ['id', 'title', 'visibility', 'published_at', 'updatedAt'],
|
||||
where: { deletedAt: null },
|
||||
order: [['updatedAt', 'DESC']],
|
||||
limit: 5,
|
||||
raw: true,
|
||||
}),
|
||||
db.agent_endpoints.findAll({
|
||||
attributes: ['id', 'name', 'provider', 'status', 'requires_auth', 'updatedAt'],
|
||||
where: { deletedAt: null },
|
||||
order: [['updatedAt', 'DESC']],
|
||||
limit: 5,
|
||||
raw: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
range: buildRange(days),
|
||||
summary: {
|
||||
profiles: totalProfiles,
|
||||
verifiedProfiles,
|
||||
disabledProfiles,
|
||||
conversations: totalConversations,
|
||||
messages: totalMessages,
|
||||
announcements: totalAnnouncements,
|
||||
},
|
||||
windowSummary: {
|
||||
profiles: profilesInRange,
|
||||
conversations: conversationsInRange,
|
||||
messages: messagesInRange,
|
||||
announcements: announcementsInRange,
|
||||
},
|
||||
businessSummary: {
|
||||
projects: totalProjects,
|
||||
pages: totalPages,
|
||||
publishedPages,
|
||||
linkCollections: totalLinkCollections,
|
||||
publicCollections,
|
||||
externalLinks: totalExternalLinks,
|
||||
widgets: totalWidgets,
|
||||
activeWidgets,
|
||||
agentEndpoints: totalAgentEndpoints,
|
||||
activeAgentEndpoints,
|
||||
securedAgentEndpoints,
|
||||
mediaChannels: totalMediaChannels,
|
||||
mediaStreams: totalMediaStreams,
|
||||
activeMediaStreams,
|
||||
assets: totalAssets,
|
||||
},
|
||||
charts: {
|
||||
activity: {
|
||||
labels,
|
||||
profiles: mapSeriesToWindow(windowDates, userSeriesRows),
|
||||
conversations: mapSeriesToWindow(windowDates, conversationSeriesRows),
|
||||
messages: mapSeriesToWindow(windowDates, messageSeriesRows),
|
||||
},
|
||||
channels: sanitizeGroupedRows(channelRows),
|
||||
announcementStatus: sanitizeGroupedRows(announcementRows),
|
||||
conversationStatus: sanitizeGroupedRows(conversationStatusRows),
|
||||
messageSenders: sanitizeGroupedRows(messageSenderRows),
|
||||
endpointProviders: sanitizeGroupedRows(endpointProviderRows),
|
||||
widgetTypes: sanitizeGroupedRows(widgetTypeRows),
|
||||
assetTypes: sanitizeGroupedRows(assetTypeRows),
|
||||
},
|
||||
recentProfiles: formatRecentProfiles(recentProfilesRows),
|
||||
recentProjects: formatRecentProjects(recentProjectsRows),
|
||||
recentAgentEndpoints: formatRecentAgentEndpoints(recentAgentEndpointRows),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = class MaiServerService {
|
||||
static async getOverview(currentUser, options = {}) {
|
||||
try {
|
||||
const authStatus = buildAuthStatus(currentUser);
|
||||
const permissions = buildPermissions(currentUser);
|
||||
const overviewData = await buildOverviewData(options);
|
||||
const handoff = buildHandoff(authStatus, overviewData);
|
||||
|
||||
return {
|
||||
authStatus,
|
||||
range: overviewData.range,
|
||||
permissions,
|
||||
summary: {
|
||||
profiles: maskValueIfHidden(overviewData.summary.profiles, permissions.users),
|
||||
verifiedProfiles: maskValueIfHidden(overviewData.summary.verifiedProfiles, permissions.users),
|
||||
disabledProfiles: maskValueIfHidden(overviewData.summary.disabledProfiles, permissions.users),
|
||||
conversations: maskValueIfHidden(overviewData.summary.conversations, permissions.conversations),
|
||||
messages: maskValueIfHidden(overviewData.summary.messages, permissions.messages),
|
||||
announcements: maskValueIfHidden(overviewData.summary.announcements, permissions.announcements),
|
||||
},
|
||||
windowSummary: {
|
||||
profiles: maskValueIfHidden(overviewData.windowSummary.profiles, permissions.users),
|
||||
conversations: maskValueIfHidden(overviewData.windowSummary.conversations, permissions.conversations),
|
||||
messages: maskValueIfHidden(overviewData.windowSummary.messages, permissions.messages),
|
||||
announcements: maskValueIfHidden(overviewData.windowSummary.announcements, permissions.announcements),
|
||||
},
|
||||
businessSummary: {
|
||||
projects: maskValueIfHidden(overviewData.businessSummary.projects, permissions.projects),
|
||||
pages: maskValueIfHidden(overviewData.businessSummary.pages, permissions.pages),
|
||||
publishedPages: maskValueIfHidden(overviewData.businessSummary.publishedPages, permissions.pages),
|
||||
linkCollections: maskValueIfHidden(overviewData.businessSummary.linkCollections, permissions.linkCollections),
|
||||
publicCollections: maskValueIfHidden(overviewData.businessSummary.publicCollections, permissions.linkCollections),
|
||||
externalLinks: maskValueIfHidden(overviewData.businessSummary.externalLinks, permissions.externalLinks),
|
||||
widgets: maskValueIfHidden(overviewData.businessSummary.widgets, permissions.widgets),
|
||||
activeWidgets: maskValueIfHidden(overviewData.businessSummary.activeWidgets, permissions.widgets),
|
||||
agentEndpoints: maskValueIfHidden(overviewData.businessSummary.agentEndpoints, permissions.agentEndpoints),
|
||||
activeAgentEndpoints: maskValueIfHidden(overviewData.businessSummary.activeAgentEndpoints, permissions.agentEndpoints),
|
||||
securedAgentEndpoints: maskValueIfHidden(overviewData.businessSummary.securedAgentEndpoints, permissions.agentEndpoints),
|
||||
mediaChannels: maskValueIfHidden(overviewData.businessSummary.mediaChannels, permissions.mediaChannels),
|
||||
mediaStreams: maskValueIfHidden(overviewData.businessSummary.mediaStreams, permissions.mediaStreams),
|
||||
activeMediaStreams: maskValueIfHidden(overviewData.businessSummary.activeMediaStreams, permissions.mediaStreams),
|
||||
assets: maskValueIfHidden(overviewData.businessSummary.assets, permissions.assets),
|
||||
},
|
||||
charts: {
|
||||
activity: {
|
||||
labels: overviewData.charts.activity.labels,
|
||||
profiles: permissions.users ? overviewData.charts.activity.profiles : [],
|
||||
conversations: permissions.conversations ? overviewData.charts.activity.conversations : [],
|
||||
messages: permissions.messages ? overviewData.charts.activity.messages : [],
|
||||
},
|
||||
channels: maskRowsIfHidden(overviewData.charts.channels, permissions.conversations),
|
||||
announcementStatus: maskRowsIfHidden(overviewData.charts.announcementStatus, permissions.announcements),
|
||||
conversationStatus: maskRowsIfHidden(overviewData.charts.conversationStatus, permissions.conversations),
|
||||
messageSenders: maskRowsIfHidden(overviewData.charts.messageSenders, permissions.messages),
|
||||
endpointProviders: maskRowsIfHidden(overviewData.charts.endpointProviders, permissions.agentEndpoints),
|
||||
widgetTypes: maskRowsIfHidden(overviewData.charts.widgetTypes, permissions.widgets),
|
||||
assetTypes: maskRowsIfHidden(overviewData.charts.assetTypes, permissions.assets),
|
||||
},
|
||||
recentProfiles: maskRowsIfHidden(overviewData.recentProfiles, permissions.users),
|
||||
recentProjects: maskRowsIfHidden(overviewData.recentProjects, permissions.projects),
|
||||
recentAgentEndpoints: maskRowsIfHidden(overviewData.recentAgentEndpoints, permissions.agentEndpoints),
|
||||
handoff,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to build MAi Server overview:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getPublicOverview(options = {}) {
|
||||
try {
|
||||
const overviewData = await buildOverviewData(options);
|
||||
|
||||
return {
|
||||
range: overviewData.range,
|
||||
permissions: buildPublicPermissions(),
|
||||
summary: overviewData.summary,
|
||||
windowSummary: overviewData.windowSummary,
|
||||
businessSummary: overviewData.businessSummary,
|
||||
charts: overviewData.charts,
|
||||
handoff: buildHandoff(
|
||||
{
|
||||
emailConfigured: EmailSender.isConfigured,
|
||||
emailVerified: false,
|
||||
isAuthenticationEnabled: EmailSender.isConfigured,
|
||||
authenticationStatusLabel: EmailSender.isConfigured ? 'Operational auth flow ready' : 'Auth flow needs mail setup',
|
||||
verificationStatusLabel: EmailSender.isConfigured ? 'Visitor verification can be completed' : 'Verification email disabled',
|
||||
},
|
||||
overviewData,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to build public MAi Server overview:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async enableAuthentication(currentUser, host) {
|
||||
try {
|
||||
if (currentUser?.disabled) {
|
||||
await UsersService.update(
|
||||
{
|
||||
disabled: false,
|
||||
emailVerified: currentUser.emailVerified,
|
||||
},
|
||||
currentUser.id,
|
||||
currentUser,
|
||||
);
|
||||
}
|
||||
|
||||
let verificationEmailSent = false;
|
||||
|
||||
if (!currentUser?.emailVerified && EmailSender.isConfigured) {
|
||||
await AuthService.sendEmailAddressVerificationEmail(currentUser.email, host);
|
||||
verificationEmailSent = true;
|
||||
}
|
||||
|
||||
const refreshedUser = await UsersDBApi.findBy({ id: currentUser.id });
|
||||
|
||||
return {
|
||||
authStatus: buildAuthStatus(refreshedUser),
|
||||
verificationEmailSent,
|
||||
message: verificationEmailSent
|
||||
? 'Authentication is enabled and a verification email was sent.'
|
||||
: 'Authentication is enabled for this account.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to enable MAi Server authentication:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async sendVerificationEmail(currentUser, host) {
|
||||
try {
|
||||
if (currentUser?.emailVerified) {
|
||||
const refreshedUser = await UsersDBApi.findBy({ id: currentUser.id });
|
||||
|
||||
return {
|
||||
authStatus: buildAuthStatus(refreshedUser),
|
||||
verificationEmailSent: false,
|
||||
message: 'This account is already verified.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!EmailSender.isConfigured) {
|
||||
const refreshedUser = await UsersDBApi.findBy({ id: currentUser.id });
|
||||
|
||||
return {
|
||||
authStatus: buildAuthStatus(refreshedUser),
|
||||
verificationEmailSent: false,
|
||||
message: 'Email delivery is not configured for this environment.',
|
||||
};
|
||||
}
|
||||
|
||||
await AuthService.sendEmailAddressVerificationEmail(currentUser.email, host);
|
||||
const refreshedUser = await UsersDBApi.findBy({ id: currentUser.id });
|
||||
|
||||
return {
|
||||
authStatus: buildAuthStatus(refreshedUser),
|
||||
verificationEmailSent: true,
|
||||
message: 'Verification email sent successfully.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to send MAi Server verification email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async sendPasswordResetEmail(currentUser, host) {
|
||||
try {
|
||||
if (!EmailSender.isConfigured) {
|
||||
return {
|
||||
emailSent: false,
|
||||
message: 'Email delivery is not configured for this environment.',
|
||||
};
|
||||
}
|
||||
|
||||
await AuthService.sendPasswordResetEmail(currentUser.email, 'register', host);
|
||||
|
||||
return {
|
||||
emailSent: true,
|
||||
message: 'Password reset email sent successfully.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to send MAi Server password reset email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -17,6 +17,14 @@ trailingSlash: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/mai-server-public',
|
||||
destination: '/web_pages/mai-server',
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
remotePatterns: [
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
245
frontend/src/helpers/modularInteractionHub.ts
Normal file
245
frontend/src/helpers/modularInteractionHub.ts
Normal file
@ -0,0 +1,245 @@
|
||||
export type HubLinkType = 'agent' | 'demo' | 'form' | 'social' | 'media' | 'enter' | 'lovable';
|
||||
|
||||
export type HubLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
type: HubLinkType;
|
||||
summary: string;
|
||||
eyebrow: string;
|
||||
accent: string;
|
||||
};
|
||||
|
||||
export type MediaPresetType = 'radio' | 'tv';
|
||||
export type MediaPresetMode = 'audio' | 'video' | 'embed';
|
||||
export type MediaPresetSourceKind = 'manual' | 'imported' | 'uploaded';
|
||||
|
||||
export type MediaPreset = {
|
||||
id: string;
|
||||
type: MediaPresetType;
|
||||
title: string;
|
||||
url: string;
|
||||
notes: string;
|
||||
mode: MediaPresetMode;
|
||||
isSample?: boolean;
|
||||
sourceKind?: MediaPresetSourceKind;
|
||||
privateUrl?: string;
|
||||
originalFilename?: string;
|
||||
durationSeconds?: number;
|
||||
thumbnailUrl?: string;
|
||||
folder?: string;
|
||||
remotePlaylistUrl?: string;
|
||||
remoteRefreshIntervalMinutes?: number;
|
||||
lastRemoteRefreshAt?: string;
|
||||
};
|
||||
|
||||
export type AdminShortcut = {
|
||||
title: string;
|
||||
href: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export const modularInteractionLinks: HubLink[] = [
|
||||
{
|
||||
id: 'agent-prof-aliyo',
|
||||
title: 'Prof Aliyo Momot AI Agent',
|
||||
url: 'https://prof-aliyo-momot-ai-agent.onhercules.app/agent',
|
||||
type: 'agent',
|
||||
eyebrow: 'AI agent',
|
||||
summary: 'Primary conversational entry point for visitors who want guided AI interaction.',
|
||||
accent: 'from-fuchsia-500/20 via-violet-500/10 to-cyan-400/20',
|
||||
},
|
||||
{
|
||||
id: 'agent-frame-smith',
|
||||
title: 'Frame Smith AI',
|
||||
url: 'https://frame-smith-ai.lovable.app',
|
||||
type: 'agent',
|
||||
eyebrow: 'Creative workflow',
|
||||
summary: 'A second AI experience positioned for making, framing, and ideation workflows.',
|
||||
accent: 'from-sky-500/20 via-cyan-500/10 to-emerald-400/20',
|
||||
},
|
||||
{
|
||||
id: 'demo-aliyo-media',
|
||||
title: 'Aliyo Momot Media Demo',
|
||||
url: 'https://aliyo-momot-media-fb6f.dev.flatlogic.app/?stream=d5ade7f8-7b12-4b2b-997e-4f1de9180e9b',
|
||||
type: 'media',
|
||||
eyebrow: 'Media experience',
|
||||
summary: 'Existing demo destination for media-first storytelling and streaming discovery.',
|
||||
accent: 'from-amber-500/20 via-orange-500/10 to-rose-400/20',
|
||||
},
|
||||
{
|
||||
id: 'demo-copyright-revealer',
|
||||
title: 'Copyright Revealer',
|
||||
url: 'https://copyright-revealer-cc67.dev.flatlogic.app',
|
||||
type: 'demo',
|
||||
eyebrow: 'Specialized demo',
|
||||
summary: 'A supporting utility demo that expands the modular toolset around your concept.',
|
||||
accent: 'from-indigo-500/20 via-blue-500/10 to-cyan-400/20',
|
||||
},
|
||||
{
|
||||
id: 'form-1',
|
||||
title: 'Fillout Intake 01',
|
||||
url: 'https://build.fillout.com/use/fwxccezugw',
|
||||
type: 'form',
|
||||
eyebrow: 'Fillout form',
|
||||
summary: 'Capture structured visitor input without forcing them to leave your ecosystem.',
|
||||
accent: 'from-lime-500/20 via-emerald-500/10 to-cyan-400/20',
|
||||
},
|
||||
{
|
||||
id: 'form-2',
|
||||
title: 'Fillout Intake 02',
|
||||
url: 'https://build.fillout.com/use/fouwuywqjg',
|
||||
type: 'form',
|
||||
eyebrow: 'Fillout form',
|
||||
summary: 'Useful for onboarding, submissions, or modular requests from the public.',
|
||||
accent: 'from-purple-500/20 via-pink-500/10 to-rose-400/20',
|
||||
},
|
||||
{
|
||||
id: 'form-3',
|
||||
title: 'Fillout Intake 03',
|
||||
url: 'https://build.fillout.com/use/1e7hvg7j95',
|
||||
type: 'form',
|
||||
eyebrow: 'Fillout form',
|
||||
summary: 'A third intake surface that keeps lead capture visible inside the link dashboard.',
|
||||
accent: 'from-cyan-500/20 via-blue-500/10 to-violet-400/20',
|
||||
},
|
||||
{
|
||||
id: 'form-4',
|
||||
title: 'Fillout Intake 04',
|
||||
url: 'https://build.fillout.com/use/j1yzoiedhq',
|
||||
type: 'form',
|
||||
eyebrow: 'Fillout form',
|
||||
summary: 'Additional form endpoint for follow-ups, media submissions, or campaign flows.',
|
||||
accent: 'from-pink-500/20 via-rose-500/10 to-orange-400/20',
|
||||
},
|
||||
{
|
||||
id: 'enter-prod-3cb9aeee',
|
||||
title: 'Prod Enter App · 3cb9aeee',
|
||||
url: 'https://3cb9aeeed04e4167addb24e60d864cc6.prod.enter.pro',
|
||||
type: 'enter',
|
||||
eyebrow: 'prod.enter.pro',
|
||||
summary: 'Hosted prod.enter.pro destination grouped with your other externally deployed app experiences.',
|
||||
accent: 'from-cyan-500/20 via-sky-500/10 to-blue-400/20',
|
||||
},
|
||||
{
|
||||
id: 'enter-prod-44fc0d2f',
|
||||
title: 'Prod Enter App · 44fc0d2f',
|
||||
url: 'https://44fc0d2f36b543c98b028bba0f8f1602.prod.enter.pro',
|
||||
type: 'enter',
|
||||
eyebrow: 'prod.enter.pro',
|
||||
summary: 'Second prod.enter.pro deployment added to the public switchboard for quick launch access.',
|
||||
accent: 'from-blue-500/20 via-indigo-500/10 to-cyan-400/20',
|
||||
},
|
||||
{
|
||||
id: 'lovable-instant-ai-player',
|
||||
title: 'Instant AI Player',
|
||||
url: 'https://instant-ai-player.lovable.app',
|
||||
type: 'lovable',
|
||||
eyebrow: 'lovable.app',
|
||||
summary: 'Lovable-hosted AI player experience surfaced alongside your other public experiments.',
|
||||
accent: 'from-fuchsia-500/20 via-pink-500/10 to-rose-400/20',
|
||||
},
|
||||
{
|
||||
id: 'lovable-art-audio-vision',
|
||||
title: 'Art Audio Vision',
|
||||
url: 'https://art-audio-vision.lovable.app',
|
||||
type: 'lovable',
|
||||
eyebrow: 'lovable.app',
|
||||
summary: 'Art-focused audio and visual destination grouped into the Lovable collection on the hub.',
|
||||
accent: 'from-amber-500/20 via-orange-500/10 to-pink-400/20',
|
||||
},
|
||||
{
|
||||
id: 'lovable-artful-interaction-os',
|
||||
title: 'Artful Interaction OS',
|
||||
url: 'https://artful-interaction-os.lovable.app',
|
||||
type: 'lovable',
|
||||
eyebrow: 'lovable.app',
|
||||
summary: 'Interaction-oriented Lovable app included for quick switching between related creative tools.',
|
||||
accent: 'from-violet-500/20 via-purple-500/10 to-cyan-400/20',
|
||||
},
|
||||
{
|
||||
id: 'lovable-melody-stream-co',
|
||||
title: 'Melody Stream Co',
|
||||
url: 'https://melody-stream-co.lovable.app',
|
||||
type: 'lovable',
|
||||
eyebrow: 'lovable.app',
|
||||
summary: 'Streaming-focused Lovable destination collected with the rest of your hosted public links.',
|
||||
accent: 'from-emerald-500/20 via-teal-500/10 to-cyan-400/20',
|
||||
},
|
||||
{
|
||||
id: 'social-facebook',
|
||||
title: 'Facebook Community Profile',
|
||||
url: 'https://www.facebook.com/profile.php?id=61567817647812',
|
||||
type: 'social',
|
||||
eyebrow: 'Social presence',
|
||||
summary: 'A community touchpoint for followers arriving from Facebook and public promotion.',
|
||||
accent: 'from-blue-500/20 via-indigo-500/10 to-violet-400/20',
|
||||
},
|
||||
];
|
||||
|
||||
export const starterMediaPresets: MediaPreset[] = [
|
||||
{
|
||||
id: 'sample-radio',
|
||||
type: 'radio',
|
||||
title: 'Sample Radio Placeholder',
|
||||
url: 'https://samplelib.com/lib/preview/mp3/sample-12s.mp3',
|
||||
notes: 'Replace this with your live radio stream URL when you are ready.',
|
||||
mode: 'audio',
|
||||
isSample: true,
|
||||
sourceKind: 'manual',
|
||||
},
|
||||
{
|
||||
id: 'sample-tv',
|
||||
type: 'tv',
|
||||
title: 'Sample TV Placeholder',
|
||||
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
|
||||
notes: 'This placeholder shows the TV widget layout until you paste a real embed or video stream.',
|
||||
mode: 'video',
|
||||
isSample: true,
|
||||
sourceKind: 'manual',
|
||||
},
|
||||
];
|
||||
|
||||
export const adminShortcuts: AdminShortcut[] = [
|
||||
{
|
||||
title: 'Admin interface',
|
||||
href: '/login',
|
||||
summary: 'Sign in to manage all generated CRUD screens and protected content modules.',
|
||||
},
|
||||
{
|
||||
title: 'External links manager',
|
||||
href: '/external_links/external_links-list',
|
||||
summary: 'Use the existing CRUD to curate agents, demos, forms, and social links.',
|
||||
},
|
||||
{
|
||||
title: 'Media streams manager',
|
||||
href: '/media_streams/media_streams-list',
|
||||
summary: 'Manage stream records for radio and television sources inside the admin area.',
|
||||
},
|
||||
{
|
||||
title: 'Widgets manager',
|
||||
href: '/widgets/widgets-list',
|
||||
summary: 'Configure reusable radio, TV, AI, and embed widgets for future authenticated workflows.',
|
||||
},
|
||||
];
|
||||
|
||||
export const hubHighlights = [
|
||||
{
|
||||
title: 'Modular by design',
|
||||
description: 'Visitors can move between AI, media, and forms without losing the overall story.',
|
||||
},
|
||||
{
|
||||
title: 'Public-friendly',
|
||||
description: 'The front door is open, branded, and focused on exploration instead of admin jargon.',
|
||||
},
|
||||
{
|
||||
title: 'Admin-ready',
|
||||
description: 'Existing CRUD screens stay in place so you can manage content once you log in.',
|
||||
},
|
||||
];
|
||||
|
||||
export const experienceSteps = [
|
||||
'Open the public landing page and understand the Modular Artificial Interaction concept at a glance.',
|
||||
'Jump into the interaction hub to browse AI agents, demos, forms, and community links.',
|
||||
'Preview radio or TV in-page, or paste your own stream URL and save it as a preset in this browser.',
|
||||
];
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
@ -87,6 +86,7 @@ export default function LayoutAuthenticated({
|
||||
|
||||
|
||||
const layoutAsidePadding = 'xl:pl-60'
|
||||
const leadershipTitle = 'Prof. Dr. Aliyo Momot — CEO & Founder'
|
||||
|
||||
return (
|
||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||
@ -111,6 +111,9 @@ export default function LayoutAuthenticated({
|
||||
>
|
||||
<BaseIcon path={mdiMenu} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<div className="hidden xl:flex items-center px-3 text-sm font-semibold text-slate-600 whitespace-nowrap dark:text-slate-300">
|
||||
{leadershipTitle}
|
||||
</div>
|
||||
<NavBarItemPlain useMargin>
|
||||
<Search />
|
||||
</NavBarItemPlain>
|
||||
@ -122,7 +125,11 @@ export default function LayoutAuthenticated({
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
/>
|
||||
{children}
|
||||
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
||||
<FooterBar>
|
||||
<span>Hand-crafted & Made with ❤️</span>
|
||||
<span className="mx-2 hidden md:inline">•</span>
|
||||
<span className="block md:inline">{leadershipTitle}</span>
|
||||
</FooterBar>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -7,6 +7,13 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
href: '/interaction-hub',
|
||||
label: 'Interaction hub',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAtomVariant' in icon ? icon['mdiAtomVariant' as keyof typeof icon] : icon.mdiBroadcast ?? icon.mdiViewDashboardOutline,
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
@ -128,6 +135,11 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: 'mdiFolderMultipleImage' in icon ? icon['mdiFolderMultipleImage' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ASSETS'
|
||||
},
|
||||
{
|
||||
href: '/mai-server',
|
||||
label: 'MAi Server',
|
||||
icon: icon.mdiServer,
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profile',
|
||||
|
||||
@ -1,166 +1,348 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
mdiArrowRight,
|
||||
mdiBroadcast,
|
||||
mdiCubeOutline,
|
||||
mdiLogin,
|
||||
mdiOpenInNew,
|
||||
mdiPlayCircleOutline,
|
||||
mdiRobotOutline,
|
||||
mdiTelevisionPlay,
|
||||
mdiViewDashboardOutline,
|
||||
} from '@mdi/js';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
import {
|
||||
adminShortcuts,
|
||||
experienceSteps,
|
||||
hubHighlights,
|
||||
modularInteractionLinks,
|
||||
} from '../helpers/modularInteractionHub';
|
||||
|
||||
const moduleCards = [
|
||||
{
|
||||
title: 'AI entry points',
|
||||
description: 'Launch your existing agents from one branded, public-facing destination.',
|
||||
icon: mdiRobotOutline,
|
||||
},
|
||||
{
|
||||
title: 'Link switchboard',
|
||||
description: 'Show demos, Fillout flows, and social destinations without the page feeling scattered.',
|
||||
icon: mdiCubeOutline,
|
||||
},
|
||||
{
|
||||
title: 'Radio + TV widgets',
|
||||
description: 'Give visitors in-page media playback with presets and future room for live streams.',
|
||||
icon: mdiBroadcast,
|
||||
},
|
||||
{
|
||||
title: 'Admin-ready modules',
|
||||
description: 'Keep the generated admin, CRUD, and permissions setup intact for future growth.',
|
||||
icon: mdiViewDashboardOutline,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const featuredLinks = modularInteractionLinks.slice(0, 6);
|
||||
|
||||
const title = 'Modular Interaction Hub'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
const statCards = [
|
||||
{ value: '2', label: 'AI experiences' },
|
||||
{ value: '4', label: 'Fillout entry forms' },
|
||||
{ value: '2', label: 'Media widgets' },
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Modular Artificial Interaction')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A public-facing AI and media hub for Modular Artificial Interaction, with agent links, forms, and radio/TV widgets."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Modular Interaction Hub app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center '>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<div className="min-h-screen bg-[#050816] text-white">
|
||||
<div className="absolute inset-x-0 top-0 -z-0 h-[38rem] bg-[radial-gradient(circle_at_top,_rgba(96,165,250,0.28),_transparent_42%),radial-gradient(circle_at_30%_30%,_rgba(217,70,239,0.24),_transparent_32%),linear-gradient(180deg,_#0a1024,_#050816)]" />
|
||||
|
||||
<header className="sticky top-0 z-20 border-b border-white/10 bg-[#050816]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-cyan-400/40 bg-cyan-400/10 text-cyan-200 shadow-[0_0_40px_rgba(34,211,238,0.15)]">
|
||||
<BaseIcon path={mdiCubeOutline} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-cyan-200/80">Modular Artificial</p>
|
||||
<p className="text-lg font-semibold tracking-wide text-white">Interaction Hub</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-8 text-sm text-slate-300 md:flex">
|
||||
<a href="#concept" className="transition hover:text-white">
|
||||
Concept
|
||||
</a>
|
||||
<a href="#modules" className="transition hover:text-white">
|
||||
Modules
|
||||
</a>
|
||||
<a href="#switchboard" className="transition hover:text-white">
|
||||
Switchboard
|
||||
</a>
|
||||
<a href="#admin" className="transition hover:text-white">
|
||||
Admin
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<BaseButton href="/login" label="Login" color="whiteDark" className="border-white/10" />
|
||||
<BaseButton href="/interaction-hub" label="Open hub" color="info" />
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<main className="relative z-10">
|
||||
<section className="mx-auto grid max-w-7xl gap-12 px-6 pb-16 pt-16 lg:grid-cols-[1.15fr_0.85fr] lg:pb-24 lg:pt-24">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-fuchsia-400/25 bg-white/5 px-4 py-2 text-sm text-fuchsia-100 shadow-[0_0_30px_rgba(217,70,239,0.12)]">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
Beautiful public front door for AI, links, and media
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<h1 className="max-w-4xl text-5xl font-semibold leading-tight text-white md:text-6xl">
|
||||
Present <span className="text-cyan-300">Modular Artificial Interaction</span> as one living experience.
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg leading-8 text-slate-300 md:text-xl">
|
||||
This first slice turns the starter app into a polished public hub: your AI agents,
|
||||
demos, Fillout links, and radio/TV widgets now feel connected, intentional, and ready
|
||||
for visitors coming from social or direct links.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<BaseButton href="/interaction-hub" label="Launch interaction hub" icon={mdiArrowRight} color="info" />
|
||||
<BaseButton href="/login" label="Admin interface" icon={mdiLogin} color="whiteDark" className="border-white/10" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{statCards.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl"
|
||||
>
|
||||
<div className="text-3xl font-semibold text-white">{item.value}</div>
|
||||
<div className="mt-2 text-sm uppercase tracking-[0.22em] text-slate-400">
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-[2rem] bg-gradient-to-br from-cyan-500/20 via-fuchsia-500/10 to-transparent blur-2xl" />
|
||||
<div className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-slate-950/75 p-6 shadow-[0_30px_80px_rgba(15,23,42,0.6)] backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-5">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-cyan-200/70">Live concept board</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">How the experience feels</h2>
|
||||
</div>
|
||||
<div className="rounded-full border border-emerald-400/40 bg-emerald-400/10 px-3 py-1 text-xs uppercase tracking-[0.2em] text-emerald-200">
|
||||
Public ready
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{hubHighlights.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-3xl border border-white/10 bg-white/5 p-5 text-left"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-white/10 text-cyan-200">
|
||||
<span className="text-sm font-semibold">0{index + 1}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white">{item.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-7 text-slate-300">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="concept" className="mx-auto max-w-7xl px-6 py-6">
|
||||
<div className="rounded-[2rem] border border-white/10 bg-white/[0.03] p-8 shadow-[0_20px_60px_rgba(15,23,42,0.4)]">
|
||||
<div className="grid gap-8 lg:grid-cols-[0.95fr_1.05fr] lg:items-center">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Concept</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
|
||||
A modular stack for interaction, intake, and media.
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-300">
|
||||
Instead of sending people to scattered tools, the app now frames everything as a
|
||||
coherent digital venue: AI assistance, form-based capture, media playback, and
|
||||
curated launch points are all organized under one calm, futuristic visual system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{experienceSteps.map((step, index) => (
|
||||
<div key={step} className="rounded-3xl border border-white/10 bg-slate-900/60 p-5">
|
||||
<div className="mb-4 text-sm uppercase tracking-[0.24em] text-fuchsia-200/80">
|
||||
Step {index + 1}
|
||||
</div>
|
||||
<p className="text-sm leading-7 text-slate-300">{step}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="modules" className="mx-auto max-w-7xl px-6 py-16">
|
||||
<div className="mb-10 max-w-3xl">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Modules</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
|
||||
The first branded slice is more than a landing page.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
{moduleCards.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-[1.75rem] border border-white/10 bg-white/[0.04] p-6 transition duration-200 hover:-translate-y-1 hover:border-cyan-300/30 hover:bg-white/[0.06]"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-cyan-400/10 text-cyan-200">
|
||||
<BaseIcon path={item.icon} size={22} />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-medium text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="switchboard" className="mx-auto max-w-7xl px-6 py-2">
|
||||
<div className="mb-10 flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Switchboard preview</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
|
||||
Curated launch cards for the tools you already have.
|
||||
</h2>
|
||||
</div>
|
||||
<BaseButton href="/interaction-hub" label="See the full hub" icon={mdiArrowRight} color="whiteDark" className="border-white/10" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{featuredLinks.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`group overflow-hidden rounded-[1.75rem] border border-white/10 bg-gradient-to-br ${item.accent} p-[1px] transition hover:-translate-y-1 hover:border-white/20`}
|
||||
>
|
||||
<div className="flex h-full flex-col rounded-[1.7rem] bg-slate-950/90 p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.22em] text-slate-300">
|
||||
{item.eyebrow}
|
||||
</span>
|
||||
<BaseIcon path={mdiOpenInNew} size={18} className="text-slate-400 transition group-hover:text-white" />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-medium text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.summary}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-6 py-16">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-[2rem] border border-white/10 bg-gradient-to-br from-cyan-500/10 via-slate-950 to-slate-950 p-8">
|
||||
<div className="flex items-center gap-3 text-cyan-200">
|
||||
<BaseIcon path={mdiPlayCircleOutline} size={24} />
|
||||
<p className="text-sm uppercase tracking-[0.25em]">Radio widget</p>
|
||||
</div>
|
||||
<h3 className="mt-5 text-2xl font-semibold text-white">In-page listening for your audience</h3>
|
||||
<p className="mt-4 max-w-xl text-sm leading-7 text-slate-300">
|
||||
The hub includes a radio player area with browser-saved presets, so you can start with
|
||||
placeholders now and swap in real stream URLs later.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[2rem] border border-white/10 bg-gradient-to-br from-fuchsia-500/10 via-slate-950 to-slate-950 p-8">
|
||||
<div className="flex items-center gap-3 text-fuchsia-200">
|
||||
<BaseIcon path={mdiTelevisionPlay} size={24} />
|
||||
<p className="text-sm uppercase tracking-[0.25em]">TV widget</p>
|
||||
</div>
|
||||
<h3 className="mt-5 text-2xl font-semibold text-white">Flexible embed or video preview</h3>
|
||||
<p className="mt-4 max-w-xl text-sm leading-7 text-slate-300">
|
||||
Visitors can stay on your site while watching a sample video or a future live embed,
|
||||
giving the experience a richer broadcast identity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="admin" className="mx-auto max-w-7xl px-6 pb-20 pt-4">
|
||||
<div className="rounded-[2rem] border border-white/10 bg-white/[0.04] p-8">
|
||||
<div className="mb-8 max-w-3xl">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">Admin continuity</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white md:text-4xl">
|
||||
Keep the admin power you already have.
|
||||
</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||
The new public experience works as the front door, while the generated admin remains the
|
||||
control room for managing links, streams, widgets, and future content.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
{adminShortcuts.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="rounded-[1.5rem] border border-white/10 bg-slate-950/70 p-6 transition hover:-translate-y-1 hover:border-cyan-300/30"
|
||||
>
|
||||
<h3 className="text-lg font-medium text-white">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.summary}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-white/10 bg-slate-950/80">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-6 py-8 text-sm text-slate-400 md:flex-row md:items-center md:justify-between">
|
||||
<p>© 2026 Modular Artificial Interaction Hub. Built as a public AI + media launch surface.</p>
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<Link href="/interaction-hub" className="transition hover:text-white">
|
||||
Open hub
|
||||
</Link>
|
||||
<Link href="/login" className="transition hover:text-white">
|
||||
Login
|
||||
</Link>
|
||||
<Link href="/privacy-policy" className="transition hover:text-white">
|
||||
Privacy policy
|
||||
</Link>
|
||||
<Link href="/terms-of-use" className="transition hover:text-white">
|
||||
Terms
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
2517
frontend/src/pages/interaction-hub.tsx
Normal file
2517
frontend/src/pages/interaction-hub.tsx
Normal file
File diff suppressed because it is too large
Load Diff
982
frontend/src/pages/mai-server.tsx
Normal file
982
frontend/src/pages/mai-server.tsx
Normal file
@ -0,0 +1,982 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import Head from 'next/head';
|
||||
import React from 'react';
|
||||
import { Bar, Line } from 'react-chartjs-2';
|
||||
import type { ReactElement } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import FormField from '../components/FormField';
|
||||
import NotificationBar from '../components/NotificationBar';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { findMe } from '../stores/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler);
|
||||
|
||||
type AuthStatus = {
|
||||
displayName: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
roleName: string;
|
||||
country: string;
|
||||
emailVerified: boolean;
|
||||
disabled: boolean;
|
||||
emailConfigured: boolean;
|
||||
isAuthenticationEnabled: boolean;
|
||||
authenticationStatusLabel: string;
|
||||
verificationStatusLabel: string;
|
||||
actionLabel: string;
|
||||
};
|
||||
|
||||
type Summary = {
|
||||
profiles: number | null;
|
||||
verifiedProfiles: number | null;
|
||||
disabledProfiles: number | null;
|
||||
conversations: number | null;
|
||||
messages: number | null;
|
||||
announcements: number | null;
|
||||
};
|
||||
|
||||
type WindowSummary = {
|
||||
profiles: number | null;
|
||||
conversations: number | null;
|
||||
messages: number | null;
|
||||
announcements: number | null;
|
||||
};
|
||||
|
||||
type BusinessSummary = {
|
||||
projects: number | null;
|
||||
pages: number | null;
|
||||
publishedPages: number | null;
|
||||
linkCollections: number | null;
|
||||
publicCollections: number | null;
|
||||
externalLinks: number | null;
|
||||
widgets: number | null;
|
||||
activeWidgets: number | null;
|
||||
agentEndpoints: number | null;
|
||||
activeAgentEndpoints: number | null;
|
||||
securedAgentEndpoints: number | null;
|
||||
mediaChannels: number | null;
|
||||
mediaStreams: number | null;
|
||||
activeMediaStreams: number | null;
|
||||
assets: number | null;
|
||||
};
|
||||
|
||||
type ChartBucket = Array<{ label: string; value: number }>;
|
||||
|
||||
type Charts = {
|
||||
activity: {
|
||||
labels: string[];
|
||||
profiles: number[];
|
||||
conversations: number[];
|
||||
messages: number[];
|
||||
};
|
||||
channels: ChartBucket;
|
||||
announcementStatus: ChartBucket;
|
||||
conversationStatus: ChartBucket;
|
||||
messageSenders: ChartBucket;
|
||||
endpointProviders: ChartBucket;
|
||||
widgetTypes: ChartBucket;
|
||||
assetTypes: ChartBucket;
|
||||
};
|
||||
|
||||
type HandoffItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
status: boolean;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type Overview = {
|
||||
authStatus: AuthStatus;
|
||||
range: {
|
||||
days: number;
|
||||
label: string;
|
||||
};
|
||||
permissions: Record<string, boolean>;
|
||||
summary: Summary;
|
||||
windowSummary: WindowSummary;
|
||||
businessSummary: BusinessSummary;
|
||||
charts: Charts;
|
||||
recentProfiles: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
createdAt: string;
|
||||
statusLabel: string;
|
||||
}>;
|
||||
recentProjects: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
visibility: string;
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
statusLabel: string;
|
||||
}>;
|
||||
recentAgentEndpoints: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
requiresAuth: boolean;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
handoff: {
|
||||
readyCount: number;
|
||||
totalCount: number;
|
||||
completionRate: number;
|
||||
items: HandoffItem[];
|
||||
};
|
||||
};
|
||||
|
||||
type ActionTone = 'success' | 'danger' | 'info';
|
||||
|
||||
const rangeOptions = [7, 30, 90];
|
||||
|
||||
const chartPalette = ['#38bdf8', '#34d399', '#f59e0b', '#a78bfa', '#fb7185', '#22c55e', '#f97316', '#06b6d4'];
|
||||
|
||||
function formatMetric(value: number | null | undefined) {
|
||||
if (value === null || value === undefined) {
|
||||
return 'Private';
|
||||
}
|
||||
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return 'Not available';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getNotificationColor(tone: ActionTone) {
|
||||
if (tone === 'success') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (tone === 'danger') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function buildBarData(title: string, rows: ChartBucket) {
|
||||
return {
|
||||
labels: rows.map((row) => row.label),
|
||||
datasets: [
|
||||
{
|
||||
label: title,
|
||||
data: rows.map((row) => row.value),
|
||||
backgroundColor: rows.map((_, index) => chartPalette[index % chartPalette.length]),
|
||||
borderRadius: 12,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getBarOptions() {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
iconPath,
|
||||
accent,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
subtitle: string;
|
||||
iconPath: string;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<CardBox className={`border border-white/10 bg-gradient-to-br ${accent} text-white`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-300">{title}</p>
|
||||
<p className="mt-4 text-3xl font-semibold">{value}</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{subtitle}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/10 p-3">
|
||||
<BaseIcon path={iconPath} size={26} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
}
|
||||
|
||||
function BarChartCard({
|
||||
title,
|
||||
description,
|
||||
rows,
|
||||
emptyLabel,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
rows: ChartBucket;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">{title}</p>
|
||||
<h3 className="mt-1 text-xl font-semibold">{description}</h3>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
|
||||
<BaseIcon path={icon.mdiChartDonut} size={22} />
|
||||
</div>
|
||||
</div>
|
||||
{rows.length ? (
|
||||
<div className="h-72">
|
||||
<Bar data={buildBarData(title, rows)} options={getBarOptions()} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 px-4 py-10 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
);
|
||||
}
|
||||
|
||||
const MaiServerPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
|
||||
const [selectedRange, setSelectedRange] = React.useState(30);
|
||||
const [overview, setOverview] = React.useState<Overview | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [loadError, setLoadError] = React.useState('');
|
||||
const [actionTone, setActionTone] = React.useState<ActionTone>('info');
|
||||
const [actionMessage, setActionMessage] = React.useState('');
|
||||
|
||||
const [profileValues, setProfileValues] = React.useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phoneNumber: '',
|
||||
});
|
||||
const [passwordValues, setPasswordValues] = React.useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const [isProfileSaving, setIsProfileSaving] = React.useState(false);
|
||||
const [isPasswordSaving, setIsPasswordSaving] = React.useState(false);
|
||||
const [isEnabling, setIsEnabling] = React.useState(false);
|
||||
const [isSendingVerification, setIsSendingVerification] = React.useState(false);
|
||||
const [isSendingReset, setIsSendingReset] = React.useState(false);
|
||||
|
||||
const loadOverview = React.useCallback(async (range = selectedRange) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setLoadError('');
|
||||
const response = await axios.get('/mai-server/overview', {
|
||||
params: { range },
|
||||
});
|
||||
setOverview(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load MAi Server overview', error);
|
||||
setLoadError('Unable to load the MAi Server dashboard right now.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedRange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadOverview(selectedRange);
|
||||
}, [loadOverview, selectedRange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setProfileValues({
|
||||
firstName: currentUser?.firstName || '',
|
||||
lastName: currentUser?.lastName || '',
|
||||
phoneNumber: currentUser?.phoneNumber || '',
|
||||
});
|
||||
}, [currentUser]);
|
||||
|
||||
const showAction = React.useCallback((tone: ActionTone, message: string) => {
|
||||
setActionTone(tone);
|
||||
setActionMessage(message);
|
||||
}, []);
|
||||
|
||||
const refreshAuthContext = React.useCallback(async () => {
|
||||
await dispatch(findMe());
|
||||
await loadOverview(selectedRange);
|
||||
}, [dispatch, loadOverview, selectedRange]);
|
||||
|
||||
const handleProfileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setProfileValues((previous) => ({
|
||||
...previous,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setPasswordValues((previous) => ({
|
||||
...previous,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveProfile = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!currentUser?.id) {
|
||||
showAction('danger', 'Your account details are not ready yet. Please refresh and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsProfileSaving(true);
|
||||
await axios.put(`/users/${currentUser.id}`, {
|
||||
id: currentUser.id,
|
||||
data: {
|
||||
firstName: profileValues.firstName,
|
||||
lastName: profileValues.lastName,
|
||||
phoneNumber: profileValues.phoneNumber,
|
||||
},
|
||||
});
|
||||
await refreshAuthContext();
|
||||
showAction('success', 'Profile details updated successfully.');
|
||||
} catch (error) {
|
||||
console.error('Failed to save MAi Server profile', error);
|
||||
showAction('danger', 'Unable to save profile details right now.');
|
||||
} finally {
|
||||
setIsProfileSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!passwordValues.currentPassword || !passwordValues.newPassword) {
|
||||
showAction('danger', 'Enter your current password and a new password to continue.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordValues.newPassword !== passwordValues.confirmPassword) {
|
||||
showAction('danger', 'The confirmation password does not match the new password.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPasswordSaving(true);
|
||||
await axios.put('/auth/password-update', {
|
||||
currentPassword: passwordValues.currentPassword,
|
||||
newPassword: passwordValues.newPassword,
|
||||
});
|
||||
setPasswordValues({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
showAction('success', 'Password updated successfully.');
|
||||
} catch (error) {
|
||||
console.error('Failed to update MAi Server password', error);
|
||||
showAction('danger', 'Unable to update the password right now.');
|
||||
} finally {
|
||||
setIsPasswordSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnableAuthentication = async () => {
|
||||
try {
|
||||
setIsEnabling(true);
|
||||
const response = await axios.post('/mai-server/enable-authentication');
|
||||
await refreshAuthContext();
|
||||
showAction('success', response.data?.message || 'Authentication is enabled.');
|
||||
} catch (error) {
|
||||
console.error('Failed to enable authentication from MAi Server', error);
|
||||
showAction('danger', 'Unable to enable authentication right now.');
|
||||
} finally {
|
||||
setIsEnabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendVerification = async () => {
|
||||
try {
|
||||
setIsSendingVerification(true);
|
||||
const response = await axios.post('/mai-server/send-verification-email');
|
||||
await refreshAuthContext();
|
||||
showAction('success', response.data?.message || 'Verification email request completed.');
|
||||
} catch (error) {
|
||||
console.error('Failed to send verification email from MAi Server', error);
|
||||
showAction('danger', 'Unable to send a verification email right now.');
|
||||
} finally {
|
||||
setIsSendingVerification(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendPasswordReset = async () => {
|
||||
try {
|
||||
setIsSendingReset(true);
|
||||
const response = await axios.post('/mai-server/send-password-reset-email');
|
||||
showAction('success', response.data?.message || 'Password reset email request completed.');
|
||||
} catch (error) {
|
||||
console.error('Failed to send MAi Server password reset email', error);
|
||||
showAction('danger', 'Unable to send a password reset email right now.');
|
||||
} finally {
|
||||
setIsSendingReset(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rangeLabel = overview?.range?.label || `Last ${selectedRange} days`;
|
||||
const handoff = overview?.handoff;
|
||||
const handoffItems = handoff?.items || [];
|
||||
|
||||
const activityData = React.useMemo(
|
||||
() => ({
|
||||
labels: overview?.charts.activity.labels || [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Profiles',
|
||||
data: overview?.charts.activity.profiles || [],
|
||||
borderColor: '#38bdf8',
|
||||
backgroundColor: 'rgba(56, 189, 248, 0.16)',
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
},
|
||||
{
|
||||
label: 'Conversations',
|
||||
data: overview?.charts.activity.conversations || [],
|
||||
borderColor: '#34d399',
|
||||
backgroundColor: 'rgba(52, 211, 153, 0.16)',
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
},
|
||||
{
|
||||
label: 'Messages',
|
||||
data: overview?.charts.activity.messages || [],
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.16)',
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[overview],
|
||||
);
|
||||
|
||||
const activityOptions = React.useMemo(
|
||||
() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.08)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
precision: 0,
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const primaryStats = [
|
||||
{
|
||||
title: 'Profiles',
|
||||
value: formatMetric(overview?.summary.profiles),
|
||||
subtitle: `${formatMetric(overview?.windowSummary.profiles)} in ${rangeLabel.toLowerCase()}`,
|
||||
iconPath: icon.mdiAccountGroupOutline,
|
||||
accent: 'from-slate-950 via-cyan-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Verified profiles',
|
||||
value: formatMetric(overview?.summary.verifiedProfiles),
|
||||
subtitle: `${formatMetric(overview?.summary.disabledProfiles)} currently disabled`,
|
||||
iconPath: icon.mdiShieldCheckOutline,
|
||||
accent: 'from-emerald-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Conversations',
|
||||
value: formatMetric(overview?.summary.conversations),
|
||||
subtitle: `${formatMetric(overview?.windowSummary.conversations)} in ${rangeLabel.toLowerCase()}`,
|
||||
iconPath: icon.mdiWeb,
|
||||
accent: 'from-sky-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Messages',
|
||||
value: formatMetric(overview?.summary.messages),
|
||||
subtitle: `${formatMetric(overview?.windowSummary.messages)} in ${rangeLabel.toLowerCase()}`,
|
||||
iconPath: icon.mdiMessageTextOutline,
|
||||
accent: 'from-amber-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
];
|
||||
|
||||
const businessStats = [
|
||||
{
|
||||
title: 'Projects',
|
||||
value: formatMetric(overview?.businessSummary.projects),
|
||||
subtitle: `${formatMetric(overview?.businessSummary.publishedPages)} published pages`,
|
||||
iconPath: icon.mdiViewDashboardOutline,
|
||||
accent: 'from-indigo-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Agent endpoints',
|
||||
value: formatMetric(overview?.businessSummary.activeAgentEndpoints),
|
||||
subtitle: `${formatMetric(overview?.businessSummary.securedAgentEndpoints)} secured endpoints`,
|
||||
iconPath: icon.mdiServerSecurity,
|
||||
accent: 'from-violet-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Media streams',
|
||||
value: formatMetric(overview?.businessSummary.activeMediaStreams),
|
||||
subtitle: `${formatMetric(overview?.businessSummary.mediaChannels)} media channels connected`,
|
||||
iconPath: icon.mdiBroadcast,
|
||||
accent: 'from-fuchsia-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Assets',
|
||||
value: formatMetric(overview?.businessSummary.assets),
|
||||
subtitle: `${formatMetric(overview?.businessSummary.externalLinks)} external links tracked`,
|
||||
iconPath: icon.mdiDatabaseOutline,
|
||||
accent: 'from-teal-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('MAi Server')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={icon.mdiServerSecurity} title="MAi Server" main>
|
||||
<BaseButton color="info" label="Open public page" href="/mai-server-public" target="_blank" icon={icon.mdiOpenInNew} />
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{actionMessage ? (
|
||||
<NotificationBar color={getNotificationColor(actionTone)} icon={actionTone === 'danger' ? icon.mdiAlertCircleOutline : icon.mdiCheckCircleOutline}>
|
||||
{actionMessage}
|
||||
</NotificationBar>
|
||||
) : null}
|
||||
|
||||
{loadError ? (
|
||||
<NotificationBar color="danger" icon={icon.mdiAlertCircleOutline}>
|
||||
{loadError}
|
||||
</NotificationBar>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.35fr_0.65fr]">
|
||||
<CardBox className="border border-white/10 bg-gradient-to-br from-slate-950 via-slate-900 to-cyan-950 text-white">
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-cyan-200">
|
||||
<BaseIcon path={icon.mdiServerSecurity} size={16} />
|
||||
Aliyo Momot MAi Server
|
||||
</div>
|
||||
<h1 className="mt-5 text-4xl font-semibold leading-tight lg:text-5xl">
|
||||
Operate onboarding, authentication, and live business metrics from one control room.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-base leading-7 text-slate-300">
|
||||
This private dashboard now combines real account controls, live workspace analytics, and a production handoff checklist built from your existing Flatlogic entities.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{rangeOptions.map((range) => (
|
||||
<BaseButton
|
||||
key={range}
|
||||
label={`Last ${range} days`}
|
||||
small
|
||||
color={selectedRange === range ? 'info' : 'white'}
|
||||
outline={selectedRange !== range}
|
||||
onClick={() => setSelectedRange(range)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`w-full max-w-md space-y-4 rounded-3xl border border-white/10 bg-white/5 p-5 ${corners !== 'rounded-full' ? '' : 'rounded-3xl'}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Operator status</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{overview?.authStatus.displayName || currentUser?.email || 'Loading…'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/10 p-3">
|
||||
<BaseIcon path={icon.mdiAccountCircleOutline} size={28} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm text-slate-300 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Email</div>
|
||||
<div className="mt-2 break-all font-medium text-white">{overview?.authStatus.email || currentUser?.email || '—'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Role</div>
|
||||
<div className="mt-2 font-medium text-white">{overview?.authStatus.roleName || 'Workspace member'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Authentication</div>
|
||||
<div className="mt-2 font-medium text-white">{overview?.authStatus.authenticationStatusLabel || 'Loading…'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Verification</div>
|
||||
<div className="mt-2 font-medium text-white">{overview?.authStatus.verificationStatusLabel || 'Loading…'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Admin actions</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">Authentication and operator controls</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
|
||||
<BaseIcon path={icon.mdiCogOutline} size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3">
|
||||
<BaseButton
|
||||
color="info"
|
||||
label={isEnabling ? 'Enabling…' : overview?.authStatus.actionLabel || 'Enable authentication'}
|
||||
icon={icon.mdiShieldCheck}
|
||||
disabled={isEnabling}
|
||||
onClick={handleEnableAuthentication}
|
||||
/>
|
||||
<BaseButton
|
||||
color="white"
|
||||
outline
|
||||
label={isSendingVerification ? 'Sending verification…' : 'Send verification email'}
|
||||
icon={icon.mdiEmailFastOutline}
|
||||
disabled={isSendingVerification}
|
||||
onClick={handleSendVerification}
|
||||
/>
|
||||
<BaseButton
|
||||
color="white"
|
||||
outline
|
||||
label={isSendingReset ? 'Sending reset…' : 'Send password reset email'}
|
||||
icon={icon.mdiLockReset}
|
||||
disabled={isSendingReset}
|
||||
onClick={handleSendPasswordReset}
|
||||
/>
|
||||
<BaseButton
|
||||
color="white"
|
||||
outline
|
||||
label={isLoading ? 'Refreshing…' : 'Refresh analytics'}
|
||||
icon={icon.mdiRefresh}
|
||||
disabled={isLoading}
|
||||
onClick={() => loadOverview(selectedRange)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 space-y-3 text-sm leading-7 text-gray-600 dark:text-slate-300">
|
||||
<div className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
|
||||
<strong className="block text-base text-gray-900 dark:text-white">Live control plane</strong>
|
||||
Every action here hits a real backend endpoint, then refreshes the authenticated user context.
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
|
||||
<strong className="block text-base text-gray-900 dark:text-white">Handoff score</strong>
|
||||
{handoff ? `${handoff.readyCount}/${handoff.totalCount} launch checks are currently passing.` : 'Loading launch readiness…'}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{primaryStats.map((card) => (
|
||||
<StatCard key={card.title} {...card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{businessStats.map((card) => (
|
||||
<StatCard key={card.title} {...card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[1.35fr_0.65fr]">
|
||||
<CardBox className="border border-white/10 bg-slate-950 text-white">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Activity analytics</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">{rangeLabel} of profile, conversation, and message growth</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-300">
|
||||
<BaseIcon path={icon.mdiChartTimelineVariant} size={26} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-80">
|
||||
<Line data={activityData} options={activityOptions} />
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<BarChartCard
|
||||
title="Agent providers"
|
||||
description="Which AI endpoint providers are active in the workspace"
|
||||
rows={overview?.charts.endpointProviders || []}
|
||||
emptyLabel={isLoading ? 'Loading provider data…' : 'No agent endpoint provider activity is available yet.'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<BarChartCard
|
||||
title="Conversation channels"
|
||||
description={`Channel distribution for ${rangeLabel.toLowerCase()}`}
|
||||
rows={overview?.charts.channels || []}
|
||||
emptyLabel={isLoading ? 'Loading channel data…' : 'No conversation channel data is available in this range.'}
|
||||
/>
|
||||
<BarChartCard
|
||||
title="Conversation status"
|
||||
description="Open, closed, and archived conversation mix"
|
||||
rows={overview?.charts.conversationStatus || []}
|
||||
emptyLabel={isLoading ? 'Loading conversation status data…' : 'No conversation status data is available yet.'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-3">
|
||||
<BarChartCard
|
||||
title="Message senders"
|
||||
description="Who is generating the conversation volume"
|
||||
rows={overview?.charts.messageSenders || []}
|
||||
emptyLabel={isLoading ? 'Loading sender data…' : 'No message sender data is available yet.'}
|
||||
/>
|
||||
<BarChartCard
|
||||
title="Announcement status"
|
||||
description="Publishing readiness for operational announcements"
|
||||
rows={overview?.charts.announcementStatus || []}
|
||||
emptyLabel={isLoading ? 'Loading announcement data…' : 'No announcement status data is available yet.'}
|
||||
/>
|
||||
<BarChartCard
|
||||
title="Content mix"
|
||||
description="Widget and asset composition across the workspace"
|
||||
rows={(overview?.charts.widgetTypes || []).slice(0, 4).concat((overview?.charts.assetTypes || []).slice(0, 4))}
|
||||
emptyLabel={isLoading ? 'Loading content composition…' : 'No widget or asset mix data is available yet.'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Profile controls</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">Edit the current MAi Server operator</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
|
||||
<BaseIcon path={icon.mdiAccountCircleOutline} size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSaveProfile}>
|
||||
<FormField label="First name" labelFor="firstName">
|
||||
<input id="firstName" name="firstName" value={profileValues.firstName} onChange={handleProfileChange} placeholder="First name" />
|
||||
</FormField>
|
||||
<FormField label="Last name" labelFor="lastName">
|
||||
<input id="lastName" name="lastName" value={profileValues.lastName} onChange={handleProfileChange} placeholder="Last name" />
|
||||
</FormField>
|
||||
<FormField label="Phone number" labelFor="phoneNumber">
|
||||
<input id="phoneNumber" name="phoneNumber" value={profileValues.phoneNumber} onChange={handleProfileChange} placeholder="Phone number" />
|
||||
</FormField>
|
||||
<FormField label="Email" labelFor="email" help="Email is managed by the account identity provider.">
|
||||
<input id="email" name="email" value={overview?.authStatus.email || currentUser?.email || ''} disabled readOnly />
|
||||
</FormField>
|
||||
<BaseButton type="submit" color="info" label={isProfileSaving ? 'Saving profile…' : 'Save profile'} disabled={isProfileSaving} icon={icon.mdiDatabaseOutline} />
|
||||
</form>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Password controls</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">Update credentials without leaving MAi Server</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
|
||||
<BaseIcon path={icon.mdiLockReset} size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<FormField label="Current password" labelFor="currentPassword">
|
||||
<input id="currentPassword" name="currentPassword" type="password" value={passwordValues.currentPassword} onChange={handlePasswordChange} placeholder="Current password" />
|
||||
</FormField>
|
||||
<FormField label="New password" labelFor="newPassword">
|
||||
<input id="newPassword" name="newPassword" type="password" value={passwordValues.newPassword} onChange={handlePasswordChange} placeholder="New password" />
|
||||
</FormField>
|
||||
<FormField label="Confirm new password" labelFor="confirmPassword">
|
||||
<input id="confirmPassword" name="confirmPassword" type="password" value={passwordValues.confirmPassword} onChange={handlePasswordChange} placeholder="Confirm new password" />
|
||||
</FormField>
|
||||
<BaseButton type="submit" color="info" label={isPasswordSaving ? 'Updating password…' : 'Update password'} disabled={isPasswordSaving} icon={icon.mdiShieldCheckOutline} />
|
||||
</form>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Production handoff</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">Launch checklist built from live workspace data</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
|
||||
<BaseIcon path={icon.mdiRocketLaunchOutline} size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-100 p-5 dark:border-slate-800">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Readiness</p>
|
||||
<p className="mt-2 text-4xl font-semibold">{handoff ? `${handoff.completionRate}%` : '—'}</p>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500 dark:text-slate-400">
|
||||
{handoff ? `${handoff.readyCount}/${handoff.totalCount} checks passing` : 'Loading checks…'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-3 overflow-hidden rounded-full bg-gray-100 dark:bg-slate-800">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-cyan-500 to-emerald-500" style={{ width: `${handoff?.completionRate || 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{handoffItems.map((item) => (
|
||||
<div key={item.key} className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-base font-semibold">
|
||||
<BaseIcon path={item.status ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline} size={18} className={item.status ? 'text-emerald-500' : 'text-amber-500'} />
|
||||
{item.label}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-slate-300">{item.description}</p>
|
||||
</div>
|
||||
<div className="min-w-[120px] text-right text-sm font-medium text-gray-500 dark:text-slate-400">{item.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Recent profiles</p>
|
||||
<h2 className="mt-1 text-xl font-semibold">Latest onboarding records</h2>
|
||||
</div>
|
||||
<BaseIcon path={icon.mdiAccountGroupOutline} size={22} className="text-cyan-500" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{overview?.recentProfiles.length ? overview.recentProfiles.map((profile) => (
|
||||
<div key={profile.id} className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
|
||||
<div className="font-semibold">{profile.name}</div>
|
||||
<div className="mt-1 text-sm text-gray-500 dark:text-slate-400">{profile.email}</div>
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-slate-500">{profile.provider} • {profile.statusLabel}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-slate-400">Created {formatDateTime(profile.createdAt)}</div>
|
||||
</div>
|
||||
)) : <div className="rounded-2xl border border-dashed border-gray-300 p-6 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400">No profile records are visible for this range.</div>}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Recent projects</p>
|
||||
<h2 className="mt-1 text-xl font-semibold">Workspace launch surfaces</h2>
|
||||
</div>
|
||||
<BaseIcon path={icon.mdiViewDashboardOutline} size={22} className="text-cyan-500" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{overview?.recentProjects.length ? overview.recentProjects.map((project) => (
|
||||
<div key={project.id} className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
|
||||
<div className="font-semibold">{project.title}</div>
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-slate-500">{project.visibility} • {project.statusLabel}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-slate-400">Updated {formatDateTime(project.updatedAt)}</div>
|
||||
</div>
|
||||
)) : <div className="rounded-2xl border border-dashed border-gray-300 p-6 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400">No project records are visible.</div>}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Recent endpoints</p>
|
||||
<h2 className="mt-1 text-xl font-semibold">Current AI connectors</h2>
|
||||
</div>
|
||||
<BaseIcon path={icon.mdiLinkVariant} size={22} className="text-cyan-500" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{overview?.recentAgentEndpoints.length ? overview.recentAgentEndpoints.map((endpoint) => (
|
||||
<div key={endpoint.id} className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
|
||||
<div className="font-semibold">{endpoint.name}</div>
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-slate-500">{endpoint.provider} • {endpoint.status}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-slate-400">{endpoint.requiresAuth ? 'Requires authentication' : 'Public access'} • Updated {formatDateTime(endpoint.updatedAt)}</div>
|
||||
</div>
|
||||
)) : <div className="rounded-2xl border border-dashed border-gray-300 p-6 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400">No agent endpoint records are visible.</div>}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
MaiServerPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default MaiServerPage;
|
||||
553
frontend/src/pages/web_pages/mai-server.tsx
Normal file
553
frontend/src/pages/web_pages/mai-server.tsx
Normal file
@ -0,0 +1,553 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import Head from 'next/head';
|
||||
import React from 'react';
|
||||
import { Bar, Line } from 'react-chartjs-2';
|
||||
import type { ReactElement } from 'react';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import BaseIcon from '../../components/BaseIcon';
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutGuest from '../../layouts/Guest';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler);
|
||||
|
||||
type ChartBucket = Array<{ label: string; value: number }>;
|
||||
|
||||
type PublicOverview = {
|
||||
range: {
|
||||
days: number;
|
||||
label: string;
|
||||
};
|
||||
summary: {
|
||||
profiles: number;
|
||||
verifiedProfiles: number;
|
||||
disabledProfiles: number;
|
||||
conversations: number;
|
||||
messages: number;
|
||||
announcements: number;
|
||||
};
|
||||
windowSummary: {
|
||||
profiles: number;
|
||||
conversations: number;
|
||||
messages: number;
|
||||
announcements: number;
|
||||
};
|
||||
businessSummary: {
|
||||
projects: number;
|
||||
pages: number;
|
||||
publishedPages: number;
|
||||
linkCollections: number;
|
||||
publicCollections: number;
|
||||
externalLinks: number;
|
||||
widgets: number;
|
||||
activeWidgets: number;
|
||||
agentEndpoints: number;
|
||||
activeAgentEndpoints: number;
|
||||
securedAgentEndpoints: number;
|
||||
mediaChannels: number;
|
||||
mediaStreams: number;
|
||||
activeMediaStreams: number;
|
||||
assets: number;
|
||||
};
|
||||
charts: {
|
||||
activity: {
|
||||
labels: string[];
|
||||
profiles: number[];
|
||||
conversations: number[];
|
||||
messages: number[];
|
||||
};
|
||||
channels: ChartBucket;
|
||||
announcementStatus: ChartBucket;
|
||||
conversationStatus: ChartBucket;
|
||||
messageSenders: ChartBucket;
|
||||
endpointProviders: ChartBucket;
|
||||
widgetTypes: ChartBucket;
|
||||
assetTypes: ChartBucket;
|
||||
};
|
||||
handoff: {
|
||||
readyCount: number;
|
||||
totalCount: number;
|
||||
completionRate: number;
|
||||
items: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
status: boolean;
|
||||
value: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const rangeOptions = [7, 30, 90];
|
||||
const chartPalette = ['#38bdf8', '#34d399', '#f59e0b', '#a78bfa', '#fb7185', '#22c55e', '#f97316', '#06b6d4'];
|
||||
|
||||
function formatMetric(value: number | null | undefined) {
|
||||
if (value === null || value === undefined) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function buildBarData(title: string, rows: ChartBucket) {
|
||||
return {
|
||||
labels: rows.map((row) => row.label),
|
||||
datasets: [
|
||||
{
|
||||
label: title,
|
||||
data: rows.map((row) => row.value),
|
||||
backgroundColor: rows.map((_, index) => chartPalette[index % chartPalette.length]),
|
||||
borderRadius: 12,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const barOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
iconPath,
|
||||
accent,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
subtitle: string;
|
||||
iconPath: string;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<CardBox className={`border border-white/10 bg-gradient-to-br ${accent} text-white`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-300">{title}</p>
|
||||
<p className="mt-4 text-3xl font-semibold">{value}</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{subtitle}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/10 p-3">
|
||||
<BaseIcon path={iconPath} size={26} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
}
|
||||
|
||||
function BarChartCard({
|
||||
title,
|
||||
description,
|
||||
rows,
|
||||
emptyLabel,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
rows: ChartBucket;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">{title}</p>
|
||||
<h3 className="mt-1 text-xl font-semibold">{description}</h3>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
|
||||
<BaseIcon path={icon.mdiChartDonut} size={22} />
|
||||
</div>
|
||||
</div>
|
||||
{rows.length ? (
|
||||
<div className="h-72">
|
||||
<Bar data={buildBarData(title, rows)} options={barOptions} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 px-4 py-10 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MaiServerPublicPage() {
|
||||
const [selectedRange, setSelectedRange] = React.useState(30);
|
||||
const [overview, setOverview] = React.useState<PublicOverview | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [loadError, setLoadError] = React.useState('');
|
||||
|
||||
const loadOverview = React.useCallback(async (range = selectedRange) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setLoadError('');
|
||||
const response = await axios.get('/mai-server-public/overview', {
|
||||
params: { range },
|
||||
});
|
||||
setOverview(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load public MAi Server overview', error);
|
||||
setLoadError('Unable to load the public marketing overview right now.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedRange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadOverview(selectedRange);
|
||||
}, [loadOverview, selectedRange]);
|
||||
|
||||
const rangeLabel = overview?.range?.label || `Last ${selectedRange} days`;
|
||||
|
||||
const activityData = React.useMemo(
|
||||
() => ({
|
||||
labels: overview?.charts.activity.labels || [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Profiles',
|
||||
data: overview?.charts.activity.profiles || [],
|
||||
borderColor: '#38bdf8',
|
||||
backgroundColor: 'rgba(56, 189, 248, 0.16)',
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
},
|
||||
{
|
||||
label: 'Conversations',
|
||||
data: overview?.charts.activity.conversations || [],
|
||||
borderColor: '#34d399',
|
||||
backgroundColor: 'rgba(52, 211, 153, 0.16)',
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
},
|
||||
{
|
||||
label: 'Messages',
|
||||
data: overview?.charts.activity.messages || [],
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.16)',
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[overview],
|
||||
);
|
||||
|
||||
const activityOptions = React.useMemo(
|
||||
() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#cbd5e1',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.08)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#cbd5e1',
|
||||
precision: 0,
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const heroStats = [
|
||||
{
|
||||
title: 'Profiles onboarded',
|
||||
value: formatMetric(overview?.summary.profiles),
|
||||
subtitle: `${formatMetric(overview?.windowSummary.profiles)} added in ${rangeLabel.toLowerCase()}`,
|
||||
iconPath: icon.mdiAccountGroupOutline,
|
||||
accent: 'from-slate-950 via-cyan-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Conversation flow',
|
||||
value: formatMetric(overview?.summary.conversations),
|
||||
subtitle: `${formatMetric(overview?.windowSummary.messages)} messages flowing in ${rangeLabel.toLowerCase()}`,
|
||||
iconPath: icon.mdiMessageTextOutline,
|
||||
accent: 'from-emerald-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Published launch surface',
|
||||
value: formatMetric(overview?.businessSummary.publishedPages),
|
||||
subtitle: `${formatMetric(overview?.businessSummary.projects)} projects in workspace`,
|
||||
iconPath: icon.mdiRocketLaunchOutline,
|
||||
accent: 'from-indigo-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'AI endpoints ready',
|
||||
value: formatMetric(overview?.businessSummary.activeAgentEndpoints),
|
||||
subtitle: `${formatMetric(overview?.businessSummary.securedAgentEndpoints)} secured endpoints`,
|
||||
iconPath: icon.mdiServerSecurity,
|
||||
accent: 'from-violet-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
];
|
||||
|
||||
const businessStats = [
|
||||
{
|
||||
title: 'Media channels',
|
||||
value: formatMetric(overview?.businessSummary.mediaChannels),
|
||||
subtitle: `${formatMetric(overview?.businessSummary.activeMediaStreams)} active streams`,
|
||||
iconPath: icon.mdiBroadcast,
|
||||
accent: 'from-fuchsia-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Content blocks',
|
||||
value: formatMetric(overview?.businessSummary.activeWidgets),
|
||||
subtitle: `${formatMetric(overview?.businessSummary.widgets)} widgets total`,
|
||||
iconPath: icon.mdiViewDashboardOutline,
|
||||
accent: 'from-sky-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Link reach',
|
||||
value: formatMetric(overview?.businessSummary.publicCollections),
|
||||
subtitle: `${formatMetric(overview?.businessSummary.externalLinks)} external links`,
|
||||
iconPath: icon.mdiLinkVariant,
|
||||
accent: 'from-teal-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
{
|
||||
title: 'Asset library',
|
||||
value: formatMetric(overview?.businessSummary.assets),
|
||||
subtitle: `${formatMetric(overview?.summary.announcements)} announcements available`,
|
||||
iconPath: icon.mdiDatabaseOutline,
|
||||
accent: 'from-amber-950 via-slate-950 to-slate-900',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>MAi Server Public</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Aliyo Momot MAi Server public overview with live product, onboarding, and operations metrics from the workspace."
|
||||
/>
|
||||
</Head>
|
||||
<main className="min-h-screen bg-slate-950 text-white">
|
||||
<section className="border-b border-white/10 bg-[radial-gradient(circle_at_top_right,_rgba(34,211,238,0.18),_transparent_32%),linear-gradient(135deg,#020617_0%,#0f172a_55%,#082f49_100%)]">
|
||||
<div className="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-20">
|
||||
<div className="grid gap-10 xl:grid-cols-[1.25fr_0.75fr] xl:items-start">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.25em] text-cyan-200">
|
||||
<BaseIcon path={icon.mdiServerSecurity} size={16} />
|
||||
Public MAi Server
|
||||
</div>
|
||||
<h1 className="mt-6 text-5xl font-semibold leading-tight lg:text-6xl">
|
||||
Turn your live SaaS workspace into a credible AI agent launch story.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-3xl text-lg leading-8 text-slate-300">
|
||||
This public overview surfaces real onboarding, conversation, content, and endpoint activity from the product instead of static marketing screenshots.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{rangeOptions.map((range) => (
|
||||
<BaseButton
|
||||
key={range}
|
||||
label={`Last ${range} days`}
|
||||
small
|
||||
color={selectedRange === range ? 'info' : 'white'}
|
||||
outline={selectedRange !== range}
|
||||
onClick={() => setSelectedRange(range)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<BaseButton color="info" label="Open private dashboard" href="/mai-server" icon={icon.mdiOpenInNew} />
|
||||
<BaseButton color="white" outline label={isLoading ? 'Refreshing…' : 'Refresh live data'} icon={icon.mdiRefresh} onClick={() => loadOverview(selectedRange)} />
|
||||
</div>
|
||||
{loadError ? <div className="mt-6 rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-3 text-sm text-red-100">{loadError}</div> : null}
|
||||
</div>
|
||||
|
||||
<CardBox className="border border-white/10 bg-white/5 text-white">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Launch readiness</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold">{overview ? `${overview.handoff.completionRate}% production-ready` : 'Loading…'}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">
|
||||
A lightweight handoff summary built from the same app data driving the private operations dashboard.
|
||||
</p>
|
||||
<div className="mt-5 h-3 overflow-hidden rounded-full bg-white/10">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-cyan-500 to-emerald-500" style={{ width: `${overview?.handoff.completionRate || 0}%` }} />
|
||||
</div>
|
||||
<div className="mt-6 space-y-3 text-sm text-slate-300">
|
||||
{(overview?.handoff.items || []).slice(0, 4).map((item) => (
|
||||
<div key={item.key} className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<BaseIcon path={item.status ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline} size={18} className={item.status ? 'text-emerald-400' : 'text-amber-400'} />
|
||||
<div>
|
||||
<div className="font-medium text-white">{item.label}</div>
|
||||
<div className="mt-1 text-slate-400">{item.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{heroStats.map((card) => (
|
||||
<StatCard key={card.title} {...card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{businessStats.map((card) => (
|
||||
<StatCard key={card.title} {...card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-6 py-12 lg:px-8">
|
||||
<div className="grid gap-6 xl:grid-cols-[1.3fr_0.7fr]">
|
||||
<CardBox className="border border-white/10 bg-slate-900/80 text-white">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Live growth curve</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">{rangeLabel} of profile, conversation, and message growth</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-300">
|
||||
<BaseIcon path={icon.mdiChartTimelineVariant} size={26} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-80">
|
||||
<Line data={activityData} options={activityOptions} />
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border border-white/10 bg-slate-900/80 text-white">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Why this matters</p>
|
||||
<div className="mt-4 space-y-4 text-sm leading-7 text-slate-300">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<strong className="block text-base text-white">Real proof instead of mock data</strong>
|
||||
Investors, customers, and partners can see actual usage movement from the live product.
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<strong className="block text-base text-white">A direct bridge to the private workspace</strong>
|
||||
The public page supports storytelling while keeping sensitive records inside the authenticated dashboard.
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<strong className="block text-base text-white">Operational depth for an AI agent SaaS</strong>
|
||||
Endpoint, widget, media, and asset signals make the platform look like a real product system, not a landing page shell.
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<BarChartCard
|
||||
title="Conversation channels"
|
||||
description={`Channel distribution for ${rangeLabel.toLowerCase()}`}
|
||||
rows={overview?.charts.channels || []}
|
||||
emptyLabel={isLoading ? 'Loading channel data…' : 'No channel activity is available in this range yet.'}
|
||||
/>
|
||||
<BarChartCard
|
||||
title="Endpoint providers"
|
||||
description="Current AI integration provider footprint"
|
||||
rows={overview?.charts.endpointProviders || []}
|
||||
emptyLabel={isLoading ? 'Loading endpoint provider data…' : 'No endpoint provider data is available yet.'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-3">
|
||||
<BarChartCard
|
||||
title="Conversation status"
|
||||
description="Open, closed, and archived thread mix"
|
||||
rows={overview?.charts.conversationStatus || []}
|
||||
emptyLabel={isLoading ? 'Loading conversation status…' : 'No conversation status data is available yet.'}
|
||||
/>
|
||||
<BarChartCard
|
||||
title="Message senders"
|
||||
description="How much activity comes from users, assistants, and system flows"
|
||||
rows={overview?.charts.messageSenders || []}
|
||||
emptyLabel={isLoading ? 'Loading sender data…' : 'No message sender data is available yet.'}
|
||||
/>
|
||||
<BarChartCard
|
||||
title="Announcement status"
|
||||
description="Readiness of launch and operational messaging"
|
||||
rows={overview?.charts.announcementStatus || []}
|
||||
emptyLabel={isLoading ? 'Loading announcement status…' : 'No announcement data is available yet.'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<BarChartCard
|
||||
title="Content composition"
|
||||
description="Combined widget and asset mix across the workspace"
|
||||
rows={(overview?.charts.widgetTypes || []).slice(0, 4).concat((overview?.charts.assetTypes || []).slice(0, 4))}
|
||||
emptyLabel={isLoading ? 'Loading content composition…' : 'No widget or asset type data is available yet.'}
|
||||
/>
|
||||
|
||||
<CardBox>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-slate-400">Production handoff checklist</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">What a buyer or stakeholder can trust from this page</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-cyan-500/10 p-3 text-cyan-600 dark:text-cyan-300">
|
||||
<BaseIcon path={icon.mdiRocketLaunchOutline} size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{(overview?.handoff.items || []).map((item) => (
|
||||
<div key={item.key} className="rounded-2xl border border-gray-100 p-4 dark:border-slate-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<BaseIcon path={item.status ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline} size={18} className={item.status ? 'text-emerald-500' : 'text-amber-500'} />
|
||||
<div>
|
||||
<div className="font-semibold">{item.label}</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-slate-300">{item.description}</div>
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-slate-500">{item.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
MaiServerPublicPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user