This commit is contained in:
Flatlogic Bot 2026-04-07 15:51:14 +00:00
parent 818e7d5818
commit c06b7851ce
7 changed files with 2185 additions and 258 deletions

View File

@ -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 }),

View 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;

View 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;

View 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;
}
}
};

View File

@ -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

View 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>;
};