1.6
This commit is contained in:
parent
818e7d5818
commit
c06b7851ce
@ -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 }),
|
||||
|
||||
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: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/mai-server-public',
|
||||
destination: '/web_pages/mai-server',
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
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