1.6
This commit is contained in:
parent
818e7d5818
commit
c06b7851ce
@ -6,7 +6,6 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const db = require('./db/models');
|
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -50,6 +49,8 @@ const media_streamsRoutes = require('./routes/media_streams');
|
|||||||
const announcementsRoutes = require('./routes/announcements');
|
const announcementsRoutes = require('./routes/announcements');
|
||||||
|
|
||||||
const assetsRoutes = require('./routes/assets');
|
const assetsRoutes = require('./routes/assets');
|
||||||
|
const maiServerRoutes = require('./routes/maiServer');
|
||||||
|
const maiServerPublicRoutes = require('./routes/maiServerPublic');
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
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/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(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
|||||||
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;
|
||||||
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: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/mai-server-public',
|
||||||
|
destination: '/web_pages/mai-server',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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