feat: implement full CRUD and administrative management endpoints for WAP servers in the sysadmin API

This commit is contained in:
2026-07-28 22:45:26 -03:00
parent 0eb2e91e15
commit 3d8911c093
11 changed files with 2144 additions and 10 deletions
@@ -36,6 +36,10 @@ export class WapServerAdapterMongoose implements IWapServerAdapter {
return this.serverList.findOne(data).exec(); return this.serverList.findOne(data).exec();
} }
public async delete(id: string): Promise<void> {
await this.serverList.deleteOne({ _id: id });
}
private buildSearchCriteria(filters: FindWapServerParams): FilterQuery<IWapServerDocument> { private buildSearchCriteria(filters: FindWapServerParams): FilterQuery<IWapServerDocument> {
const searchCriteria: FilterQuery<IWapServerDocument> = {}; const searchCriteria: FilterQuery<IWapServerDocument> = {};
@@ -7,6 +7,7 @@ export type FindWapServerParams = {
description?: string; description?: string;
countBots?: number; countBots?: number;
maxBots?: number; maxBots?: number;
port?: number;
active?: boolean; active?: boolean;
countBotsFrom?: number; countBotsFrom?: number;
countBotsTo?: number; countBotsTo?: number;
@@ -100,9 +101,19 @@ export interface IWapServer {
ipv6: string; ipv6: string;
countBots: number; countBots: number;
maxBots: number; maxBots: number;
port: number;
active: boolean; active: boolean;
} }
export type UpdateWapServerParams = {
serverId: string;
name?: string;
ipv4?: string;
port?: number;
maxBots?: number;
active?: boolean;
};
export type PaginateWapServerParams = FindWapServerParams & { export type PaginateWapServerParams = FindWapServerParams & {
page: number; page: number;
limit: number; limit: number;
@@ -114,13 +125,111 @@ export type PaginateWapServerResults = {
pages: number; pages: number;
}; };
export type SysAdminWapServerByIdParams = {
serverId: string;
};
export type SysAdminWapServerAuditParams = SysAdminWapServerByIdParams;
export type SysAdminWapServerRecalculateCountParams = SysAdminWapServerByIdParams;
export type SysAdminDeleteWapServerParams = SysAdminWapServerByIdParams;
export type SysAdminDeleteWapServerResult = {
serverId: string;
deleted: true;
};
export type SysAdminWapServerOrganizationActionParams = SysAdminWapServerByIdParams & {
organizationId: string;
};
export type SysAdminWapServerQrResult = {
serverId: string;
organizationId: string;
qr: string;
};
export type SysAdminWapServerDetachResult = {
serverId: string;
organizationId: string;
botDeleted: boolean;
botAlreadyMissing: boolean;
before: number;
after: number;
};
export type SysAdminWapServerDeleteBotResult = {
serverId: string;
organizationId: string;
assignmentRemains: true;
before: number;
after: number;
};
export type SysAdminWapAssignedOrganization = {
id: string;
name?: string;
};
export type SysAdminWapContainerDto = {
id?: string;
name?: string;
organizationId?: string;
image?: string;
state?: string;
status?: string;
ports?: unknown[];
created?: number;
};
export type SysAdminWapServerAuditResult = {
serverId: string;
assignedOrganizations: SysAdminWapAssignedOrganization[];
detectedBots: SysAdminWapContainerDto[];
validBots: SysAdminWapContainerDto[];
ghostBots: SysAdminWapContainerDto[];
missingBots: SysAdminWapAssignedOrganization[];
expectedCountBots: number;
storedCountBots: number;
runtimeCountBots: number;
countMismatch: boolean;
};
export type SysAdminWapServerRecalculateCountResult = {
serverId: string;
before: number;
after: number;
};
export type SysAdminGetWapServerParams = PaginateWapServerParams & { export type SysAdminGetWapServerParams = PaginateWapServerParams & {
payload: IPayload; payload: IPayload;
}; };
export type SysAdminUpdateWapServerParams = UpdateWapServerParams & {
payload: IPayload;
};
export type SysAdminAuditWapServerParams = SysAdminWapServerAuditParams & {
payload: IPayload;
};
export type SysAdminRecalculateWapServerCountParams = SysAdminWapServerRecalculateCountParams & {
payload: IPayload;
};
export type SysAdminDeleteWapServerRequest = SysAdminDeleteWapServerParams & {
payload: IPayload;
};
export type SysAdminWapServerOrganizationActionRequest = SysAdminWapServerOrganizationActionParams & {
payload: IPayload;
};
export interface IWapServerAdapter { export interface IWapServerAdapter {
find(data: FindWapServerParams): Promise<IWapServer[]>; find(data: FindWapServerParams): Promise<IWapServer[]>;
findOne(data: FindWapServerParams): Promise<IWapServerDocument | null>; findOne(data: FindWapServerParams): Promise<IWapServerDocument | null>;
delete(id: string): Promise<void>;
paginate(filters: PaginateWapServerParams): Promise<PaginateWapServerResults>; paginate(filters: PaginateWapServerParams): Promise<PaginateWapServerResults>;
} }
@@ -140,4 +249,14 @@ export interface IWapServerManager {
getQr(data: BotEventParams): Promise<string>; getQr(data: BotEventParams): Promise<string>;
sendMessage(data: SendBotMessageParams): Promise<void>; sendMessage(data: SendBotMessageParams): Promise<void>;
sysAdminGetWapServers(data: PaginateWapServerParams): Promise<PaginateWapServerResults>; sysAdminGetWapServers(data: PaginateWapServerParams): Promise<PaginateWapServerResults>;
sysAdminUpdateWapServer(data: UpdateWapServerParams): Promise<IWapServer>;
sysAdminAuditWapServer(data: SysAdminWapServerAuditParams): Promise<SysAdminWapServerAuditResult>;
sysAdminRecalculateWapServerCount(data: SysAdminWapServerRecalculateCountParams): Promise<SysAdminWapServerRecalculateCountResult>;
sysAdminDeleteWapServer(data: SysAdminDeleteWapServerParams): Promise<SysAdminDeleteWapServerResult>;
sysAdminStartBot(data: SysAdminWapServerOrganizationActionParams): Promise<void>;
sysAdminStopBot(data: SysAdminWapServerOrganizationActionParams): Promise<void>;
sysAdminRestartBot(data: SysAdminWapServerOrganizationActionParams): Promise<void>;
sysAdminGetQr(data: SysAdminWapServerOrganizationActionParams): Promise<SysAdminWapServerQrResult>;
sysAdminDeleteBot(data: SysAdminWapServerOrganizationActionParams): Promise<SysAdminWapServerDeleteBotResult>;
sysAdminDetachOrganizationServer(data: SysAdminWapServerOrganizationActionParams): Promise<SysAdminWapServerDetachResult>;
} }
+526 -1
View File
@@ -13,6 +13,17 @@ import {
BotView, BotView,
IWapServer, IWapServer,
IWapServerManager, IWapServerManager,
SysAdminDeleteWapServerParams,
SysAdminDeleteWapServerResult,
SysAdminWapContainerDto,
SysAdminWapServerAuditParams,
SysAdminWapServerAuditResult,
SysAdminWapServerRecalculateCountParams,
SysAdminWapServerRecalculateCountResult,
SysAdminWapServerDetachResult,
SysAdminWapServerDeleteBotResult,
SysAdminWapServerOrganizationActionParams,
SysAdminWapServerQrResult,
SendBotMessageParams, SendBotMessageParams,
ValidateBotEventStatus, ValidateBotEventStatus,
BOT_STATE, BOT_STATE,
@@ -21,12 +32,19 @@ import {
VALIDATE_BOT_ENVENT_ERRORS, VALIDATE_BOT_ENVENT_ERRORS,
PaginateWapServerResults, PaginateWapServerResults,
PaginateWapServerParams, PaginateWapServerParams,
UpdateWapServerParams,
} from "./WapServer.Interface"; } from "./WapServer.Interface";
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons"; import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
import { PlanFeatures } from "../Plans/Plans.interface"; import { PlanFeatures } from "../Plans/Plans.interface";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
const BOT_ADMIN_ACTION_TIMEOUT_MS = 10000;
const BOT_ADMIN_RESTART_STATUS_RETRIES = 5;
const BOT_ADMIN_RESTART_STATUS_DELAY_MS = 1000;
const MONGO_OBJECT_ID_REGEX = /^[a-fA-F0-9]{24}$/;
const WAP_SERVER_METADATA_HOSTS = new Set(["169.254.169.254", "metadata.google.internal"]);
export interface CreateBotResult { export interface CreateBotResult {
organizationId: string; organizationId: string;
port: number; port: number;
@@ -142,10 +160,259 @@ class WapServerManager implements IWapServerManager {
return servidores; return servidores;
} }
private normalizeBotAdminApiUrl(url: string): string {
const rawUrl = String(url).trim();
if (!rawUrl) {
throw new Error("URL is required");
}
if (/\s|[\x00-\x1F\x7F]/.test(rawUrl)) {
throw new Error("URL must not contain whitespace or control characters");
}
const hasProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(rawUrl);
const normalizedInput = hasProtocol ? rawUrl : `http://${rawUrl}`;
let parsedUrl: URL;
try {
parsedUrl = new URL(normalizedInput);
} catch (error) {
throw new Error("URL must be a valid HTTP(S) origin");
}
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
throw new Error("URL protocol must be http or https");
}
if (!parsedUrl.hostname) {
throw new Error("URL host is required");
}
if (parsedUrl.username || parsedUrl.password) {
throw new Error("URL must not include credentials");
}
const path = decodeURIComponent(parsedUrl.pathname);
if ((parsedUrl.pathname && parsedUrl.pathname !== "/") || path.includes("..")) {
throw new Error("URL path is not allowed; use only the origin");
}
if (parsedUrl.search || parsedUrl.hash) {
throw new Error("URL must not include query string or hash");
}
if (WAP_SERVER_METADATA_HOSTS.has(parsedUrl.hostname.toLowerCase())) {
throw new Error("URL host is not allowed");
}
return `${parsedUrl.origin}/`;
}
private getBotAdminApiUrl(url: string): string { private getBotAdminApiUrl(url: string): string {
//return `https://${url}:${process.env.DEFAULT_BOT_ADMIN_PORT}/`; //return `https://${url}:${process.env.DEFAULT_BOT_ADMIN_PORT}/`;
//return `https://${url}/`; //return `https://${url}/`;
return url; return this.normalizeBotAdminApiUrl(url);
}
private formatResponseBody(data: unknown): string {
if (data === undefined || data === null) {
return "empty body";
}
const body = typeof data === "string" ? data : JSON.stringify(data);
return body.length > 1000 ? `${body.slice(0, 1000)}...` : body;
}
private createBotAdminContainersError(error: unknown, targetUrl: string): Error {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const body = this.formatResponseBody(error.response?.data);
const statusText = status ? `status ${status}` : error.code || "no response";
return new Error(`Bot-admin containers request failed for ${targetUrl}: ${statusText}; body: ${body}`);
}
if (error instanceof Error && error.message) {
return new Error(`Bot-admin containers request failed for ${targetUrl}: ${error.message}`);
}
return new Error(`Bot-admin containers request failed for ${targetUrl}: unknown error`);
}
private createBotAdminActionError(error: unknown, targetUrl: string): Error {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const body = this.formatResponseBody(error.response?.data);
const statusText = status ? `status ${status}` : error.code || "no response";
return new Error(`Bot-admin request failed for ${targetUrl}: ${statusText}; body: ${body}`);
}
if (error instanceof Error && error.message) {
return new Error(`Bot-admin request failed for ${targetUrl}: ${error.message}`);
}
return new Error(`Bot-admin request failed for ${targetUrl}: unknown error`);
}
private isBotAdminMissingContainerError(error: unknown): boolean {
if (!axios.isAxiosError(error)) {
return false;
}
const status = error.response?.status;
const body = this.formatResponseBody(error.response?.data).toLowerCase();
// Be conservative: only allow detach to continue for explicit missing-container signals.
return status === 404 || body.includes("not found") || body.includes("no such container");
}
private async getSysAdminServerActionTarget(data: SysAdminWapServerOrganizationActionParams): Promise<{
server: any;
baseUrl: string;
encodedOrganizationId: string;
}> {
if (!data.serverId) {
throw new Error("Server id is required");
}
if (!data.organizationId) {
throw new Error("Organization id is required");
}
if (!MONGO_OBJECT_ID_REGEX.test(data.organizationId)) {
throw new Error("Organization id must be a valid Mongo ObjectId");
}
const server = await this.servers.serverList.findOne({ _id: data.serverId });
if (!server) {
throw new Error("WAP server not found");
}
return { server, baseUrl: this.getBotAdminApiUrl(server.ipv4), encodedOrganizationId: encodeURIComponent(data.organizationId) };
}
private async postSysAdminBotAction(
data: SysAdminWapServerOrganizationActionParams,
action: "start" | "stop",
): Promise<void> {
const { baseUrl, encodedOrganizationId } = await this.getSysAdminServerActionTarget(data);
const targetUrl = `${baseUrl}containers/${encodedOrganizationId}/${action}`;
try {
const response = await axios.post(targetUrl, {}, {
headers: { "Content-Type": "application/json" },
timeout: BOT_ADMIN_ACTION_TIMEOUT_MS,
});
const result: CreateBotResult = response.data;
if (result.error) {
throw new Error(result.error);
}
} catch (error) {
throw this.createBotAdminActionError(error, targetUrl);
}
}
private async deleteSysAdminRuntimeBot(
baseUrl: string,
encodedOrganizationId: string,
allowMissingContainer = false,
): Promise<{ botDeleted: boolean; botAlreadyMissing: boolean }> {
const targetUrl = `${baseUrl}containers/${encodedOrganizationId}`;
try {
const response = await axios.delete(targetUrl, { timeout: BOT_ADMIN_ACTION_TIMEOUT_MS });
const result: CreateBotResult = response.data;
if (result.error) {
throw new Error(result.error);
}
return { botDeleted: true, botAlreadyMissing: false };
} catch (error) {
if (allowMissingContainer && this.isBotAdminMissingContainerError(error)) {
return { botDeleted: false, botAlreadyMissing: true };
}
throw this.createBotAdminActionError(error, targetUrl);
}
}
private async waitForSysAdminBotStopped(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
const { baseUrl, encodedOrganizationId } = await this.getSysAdminServerActionTarget(data);
const targetUrl = `${baseUrl}containers/${encodedOrganizationId}/status`;
for (let attempt = 0; attempt < BOT_ADMIN_RESTART_STATUS_RETRIES; attempt += 1) {
try {
const response = await axios.post(targetUrl, {}, { timeout: BOT_ADMIN_ACTION_TIMEOUT_MS });
const status = String(response.data?.status || "").toLowerCase();
if (!status.includes("up")) {
return;
}
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 500) {
return;
}
throw this.createBotAdminActionError(error, targetUrl);
}
await new Promise((resolve) => setTimeout(resolve, BOT_ADMIN_RESTART_STATUS_DELAY_MS));
}
throw new Error("Bot did not stop before restart timeout; start was not attempted.");
}
private extractOrganizationIdFromContainer(container: any): string | undefined {
const names = Array.isArray(container?.Names) ? container.Names : [];
for (const rawName of names) {
const name = String(rawName || "").replace(/^\//, "");
const match = name.match(/^(.+)_bot_\d+$/);
if (match && match[1]) {
return match[1];
}
}
return undefined;
}
private sanitizeContainer(container: any): SysAdminWapContainerDto {
const names = Array.isArray(container?.Names) ? container.Names : [];
const firstName = names.length > 0 ? String(names[0]).replace(/^\//, "") : undefined;
return {
id: container?.Id,
name: firstName,
organizationId: this.extractOrganizationIdFromContainer(container),
image: container?.Image,
state: container?.State,
status: container?.Status,
ports: Array.isArray(container?.Ports) ? container.Ports : [],
created: container?.Created,
};
}
private async getSysAdminRuntimeContainers(server: any): Promise<SysAdminWapContainerDto[]> {
const containersUrl = this.getBotAdminApiUrl(server.ipv4) + "containers";
try {
const response = await axios.get(containersUrl, {
headers: { "Content-Type": "application/json" },
timeout: BOT_ADMIN_ACTION_TIMEOUT_MS,
});
return Array.isArray(response.data)
? response.data.map((container: any) => this.sanitizeContainer(container))
: [];
} catch (error) {
throw this.createBotAdminContainersError(error, containersUrl);
}
} }
public async getAvailableServer(): Promise<IWapServer> { public async getAvailableServer(): Promise<IWapServer> {
@@ -713,6 +980,264 @@ class WapServerManager implements IWapServerManager {
return servers; return servers;
} }
public async sysAdminUpdateWapServer(data: UpdateWapServerParams): Promise<IWapServer> {
if (!data.serverId) {
throw new Error("Server id is required");
}
if (!MONGO_OBJECT_ID_REGEX.test(data.serverId)) {
throw new Error("Server id must be a valid Mongo ObjectId");
}
const server = await this.servers.serverList.findOne({ _id: data.serverId });
if (!server) {
throw new Error("WAP server not found");
}
if (data.name !== undefined) {
server.name = String(data.name).trim();
}
if (data.ipv4 !== undefined) {
server.ipv4 = this.normalizeBotAdminApiUrl(data.ipv4);
}
if (data.port !== undefined) {
if (!Number.isFinite(data.port) || data.port < 0) {
throw new Error("Port must be a finite non-negative number");
}
server.port = data.port;
}
if (data.maxBots !== undefined) {
if (!Number.isFinite(data.maxBots) || data.maxBots < 0) {
throw new Error("Max bots must be a finite non-negative number");
}
server.maxBots = data.maxBots;
}
if (data.active !== undefined) {
server.active = Boolean(data.active);
}
await server.save();
return server;
}
public async sysAdminAuditWapServer(data: SysAdminWapServerAuditParams): Promise<SysAdminWapServerAuditResult> {
if (!data.serverId) {
throw new Error("Server id is required");
}
if (!MONGO_OBJECT_ID_REGEX.test(data.serverId)) {
throw new Error("Server id must be a valid Mongo ObjectId");
}
const server = await this.servers.serverList.findOne({ _id: data.serverId });
if (!server) {
throw new Error("WAP server not found");
}
const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId });
const assignedOrganizations = assignedCompanies.map((company: any) => ({
id: String(company.id || company._id),
name: company.name,
}));
const assignedIds = new Set(assignedOrganizations.map((organization) => organization.id));
const detectedBots = await this.getSysAdminRuntimeContainers(server);
const runtimeOrganizationIds = new Set(
detectedBots
.map((container) => container.organizationId)
.filter((organizationId): organizationId is string => Boolean(organizationId)),
);
const validBots = detectedBots.filter(
(container) => container.organizationId && assignedIds.has(container.organizationId),
);
const ghostBots = detectedBots.filter(
(container) => !container.organizationId || !assignedIds.has(container.organizationId),
);
const missingBots = assignedOrganizations.filter(
(organization) => !runtimeOrganizationIds.has(organization.id),
);
const expectedCountBots = assignedOrganizations.length;
const storedCountBots = server.countBots ?? 0;
const runtimeCountBots = detectedBots.length;
return {
serverId: String(server.id || server._id),
assignedOrganizations,
detectedBots,
validBots,
ghostBots,
missingBots,
expectedCountBots,
storedCountBots,
runtimeCountBots,
countMismatch: storedCountBots !== expectedCountBots || runtimeCountBots !== expectedCountBots,
};
}
public async sysAdminRecalculateWapServerCount(
data: SysAdminWapServerRecalculateCountParams,
): Promise<SysAdminWapServerRecalculateCountResult> {
if (!data.serverId) {
throw new Error("Server id is required");
}
if (!MONGO_OBJECT_ID_REGEX.test(data.serverId)) {
throw new Error("Server id must be a valid Mongo ObjectId");
}
const server = await this.servers.serverList.findOne({ _id: data.serverId });
if (!server) {
throw new Error("WAP server not found");
}
const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId });
const before = server.countBots;
const after = assignedCompanies.length;
server.countBots = after;
await server.save();
return { serverId: String(server.id || server._id), before, after };
}
public async sysAdminDeleteWapServer(data: SysAdminDeleteWapServerParams): Promise<SysAdminDeleteWapServerResult> {
if (!data.serverId) {
throw new Error("Server id is required");
}
if (!MONGO_OBJECT_ID_REGEX.test(data.serverId)) {
throw new Error("Server id must be a valid Mongo ObjectId");
}
const server = await this.servers.serverList.findOne({ _id: data.serverId });
if (!server) {
throw new Error("WAP server not found");
}
if (server.active) {
throw new Error("WAP server must be inactive before deletion");
}
const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId });
if (assignedCompanies.length > 0) {
throw new Error(`WAP server cannot be deleted because it has ${assignedCompanies.length} associated bot(s) in DB`);
}
let detectedBots: SysAdminWapContainerDto[];
try {
detectedBots = await this.getSysAdminRuntimeContainers(server);
} catch (error) {
const message = error instanceof Error && error.message ? error.message : "unknown error";
throw new Error(`WAP server cannot be deleted because bot-admin could not be audited. Verify runtime bots manually before deleting. ${message}`);
}
if (detectedBots.length > 0) {
throw new Error(`WAP server cannot be deleted because bot-admin detected ${detectedBots.length} runtime bot(s)`);
}
await this.servers.delete(data.serverId);
return { serverId: data.serverId, deleted: true };
}
public async sysAdminStartBot(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
await this.postSysAdminBotAction(data, "start");
}
public async sysAdminStopBot(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
await this.postSysAdminBotAction(data, "stop");
}
public async sysAdminRestartBot(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
await this.postSysAdminBotAction(data, "stop");
await this.waitForSysAdminBotStopped(data);
try {
await this.postSysAdminBotAction(data, "start");
} catch (error) {
const message = error instanceof Error && error.message ? error.message : "unknown error";
throw new Error(`Bot restart stopped the bot, but start failed. The bot may be stopped. ${message}`);
}
}
public async sysAdminGetQr(data: SysAdminWapServerOrganizationActionParams): Promise<SysAdminWapServerQrResult> {
const { baseUrl, encodedOrganizationId } = await this.getSysAdminServerActionTarget(data);
const targetUrl = `${baseUrl}containers/${encodedOrganizationId}/qr`;
try {
const response = await axios.get(targetUrl, {
responseType: "arraybuffer",
timeout: BOT_ADMIN_ACTION_TIMEOUT_MS,
});
const qr = Buffer.from(response.data, "binary").toString("base64");
return { serverId: data.serverId, organizationId: data.organizationId, qr };
} catch (error) {
throw this.createBotAdminActionError(error, targetUrl);
}
}
public async sysAdminDeleteBot(
data: SysAdminWapServerOrganizationActionParams,
): Promise<SysAdminWapServerDeleteBotResult> {
const { server, baseUrl, encodedOrganizationId } = await this.getSysAdminServerActionTarget(data);
const before = server.countBots ?? 0;
await this.deleteSysAdminRuntimeBot(baseUrl, encodedOrganizationId);
// Deleting the runtime bot intentionally keeps the DB assignment; detach is a separate sysadmin action.
const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId });
const after = assignedCompanies.length;
server.countBots = after;
await server.save();
return { serverId: data.serverId, organizationId: data.organizationId, assignmentRemains: true, before, after };
}
public async sysAdminDetachOrganizationServer(
data: SysAdminWapServerOrganizationActionParams,
): Promise<SysAdminWapServerDetachResult> {
const { server, baseUrl, encodedOrganizationId } = await this.getSysAdminServerActionTarget(data);
const company = await CompaniesManager.companies.findOne({ _id: data.organizationId });
if (!company) {
throw new Error("Organization not found");
}
if (String(company.wapServerId || "") !== data.serverId) {
throw new Error("Organization is not assigned to this WAP server");
}
const before = server.countBots ?? 0;
const deleteResult = await this.deleteSysAdminRuntimeBot(baseUrl, encodedOrganizationId, true);
company.wapServerId = undefined;
await company.save();
const assignedCompanies = await CompaniesManager.companies.find({ wapServerId: data.serverId });
const after = assignedCompanies.length;
server.countBots = after;
await server.save();
return { serverId: data.serverId, organizationId: data.organizationId, ...deleteResult, before, after };
}
} }
const WapServerList = new WapServerManager(); const WapServerList = new WapServerManager();
@@ -1,12 +1,60 @@
import { import {
PaginateWapServerResults, PaginateWapServerResults,
SysAdminDeleteWapServerRequest,
SysAdminDeleteWapServerResult,
SysAdminAuditWapServerParams,
SysAdminGetWapServerParams, SysAdminGetWapServerParams,
SysAdminRecalculateWapServerCountParams,
SysAdminWapServerDeleteBotResult,
SysAdminWapServerDetachResult,
SysAdminWapServerAuditResult,
SysAdminWapServerOrganizationActionRequest,
SysAdminWapServerQrResult,
SysAdminWapServerRecalculateCountResult,
SysAdminUpdateWapServerParams,
IWapServer,
} from "../../../Models/WapServer/WapServer.Interface"; } from "../../../Models/WapServer/WapServer.Interface";
import { ApiValidationError } from "../../../Models/Server.Error.model"; import { ApiValidationError } from "../../../Models/Server.Error.model";
import { ApiVoidResult } from "../../../Models/Api.VoidResult.type";
import axios from "axios";
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa"; import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { SA_WapServerService } from "./SysAdminWapServer.Service"; import { SA_WapServerService } from "./SysAdminWapServer.Service";
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth"; import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
const stringifyErrorBody = (data: unknown): string => {
if (data === undefined || data === null) {
return "";
}
if (typeof data === "string") {
return data;
}
const body = JSON.stringify(data);
return body.length > 1000 ? `${body.slice(0, 1000)}...` : body;
};
const getErrorDesc = (error: unknown): string => {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data as any;
const responseMessage =
responseData?.desc || responseData?.message || responseData?.error || stringifyErrorBody(responseData);
const requestUrl = error.config?.url ? ` url ${error.config.url}` : "";
const status = error.response?.status ? `status ${error.response.status}` : error.code || "no response";
return responseMessage || `Upstream request failed:${requestUrl} ${status}`.trim();
}
if (error instanceof Error && error.message) {
return error.message;
}
const fallback = stringifyErrorBody(error);
return fallback || "Unknown error while auditing WAP server";
};
@Route("sysadmin/wapserver/get") @Route("sysadmin/wapserver/get")
export class SysAdminWapServerGetController extends Controller { export class SysAdminWapServerGetController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error") @Response<ApiValidationError>(500, "Ha ocurrido un error")
@@ -20,6 +68,69 @@ export class SysAdminWapServerGetController extends Controller {
const result = await new SA_WapServerService().sysAdminGetWapServers(requestBody); const result = await new SA_WapServerService().sysAdminGetWapServers(requestBody);
this.setStatus(200); this.setStatus(200);
return result; return result;
} catch (e) {
this.setStatus(500);
return new ApiValidationError(500, getErrorDesc(e));
}
}
}
@Route("sysadmin/wapserver/update")
export class SysAdminWapServerUpdateController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
@Middlewares(sysAdminAuthMiddleware)
public async sysAdminUpdateWapServer(
@Body() requestBody: SysAdminUpdateWapServerParams,
): Promise<IWapServer | ApiValidationError> {
try {
const { payload, ...cleanRequestBody } = requestBody as any;
const result = await new SA_WapServerService().sysAdminUpdateWapServer(cleanRequestBody);
this.setStatus(200);
return result;
} catch (e) {
this.setStatus(500);
return new ApiValidationError(500, getErrorDesc(e));
}
}
}
@Route("sysadmin/wapserver/audit")
export class SysAdminWapServerAuditController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
@Middlewares(sysAdminAuthMiddleware)
public async sysAdminAuditWapServer(
@Body() requestBody: SysAdminAuditWapServerParams,
): Promise<SysAdminWapServerAuditResult | ApiValidationError> {
try {
const { payload, ...cleanRequestBody } = requestBody as any;
const result = await new SA_WapServerService().sysAdminAuditWapServer(cleanRequestBody);
this.setStatus(200);
return result;
} catch (e) {
this.setStatus(500);
return new ApiValidationError(500, getErrorDesc(e));
}
}
}
@Route("sysadmin/wapserver/recalculate-count")
export class SysAdminWapServerRecalculateCountController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
@Middlewares(sysAdminAuthMiddleware)
public async sysAdminRecalculateWapServerCount(
@Body() requestBody: SysAdminRecalculateWapServerCountParams,
): Promise<SysAdminWapServerRecalculateCountResult | ApiValidationError> {
try {
const { payload, ...cleanRequestBody } = requestBody as any;
const result = await new SA_WapServerService().sysAdminRecalculateWapServerCount(cleanRequestBody);
this.setStatus(200);
return result;
} catch (e) { } catch (e) {
const errorOccurred: Error = e as Error; const errorOccurred: Error = e as Error;
this.setStatus(500); this.setStatus(500);
@@ -27,3 +138,137 @@ export class SysAdminWapServerGetController extends Controller {
} }
} }
} }
@Route("sysadmin/wapserver/delete")
export class SysAdminWapServerDeleteController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
@Middlewares(sysAdminAuthMiddleware)
public async sysAdminDeleteWapServer(
@Body() requestBody: SysAdminDeleteWapServerRequest,
): Promise<SysAdminDeleteWapServerResult | ApiValidationError> {
try {
const { payload, ...cleanRequestBody } = requestBody as any;
const result = await new SA_WapServerService().sysAdminDeleteWapServer(cleanRequestBody);
this.setStatus(200);
return result;
} catch (e) {
this.setStatus(500);
return new ApiValidationError(500, getErrorDesc(e));
}
}
}
@Route("sysadmin/wapserver/bot")
export class SysAdminWapServerBotController extends Controller {
private cleanRequestBody(requestBody: SysAdminWapServerOrganizationActionRequest) {
const { payload, ...cleanRequestBody } = requestBody as any;
return cleanRequestBody;
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("start")
@Middlewares(sysAdminAuthMiddleware)
public async sysAdminStartBot(
@Body() requestBody: SysAdminWapServerOrganizationActionRequest,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new SA_WapServerService().sysAdminStartBot(this.cleanRequestBody(requestBody));
this.setStatus(200);
return { success: true, message: "ok" };
} catch (e) {
this.setStatus(500);
return new ApiValidationError(500, getErrorDesc(e));
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("stop")
@Middlewares(sysAdminAuthMiddleware)
public async sysAdminStopBot(
@Body() requestBody: SysAdminWapServerOrganizationActionRequest,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new SA_WapServerService().sysAdminStopBot(this.cleanRequestBody(requestBody));
this.setStatus(200);
return { success: true, message: "ok" };
} catch (e) {
this.setStatus(500);
return new ApiValidationError(500, getErrorDesc(e));
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("restart")
@Middlewares(sysAdminAuthMiddleware)
public async sysAdminRestartBot(
@Body() requestBody: SysAdminWapServerOrganizationActionRequest,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new SA_WapServerService().sysAdminRestartBot(this.cleanRequestBody(requestBody));
this.setStatus(200);
return { success: true, message: "ok" };
} catch (e) {
this.setStatus(500);
return new ApiValidationError(500, getErrorDesc(e));
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("qr")
@Middlewares(sysAdminAuthMiddleware)
public async sysAdminGetQr(
@Body() requestBody: SysAdminWapServerOrganizationActionRequest,
): Promise<SysAdminWapServerQrResult | ApiValidationError> {
try {
const result = await new SA_WapServerService().sysAdminGetQr(this.cleanRequestBody(requestBody));
this.setStatus(200);
return result;
} catch (e) {
this.setStatus(500);
return new ApiValidationError(500, getErrorDesc(e));
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("delete")
@Middlewares(sysAdminAuthMiddleware)
public async sysAdminDeleteBot(
@Body() requestBody: SysAdminWapServerOrganizationActionRequest,
): Promise<SysAdminWapServerDeleteBotResult | ApiValidationError> {
try {
const result = await new SA_WapServerService().sysAdminDeleteBot(this.cleanRequestBody(requestBody));
this.setStatus(200);
return result;
} catch (e) {
this.setStatus(500);
return new ApiValidationError(500, getErrorDesc(e));
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("detach")
@Middlewares(sysAdminAuthMiddleware)
public async sysAdminDetachOrganizationServer(
@Body() requestBody: SysAdminWapServerOrganizationActionRequest,
): Promise<SysAdminWapServerDetachResult | ApiValidationError> {
try {
const result = await new SA_WapServerService().sysAdminDetachOrganizationServer(
this.cleanRequestBody(requestBody),
);
this.setStatus(200);
return result;
} catch (e) {
this.setStatus(500);
return new ApiValidationError(500, getErrorDesc(e));
}
}
}
@@ -3,6 +3,17 @@ import { connect } from "mongoose";
import { import {
PaginateWapServerParams, PaginateWapServerParams,
PaginateWapServerResults, PaginateWapServerResults,
SysAdminDeleteWapServerParams,
SysAdminDeleteWapServerResult,
SysAdminWapServerAuditParams,
SysAdminWapServerAuditResult,
SysAdminWapServerDeleteBotResult,
SysAdminWapServerDetachResult,
SysAdminWapServerOrganizationActionParams,
SysAdminWapServerQrResult,
SysAdminWapServerRecalculateCountParams,
SysAdminWapServerRecalculateCountResult,
UpdateWapServerParams,
} from "../../../Models/WapServer/WapServer.Interface"; } from "../../../Models/WapServer/WapServer.Interface";
import WapServerList from "../../../Models/WapServer/WapServer"; import WapServerList from "../../../Models/WapServer/WapServer";
@@ -11,4 +22,60 @@ export class SA_WapServerService {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
return await WapServerList.sysAdminGetWapServers(data); return await WapServerList.sysAdminGetWapServers(data);
} }
public async sysAdminUpdateWapServer(data: UpdateWapServerParams) {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await WapServerList.sysAdminUpdateWapServer(data);
}
public async sysAdminAuditWapServer(data: SysAdminWapServerAuditParams): Promise<SysAdminWapServerAuditResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await WapServerList.sysAdminAuditWapServer(data);
}
public async sysAdminRecalculateWapServerCount(
data: SysAdminWapServerRecalculateCountParams,
): Promise<SysAdminWapServerRecalculateCountResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await WapServerList.sysAdminRecalculateWapServerCount(data);
}
public async sysAdminDeleteWapServer(data: SysAdminDeleteWapServerParams): Promise<SysAdminDeleteWapServerResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await WapServerList.sysAdminDeleteWapServer(data);
}
public async sysAdminStartBot(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await WapServerList.sysAdminStartBot(data);
}
public async sysAdminStopBot(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await WapServerList.sysAdminStopBot(data);
}
public async sysAdminRestartBot(data: SysAdminWapServerOrganizationActionParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await WapServerList.sysAdminRestartBot(data);
}
public async sysAdminGetQr(data: SysAdminWapServerOrganizationActionParams): Promise<SysAdminWapServerQrResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await WapServerList.sysAdminGetQr(data);
}
public async sysAdminDeleteBot(
data: SysAdminWapServerOrganizationActionParams,
): Promise<SysAdminWapServerDeleteBotResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await WapServerList.sysAdminDeleteBot(data);
}
public async sysAdminDetachOrganizationServer(
data: SysAdminWapServerOrganizationActionParams,
): Promise<SysAdminWapServerDetachResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await WapServerList.sysAdminDetachOrganizationServer(data);
}
} }
+124
View File
@@ -174,6 +174,130 @@ export const fetchWapServers = async (filters: any) => {
return res.data; return res.data;
}; };
export type WapServer = {
id?: string;
_id?: string;
name?: string;
description?: string;
ipv4: string;
ipv6?: string;
countBots: number;
maxBots: number;
port: number;
active: boolean;
};
export type UpdateWapServerPayload = {
serverId: string;
name?: string;
ipv4?: string;
port?: number;
maxBots?: number;
active?: boolean;
};
export const updateWapServer = async (data: UpdateWapServerPayload): Promise<WapServer> => {
const res = await sysadminApi.post('/wap/update', data);
return res.data;
};
export type WapAssignedOrganization = {
id: string;
name?: string;
};
export type WapContainerDto = {
id?: string;
name?: string;
organizationId?: string;
image?: string;
state?: string;
status?: string;
ports?: unknown[];
created?: number;
};
export type WapServerAuditResult = {
serverId: string;
assignedOrganizations: WapAssignedOrganization[];
detectedBots: WapContainerDto[];
validBots: WapContainerDto[];
ghostBots: WapContainerDto[];
missingBots: WapAssignedOrganization[];
expectedCountBots: number;
storedCountBots: number;
runtimeCountBots: number;
countMismatch: boolean;
};
export type WapServerRecalculateCountResult = {
serverId: string;
before: number;
after: number;
};
export type WapServerDeleteResult = {
serverId: string;
deleted: true;
};
export type WapServerOrganizationActionPayload = {
serverId: string;
organizationId: string;
};
export type WapServerQrResult = WapServerOrganizationActionPayload & {
qr: string;
};
export type WapServerDetachResult = WapServerOrganizationActionPayload & {
botDeleted: boolean;
botAlreadyMissing: boolean;
before: number;
after: number;
};
export const auditWapServer = async (serverId: string): Promise<WapServerAuditResult> => {
const res = await sysadminApi.post('/wap/audit', { serverId });
return res.data;
};
export const recalculateWapServerCount = async (serverId: string): Promise<WapServerRecalculateCountResult> => {
const res = await sysadminApi.post('/wap/recalculate-count', { serverId });
return res.data;
};
export const deleteWapServer = async (serverId: string): Promise<WapServerDeleteResult> => {
const res = await sysadminApi.post('/wap/delete', { serverId });
return res.data;
};
export const startWapBot = async (data: WapServerOrganizationActionPayload): Promise<void> => {
await sysadminApi.post('/wap/bot/start', data);
};
export const stopWapBot = async (data: WapServerOrganizationActionPayload): Promise<void> => {
await sysadminApi.post('/wap/bot/stop', data);
};
export const restartWapBot = async (data: WapServerOrganizationActionPayload): Promise<void> => {
await sysadminApi.post('/wap/bot/restart', data);
};
export const getWapBotQr = async (data: WapServerOrganizationActionPayload): Promise<WapServerQrResult> => {
const res = await sysadminApi.post('/wap/bot/qr', data);
return res.data;
};
export const deleteWapBot = async (data: WapServerOrganizationActionPayload): Promise<void> => {
await sysadminApi.post('/wap/bot/delete', data);
};
export const detachWapOrganizationServer = async (data: WapServerOrganizationActionPayload): Promise<WapServerDetachResult> => {
const res = await sysadminApi.post('/wap/bot/detach', data);
return res.data;
};
// Companies API Calls // Companies API Calls
export const fetchCompanies = async (filters: any) => { export const fetchCompanies = async (filters: any) => {
const res = await sysadminApi.post('/companies/paginate', filters); const res = await sysadminApi.post('/companies/paginate', filters);
+749 -7
View File
@@ -1,13 +1,36 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Search, RotateCcw, CheckCircle, XCircle, Server, ServerOff } from 'lucide-react'; import { Search, RotateCcw, CheckCircle, XCircle, Server, ServerOff, ClipboardList, Calculator, X, MoreVertical, Play, Pause, Power, Trash2, Unlink, QrCode, RotateCw, Edit3 } from 'lucide-react';
import { fetchWapServers } from '../api/sysadmin'; import { auditWapServer, deleteWapBot, deleteWapServer, detachWapOrganizationServer, fetchWapServers, getWapBotQr, recalculateWapServerCount, restartWapBot, startWapBot, stopWapBot, updateWapServer, type WapContainerDto, type WapServerAuditResult, type WapServerQrResult, type WapServerRecalculateCountResult } from '../api/sysadmin';
const getErrorMessage = (error: unknown, fallback: string): string => {
const responseData = (error as any)?.response?.data;
if (typeof responseData === 'string') {
return responseData;
}
const responseError = responseData?.error;
return responseData?.desc || responseData?.message || responseError?.desc || responseError?.message || responseError || (error as Error)?.message || fallback;
};
export const WapServersPage = () => { export const WapServersPage = () => {
const [servers, setServers] = useState<any[]>([]); const [servers, setServers] = useState<any[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [auditByServer, setAuditByServer] = useState<Record<string, WapServerAuditResult>>({});
const [selectedAuditServerId, setSelectedAuditServerId] = useState<string | null>(null);
const [auditTab, setAuditTab] = useState<'assigned' | 'ghosts' | 'missing'>('assigned');
const [recalculateResult, setRecalculateResult] = useState<WapServerRecalculateCountResult | null>(null);
const [qrResult, setQrResult] = useState<WapServerQrResult | null>(null);
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [actionServerId, setActionServerId] = useState<string | null>(null);
const [openActionsServerId, setOpenActionsServerId] = useState<string | null>(null);
const [openAuditActionKey, setOpenAuditActionKey] = useState<string | null>(null);
const [editingServer, setEditingServer] = useState<any | null>(null);
const [editForm, setEditForm] = useState({ name: '', ipv4: '', port: '', maxBots: '', active: true });
// Filters // Filters
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
@@ -42,6 +65,18 @@ export const WapServersPage = () => {
loadServers(); loadServers();
}, [page, filters.active]); }, [page, filters.active]);
useEffect(() => {
if (!openActionsServerId && !openAuditActionKey) return;
const closeActionsMenu = () => {
setOpenActionsServerId(null);
setOpenAuditActionKey(null);
};
document.addEventListener('click', closeActionsMenu);
return () => document.removeEventListener('click', closeActionsMenu);
}, [openActionsServerId, openAuditActionKey]);
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setPage(1); setPage(1);
@@ -59,12 +94,190 @@ export const WapServersPage = () => {
setPage(1); setPage(1);
}; };
const getServerId = (server: any) => server.id || server._id;
const runRowAction = (action: () => void) => {
setOpenActionsServerId(null);
setOpenAuditActionKey(null);
action();
};
const openEditServer = (server: any) => {
setEditingServer(server);
setEditForm({
name: server.name || '',
ipv4: server.ipv4 || '',
port: server.port === undefined || server.port === null ? '' : String(server.port),
maxBots: server.maxBots === undefined || server.maxBots === null ? '' : String(server.maxBots),
active: Boolean(server.active),
});
};
const handleSaveServer = async (event: React.FormEvent) => {
event.preventDefault();
if (!editingServer) return;
const serverId = getServerId(editingServer);
const port = Number(editForm.port);
const maxBots = Number(editForm.maxBots);
if (!Number.isFinite(port) || port < 0 || !Number.isFinite(maxBots) || maxBots < 0) {
alert('Puerto y cantidad máxima de bots deben ser números no negativos.');
return;
}
setActionServerId(serverId);
try {
await updateWapServer({
serverId,
name: editForm.name,
ipv4: editForm.ipv4,
port,
maxBots,
active: editForm.active,
});
setActionMessage('Servidor WAP actualizado.');
setEditingServer(null);
await loadServers();
} catch (e) {
console.error(e);
alert(getErrorMessage(e, 'Error actualizando servidor WAP'));
}
setActionServerId(null);
};
const refreshSelectedAudit = async (serverId: string) => {
const audit = await auditWapServer(serverId);
setAuditByServer(prev => ({ ...prev, [serverId]: audit }));
};
const handleAudit = async (serverId: string) => {
setActionServerId(serverId);
try {
const audit = await auditWapServer(serverId);
setAuditByServer(prev => ({ ...prev, [serverId]: audit }));
setAuditTab('assigned');
setSelectedAuditServerId(serverId);
} catch (e) {
console.error(e);
alert(getErrorMessage(e, 'Error auditando servidor WAP'));
}
setActionServerId(null);
};
const handleRecalculateCount = async (serverId: string) => {
setActionServerId(serverId);
try {
const result = await recalculateWapServerCount(serverId);
setRecalculateResult(result);
setSelectedAuditServerId(null);
await loadServers();
} catch (e) {
console.error(e);
alert(getErrorMessage(e, 'Error recalculando contador WAP'));
}
setActionServerId(null);
};
const handleDeleteServer = async (server: any) => {
const serverId = getServerId(server);
const serverLabel = server.name || serverId;
if (!window.confirm(`Confirmá eliminar el servidor WAP ${serverLabel}. Debe estar inactivo y sin bots asociados.`)) {
return;
}
setActionServerId(serverId);
try {
await deleteWapServer(serverId);
setActionMessage(`Servidor WAP eliminado: ${serverLabel}.`);
setSelectedAuditServerId(null);
await loadServers();
} catch (e) {
console.error(e);
alert(getErrorMessage(e, 'Error eliminando servidor WAP'));
}
setActionServerId(null);
};
const handleBotAction = async (
serverId: string,
organizationId: string,
action: 'start' | 'pause' | 'restart' | 'qr' | 'poweroff' | 'delete' | 'detach'
) => {
const labels = {
start: 'iniciar el bot',
pause: 'pausar el bot',
restart: 'reiniciar el bot',
qr: 'obtener QR',
poweroff: 'apagar el bot',
delete: 'eliminar el bot',
detach: 'desasociar el servidor'
};
const destructive = action === 'poweroff' || action === 'delete' || action === 'detach';
if (destructive && !window.confirm(`Confirmá ${labels[action]} para la organización ${organizationId}.`)) {
return;
}
setActionServerId(serverId);
try {
const payload = { serverId, organizationId };
if (action === 'start') await startWapBot(payload);
if (action === 'pause' || action === 'poweroff') await stopWapBot(payload);
if (action === 'restart') await restartWapBot(payload);
if (action === 'qr') setQrResult(await getWapBotQr(payload));
if (action === 'delete') await deleteWapBot(payload);
if (action === 'detach') {
const result = await detachWapOrganizationServer(payload);
const botMessage = result.botAlreadyMissing ? 'El bot ya no existía en el servidor.' : 'Bot eliminado del servidor.';
setActionMessage(`Servidor desasociado. ${botMessage} Contador: ${result.before} -> ${result.after}.`);
await loadServers();
}
if (action !== 'qr' && action !== 'detach') {
setActionMessage(`Acción completada: ${labels[action]}.`);
}
await refreshSelectedAudit(serverId);
if (action === 'delete') await loadServers();
} catch (e) {
console.error(e);
alert(getErrorMessage(e, `Error al ${labels[action]}`));
}
setActionServerId(null);
};
const renderAuditActionsMenu = (key: string, serverId: string, organizationId: string, includeBotActions: boolean, includeDetach: boolean) => (
<div className="actions-menu-wrapper audit-actions-wrapper" onClick={(e) => e.stopPropagation()}>
<button className="icon-btn" title="Acciones" aria-label={`Acciones para organización ${organizationId}`} aria-haspopup="menu" aria-expanded={openAuditActionKey === key} disabled={actionServerId === serverId} onClick={() => setOpenAuditActionKey(openAuditActionKey === key ? null : key)}>
<MoreVertical size={16} />
</button>
{openAuditActionKey === key && (
<div className="actions-menu audit-row-menu" role="menu">
{includeBotActions && <button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'start'))}><Play size={16} /> Reanudar bot</button>}
{includeBotActions && <button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'pause'))}><Pause size={16} /> Pausar</button>}
{includeBotActions && <button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'restart'))}><RotateCw size={16} /> Reiniciar</button>}
{includeBotActions && <button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'qr'))}><QrCode size={16} /> Obtener QR</button>}
{includeBotActions && <button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'poweroff'))}><Power size={16} /> Apagar</button>}
{includeBotActions && <button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'delete'))}><Trash2 size={16} /> Eliminar bot</button>}
{includeDetach && <button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleBotAction(serverId, organizationId, 'detach'))}><Unlink size={16} /> Desasociar servidor</button>}
</div>
)}
</div>
);
useEffect(() => { useEffect(() => {
if (filters.name === '' && filters.description === '' && filters.active === 'all' && filters.countBotsFrom === '' && filters.countBotsTo === '') { if (filters.name === '' && filters.description === '' && filters.active === 'all' && filters.countBotsFrom === '' && filters.countBotsTo === '') {
loadServers(); loadServers();
} }
}, [filters]); }, [filters]);
const selectedAudit = selectedAuditServerId ? auditByServer[selectedAuditServerId] : null;
const selectedAuditServer = selectedAuditServerId ? servers.find(s => getServerId(s) === selectedAuditServerId) : null;
const recalculateServer = recalculateResult ? servers.find(s => getServerId(s) === recalculateResult.serverId) : null;
return ( return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in users-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in users-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
@@ -147,16 +360,22 @@ export const WapServersPage = () => {
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600' }}>Dirección IP</th> <th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600' }}>Dirección IP</th>
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', textAlign: 'center' }}>Capacidad</th> <th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', textAlign: 'center' }}>Capacidad</th>
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', textAlign: 'center' }}>Estado</th> <th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', textAlign: 'center' }}>Estado</th>
<th className="sticky-header" style={{ padding: '1rem', color: 'var(--text-muted)', fontWeight: '600', textAlign: 'center' }}>Diagnóstico</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr><td colSpan={4} style={{ padding: '2rem', textAlign: 'center' }}>Cargando servidores...</td></tr> <tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center' }}>Cargando servidores...</td></tr>
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<tr><td colSpan={4} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron servidores WAP</td></tr> <tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron servidores WAP</td></tr>
) : ( ) : (
servers.map(s => ( servers.map(s => {
<tr key={s._id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover"> const serverId = getServerId(s);
const isRunningAction = actionServerId === serverId;
return (
<React.Fragment key={serverId}>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover">
<td style={{ padding: '1rem' }}> <td style={{ padding: '1rem' }}>
<div style={{ fontWeight: '600', color: 'var(--text-main)', display: 'flex', alignItems: 'center', gap: '8px' }}> <div style={{ fontWeight: '600', color: 'var(--text-main)', display: 'flex', alignItems: 'center', gap: '8px' }}>
{s.active ? <Server size={16} color="var(--primary)" /> : <ServerOff size={16} color="var(--text-muted)" />} {s.active ? <Server size={16} color="var(--primary)" /> : <ServerOff size={16} color="var(--text-muted)" />}
@@ -179,8 +398,40 @@ export const WapServersPage = () => {
{s.active ? 'Activo' : 'Inactivo'} {s.active ? 'Activo' : 'Inactivo'}
</div> </div>
</td> </td>
<td style={{ padding: '1rem' }}>
<div className="actions-menu-wrapper" onClick={(e) => e.stopPropagation()}>
<button
className="icon-btn"
title="Acciones"
aria-label={`Acciones para ${s.name || serverId}`}
aria-haspopup="menu"
aria-expanded={openActionsServerId === serverId}
disabled={isRunningAction}
onClick={() => setOpenActionsServerId(openActionsServerId === serverId ? null : serverId)}
>
<MoreVertical size={18} />
</button>
{openActionsServerId === serverId && (
<div className="actions-menu" role="menu">
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => openEditServer(s))}>
<Edit3 size={16} /> Editar
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleAudit(serverId))}>
<ClipboardList size={16} /> Auditar
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleRecalculateCount(serverId))}>
<Calculator size={16} /> Recalcular contador
</button>
<button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleDeleteServer(s))}>
<Trash2 size={16} /> Eliminar
</button>
</div>
)}
</div>
</td>
</tr> </tr>
)) </React.Fragment>
);})
)} )}
</tbody> </tbody>
</table> </table>
@@ -196,6 +447,223 @@ export const WapServersPage = () => {
</div> </div>
</div> </div>
{selectedAudit && (
<div className="modal-overlay" onClick={() => setSelectedAuditServerId(null)}>
<div className="audit-modal glass-panel" onClick={(event) => event.stopPropagation()}>
<div className="audit-modal-header">
<div>
<h2>Diagnóstico WAP</h2>
<p>{selectedAuditServer?.name || selectedAudit.serverId}</p>
</div>
<button className="btn-secondary" onClick={() => setSelectedAuditServerId(null)} aria-label="Cerrar diagnóstico">
<X size={18} />
</button>
</div>
<div className="audit-panel">
<div className="audit-summary">
<span>Stored: <strong>{selectedAudit.storedCountBots}</strong></span>
<span>Expected: <strong>{selectedAudit.expectedCountBots}</strong></span>
<span>Runtime: <strong>{selectedAudit.runtimeCountBots}</strong></span>
<span style={{ color: selectedAudit.countMismatch ? 'var(--warning)' : 'var(--success)' }}>
{selectedAudit.countMismatch ? 'Mismatch' : 'OK'}
</span>
</div>
<div className="audit-tabs" role="tablist" aria-label="Resultados del diagnóstico WAP">
<button type="button" role="tab" className={auditTab === 'assigned' ? 'audit-tab active' : 'audit-tab'} onClick={() => setAuditTab('assigned')}>
Organizaciones asignadas ({selectedAudit.assignedOrganizations.length})
</button>
<button type="button" role="tab" className={auditTab === 'ghosts' ? 'audit-tab active' : 'audit-tab'} onClick={() => setAuditTab('ghosts')}>
Bots Fantasmas ({selectedAudit.ghostBots.length})
</button>
<button type="button" role="tab" className={auditTab === 'missing' ? 'audit-tab active' : 'audit-tab'} onClick={() => setAuditTab('missing')}>
Organizaciones sin bots ({selectedAudit.missingBots.length})
</button>
</div>
<div className="audit-tab-panel">
{auditTab === 'assigned' && (
selectedAudit.assignedOrganizations.length === 0
? <p>Sin organizaciones asignadas.</p>
: <table className="audit-result-table">
<thead>
<tr>
<th>Organización</th>
<th>ID</th>
<th>Estado del bot</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{selectedAudit.assignedOrganizations.map(org => {
const bot = selectedAudit.validBots.find((item: WapContainerDto) => item.organizationId === org.id);
return (
<tr key={org.id}>
<td>{org.name || 'Sin nombre'}</td>
<td><small>{org.id}</small></td>
<td>{bot?.status || bot?.state || 'Sin bot detectado'}</td>
<td>{renderAuditActionsMenu(`assigned-${org.id}`, selectedAudit.serverId, org.id, true, true)}</td>
</tr>
);
})}
</tbody>
</table>
)}
{auditTab === 'ghosts' && (
selectedAudit.ghostBots.length === 0
? <p>Sin bots fantasma.</p>
: <table className="audit-result-table">
<thead>
<tr>
<th>Bot</th>
<th>Organización</th>
<th>Estado</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{selectedAudit.ghostBots.map((bot: WapContainerDto) => (
<tr key={bot.id || bot.name}>
<td>{bot.name || bot.id || 'Sin nombre'}</td>
<td><small>{bot.organizationId || 'organizationId no detectado'}</small></td>
<td>{bot.status || bot.state || '-'}</td>
<td>{bot.organizationId ? renderAuditActionsMenu(`ghost-${bot.organizationId}-${bot.id || bot.name}`, selectedAudit.serverId, bot.organizationId, true, false) : '-'}</td>
</tr>
))}
</tbody>
</table>
)}
{auditTab === 'missing' && (
selectedAudit.missingBots.length === 0
? <p>Sin organizaciones sin bot.</p>
: <table className="audit-result-table">
<thead>
<tr>
<th>Organización</th>
<th>ID</th>
<th style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{selectedAudit.missingBots.map(org => (
<tr key={org.id}>
<td>{org.name || 'Sin nombre'}</td>
<td><small>{org.id}</small></td>
<td>{renderAuditActionsMenu(`missing-${org.id}`, selectedAudit.serverId, org.id, false, true)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
</div>
)}
{editingServer && (
<div className="modal-overlay" onClick={() => setEditingServer(null)}>
<form className="edit-server-modal glass-panel" onSubmit={handleSaveServer} onClick={(event) => event.stopPropagation()}>
<div className="audit-modal-header">
<div>
<h2>Editar servidor</h2>
<p>{editingServer.name || getServerId(editingServer)}</p>
</div>
<button type="button" className="btn-secondary" onClick={() => setEditingServer(null)} aria-label="Cerrar edición">
<X size={18} />
</button>
</div>
<div className="edit-server-grid">
<label>
<span>Nombre</span>
<input className="input-glass" value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} />
</label>
<label>
<span>URL</span>
<input className="input-glass" value={editForm.ipv4} onChange={(e) => setEditForm({ ...editForm, ipv4: e.target.value })} placeholder="http://localhost:3005/" required />
<small style={{ color: 'var(--text-muted)' }}>Usá solo el origen HTTP(S), sin path, query ni credenciales.</small>
</label>
<label>
<span>Puerto</span>
<input className="input-glass" type="number" min="0" value={editForm.port} onChange={(e) => setEditForm({ ...editForm, port: e.target.value })} required />
</label>
<label>
<span>Cantidad máxima de bots</span>
<input className="input-glass" type="number" min="0" value={editForm.maxBots} onChange={(e) => setEditForm({ ...editForm, maxBots: e.target.value })} required />
</label>
</div>
<button type="button" className={editForm.active ? 'status-toggle active' : 'status-toggle'} onClick={() => setEditForm({ ...editForm, active: !editForm.active })}>
{editForm.active ? <CheckCircle size={16} /> : <XCircle size={16} />}
{editForm.active ? 'Activo' : 'Inactivo'}
</button>
<div className="edit-server-actions">
<button type="button" className="btn-secondary" onClick={() => setEditingServer(null)}>Cancelar</button>
<button type="submit" className="btn-primary" disabled={actionServerId === getServerId(editingServer)}>Guardar cambios</button>
</div>
</form>
</div>
)}
{recalculateResult && (
<div className="modal-overlay" onClick={() => setRecalculateResult(null)}>
<div className="recalculate-modal glass-panel" onClick={(event) => event.stopPropagation()}>
<div className="audit-modal-header">
<div>
<h2>Contador recalculado</h2>
<p>{recalculateServer?.name || recalculateResult.serverId}</p>
</div>
<button className="btn-secondary" onClick={() => setRecalculateResult(null)} aria-label="Cerrar resultado">
<X size={18} />
</button>
</div>
<div className="recalculate-summary">
<span>Antes: <strong>{recalculateResult.before}</strong></span>
<span>Después: <strong>{recalculateResult.after}</strong></span>
</div>
</div>
</div>
)}
{qrResult && (
<div className="modal-overlay" onClick={() => setQrResult(null)}>
<div className="recalculate-modal glass-panel" onClick={(event) => event.stopPropagation()}>
<div className="audit-modal-header">
<div>
<h2>QR del bot</h2>
<p>{qrResult.organizationId}</p>
</div>
<button className="btn-secondary" onClick={() => setQrResult(null)} aria-label="Cerrar QR">
<X size={18} />
</button>
</div>
<div className="qr-result">
<img src={`data:image/png;base64,${qrResult.qr}`} alt={`QR para ${qrResult.organizationId}`} />
<textarea readOnly value={qrResult.qr} />
</div>
</div>
</div>
)}
{actionMessage && (
<div className="modal-overlay" onClick={() => setActionMessage(null)}>
<div className="recalculate-modal glass-panel" onClick={(event) => event.stopPropagation()}>
<div className="audit-modal-header">
<div>
<h2>Acción completada</h2>
<p>{actionMessage}</p>
</div>
<button className="btn-secondary" onClick={() => setActionMessage(null)} aria-label="Cerrar resultado">
<X size={18} />
</button>
</div>
</div>
</div>
)}
<style dangerouslySetInnerHTML={{__html: ` <style dangerouslySetInnerHTML={{__html: `
.sticky-header { .sticky-header {
position: sticky; position: sticky;
@@ -209,6 +677,280 @@ export const WapServersPage = () => {
.table-row-hover:hover { .table-row-hover:hover {
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
} }
.icon-btn {
background: rgba(255,255,255,0.05);
color: var(--text-muted);
padding: 8px;
border-radius: 8px;
transition: all 0.2s;
}
.icon-btn:hover:not(:disabled) {
background: var(--primary);
color: white;
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.actions-menu-wrapper {
position: relative;
display: inline-flex;
justify-content: flex-end;
width: 100%;
}
.actions-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
min-width: 220px;
padding: 0.4rem;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background: #111827;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(12px);
}
.actions-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.75rem;
border: 0;
border-radius: 8px;
background: transparent;
color: #f8fafc;
font-size: 0.9rem;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: all 0.2s;
}
.actions-menu-item:hover {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
.actions-menu-item.danger {
color: #fecaca;
}
.actions-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.18);
color: #ffffff;
}
.audit-actions-wrapper {
justify-content: flex-end;
}
.audit-row-menu {
z-index: 50;
}
.audit-panel {
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(8px);
overflow: auto;
}
.audit-modal {
width: min(1100px, 96vw);
max-height: 86vh;
overflow: visible;
padding: 1.25rem;
}
.recalculate-modal {
width: min(520px, 96vw);
padding: 1.25rem;
}
.edit-server-modal {
width: min(560px, 96vw);
padding: 1.25rem;
}
.edit-server-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.edit-server-grid label {
display: grid;
gap: 0.45rem;
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 600;
}
.status-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.7rem 0.9rem;
border: 1px solid var(--glass-border);
border-radius: 12px;
background: rgba(245, 158, 11, 0.12);
color: var(--warning);
font-weight: 700;
cursor: pointer;
}
.status-toggle.active {
background: rgba(34, 197, 94, 0.12);
color: var(--success);
}
.edit-server-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.25rem;
}
.audit-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.audit-modal-header h2 {
margin: 0;
color: var(--text-main);
}
.audit-modal-header p {
margin: 0.25rem 0 0;
color: var(--text-muted);
}
.recalculate-summary {
display: flex;
gap: 1rem;
flex-wrap: wrap;
padding: 1rem;
border: 1px solid var(--glass-border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
}
.qr-result {
display: grid;
gap: 1rem;
}
.qr-result img {
max-width: 260px;
width: 100%;
justify-self: center;
border-radius: 12px;
background: #ffffff;
padding: 0.75rem;
}
.qr-result textarea {
min-height: 120px;
width: 100%;
resize: vertical;
border: 1px solid var(--glass-border);
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
color: var(--text-muted);
padding: 0.75rem;
font-family: monospace;
font-size: 0.8rem;
}
.audit-summary {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
color: var(--text-muted);
}
.audit-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.audit-grid h3 {
font-size: 0.9rem;
margin-bottom: 0.5rem;
color: var(--text-main);
}
.audit-grid p {
margin: 0 0 0.4rem;
color: var(--text-muted);
font-size: 0.85rem;
}
.audit-grid small {
display: block;
opacity: 0.75;
word-break: break-all;
}
.audit-tabs {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
border-bottom: 1px solid var(--glass-border);
}
.audit-tab {
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-muted);
padding: 0.75rem 0.85rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.audit-tab:hover,
.audit-tab.active {
color: var(--text-main);
border-bottom-color: var(--primary);
}
.audit-tab-panel p {
margin: 0 0 0.6rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.audit-tab-panel small {
display: block;
opacity: 0.75;
word-break: break-all;
}
.audit-result-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.audit-result-table th {
padding: 0.85rem 1rem;
color: var(--text-muted);
font-weight: 600;
border-bottom: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.03);
}
.audit-result-table td {
padding: 0.85rem 1rem;
color: var(--text-main);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.audit-result-table tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
.audit-result-table small {
color: var(--text-muted);
word-break: break-all;
}
@media (max-width: 900px) {
.audit-grid {
grid-template-columns: 1fr;
}
.edit-server-grid {
grid-template-columns: 1fr;
}
}
`}} /> `}} />
</motion.div> </motion.div>
); );
+133 -1
View File
@@ -1,9 +1,28 @@
import axios from "axios";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { WapService } from "../services/wap.service"; import { WapService } from "../services/wap.service";
import { PaginateWapServerParams } from "src/models/WapServers.Model"; import { PaginateWapServerParams, UpdateWapServerParams, WapServerByIdParams, WapServerOrganizationActionParams } from "src/models/WapServers.Model";
const wapService = new WapService(); const wapService = new WapService();
const getErrorStatus = (error: unknown): number => {
if (axios.isAxiosError(error) && error.response?.status) {
return error.response.status;
}
return 500;
};
const getErrorMessage = (error: unknown): string => {
const responseData = axios.isAxiosError(error) ? error.response?.data : undefined;
if (typeof responseData === "string") {
return responseData;
}
return responseData?.desc || responseData?.message || responseData?.error || (error as Error)?.message || "Internal Server Error";
};
export class WapController { export class WapController {
public getServers = async (req: Request, res: Response): Promise<void> => { public getServers = async (req: Request, res: Response): Promise<void> => {
try { try {
@@ -15,4 +34,117 @@ export class WapController {
res.status(500).json({ success: false, message: "Internal Server Error" }); res.status(500).json({ success: false, message: "Internal Server Error" });
} }
}; };
public auditServer = async (req: Request, res: Response): Promise<void> => {
try {
const data: WapServerByIdParams = req.body as WapServerByIdParams;
const audit = await wapService.auditWapServer(data);
res.json(audit);
} catch (error) {
console.error("Error auditing wap server:", error);
const message = getErrorMessage(error);
res.status(getErrorStatus(error)).json({ success: false, message, desc: message });
}
};
public updateServer = async (req: Request, res: Response): Promise<void> => {
try {
const result = await wapService.updateWapServer(req.body as UpdateWapServerParams);
res.json(result);
} catch (error) {
console.error("Error updating wap server:", error);
const message = getErrorMessage(error);
res.status(getErrorStatus(error)).json({ success: false, message, desc: message });
}
};
public recalculateCount = async (req: Request, res: Response): Promise<void> => {
try {
const data: WapServerByIdParams = req.body as WapServerByIdParams;
const result = await wapService.recalculateWapServerCount(data);
res.json(result);
} catch (error) {
console.error("Error recalculating wap server count:", error);
const message = getErrorMessage(error);
res.status(getErrorStatus(error)).json({ success: false, message, desc: message });
}
};
public deleteServer = async (req: Request, res: Response): Promise<void> => {
try {
const data: WapServerByIdParams = req.body as WapServerByIdParams;
const result = await wapService.deleteWapServer(data);
res.json(result);
} catch (error) {
console.error("Error deleting wap server:", error);
const message = getErrorMessage(error);
res.status(getErrorStatus(error)).json({ success: false, message, desc: message });
}
};
public startBot = async (req: Request, res: Response): Promise<void> => {
try {
await wapService.startBot(req.body as WapServerOrganizationActionParams);
res.json({ success: true, message: "ok" });
} catch (error) {
console.error("Error starting wap bot:", error);
const message = getErrorMessage(error);
res.status(getErrorStatus(error)).json({ success: false, message, desc: message });
}
};
public stopBot = async (req: Request, res: Response): Promise<void> => {
try {
await wapService.stopBot(req.body as WapServerOrganizationActionParams);
res.json({ success: true, message: "ok" });
} catch (error) {
console.error("Error stopping wap bot:", error);
const message = getErrorMessage(error);
res.status(getErrorStatus(error)).json({ success: false, message, desc: message });
}
};
public restartBot = async (req: Request, res: Response): Promise<void> => {
try {
await wapService.restartBot(req.body as WapServerOrganizationActionParams);
res.json({ success: true, message: "ok" });
} catch (error) {
console.error("Error restarting wap bot:", error);
const message = getErrorMessage(error);
res.status(getErrorStatus(error)).json({ success: false, message, desc: message });
}
};
public getBotQr = async (req: Request, res: Response): Promise<void> => {
try {
const result = await wapService.getBotQr(req.body as WapServerOrganizationActionParams);
res.json(result);
} catch (error) {
console.error("Error getting wap bot qr:", error);
const message = getErrorMessage(error);
res.status(getErrorStatus(error)).json({ success: false, message, desc: message });
}
};
public deleteBot = async (req: Request, res: Response): Promise<void> => {
try {
await wapService.deleteBot(req.body as WapServerOrganizationActionParams);
res.json({ success: true, message: "ok" });
} catch (error) {
console.error("Error deleting wap bot:", error);
const message = getErrorMessage(error);
res.status(getErrorStatus(error)).json({ success: false, message, desc: message });
}
};
public detachOrganizationServer = async (req: Request, res: Response): Promise<void> => {
try {
const result = await wapService.detachOrganizationServer(req.body as WapServerOrganizationActionParams);
res.json(result);
} catch (error) {
console.error("Error detaching wap organization server:", error);
const message = getErrorMessage(error);
res.status(getErrorStatus(error)).json({ success: false, message, desc: message });
}
};
} }
+70
View File
@@ -6,6 +6,7 @@ export type FindWapServerParams = {
description?: string; description?: string;
countBots?: number; countBots?: number;
maxBots?: number; maxBots?: number;
port?: number;
active?: boolean; active?: boolean;
countBotsFrom?: number; countBotsFrom?: number;
countBotsTo?: number; countBotsTo?: number;
@@ -19,9 +20,19 @@ export interface IWapServer {
ipv6: string; ipv6: string;
countBots: number; countBots: number;
maxBots: number; maxBots: number;
port: number;
active: boolean; active: boolean;
} }
export type UpdateWapServerParams = {
serverId: string;
name?: string;
ipv4?: string;
port?: number;
maxBots?: number;
active?: boolean;
};
export type PaginateWapServerParams = FindWapServerParams & { export type PaginateWapServerParams = FindWapServerParams & {
page: number; page: number;
limit: number; limit: number;
@@ -36,3 +47,62 @@ export type PaginateWapServerResults = {
export type SysAdminGetWapServerParams = PaginateWapServerParams & { export type SysAdminGetWapServerParams = PaginateWapServerParams & {
payload: IPayload; payload: IPayload;
}; };
export type WapServerByIdParams = {
serverId: string;
};
export type WapServerDeleteResult = {
serverId: string;
deleted: true;
};
export type WapServerOrganizationActionParams = WapServerByIdParams & {
organizationId: string;
};
export type WapServerQrResult = WapServerOrganizationActionParams & {
qr: string;
};
export type WapServerDetachResult = WapServerOrganizationActionParams & {
botDeleted: boolean;
botAlreadyMissing: boolean;
before: number;
after: number;
};
export type WapAssignedOrganization = {
id: string;
name?: string;
};
export type WapContainerDto = {
id?: string;
name?: string;
organizationId?: string;
image?: string;
state?: string;
status?: string;
ports?: unknown[];
created?: number;
};
export type WapServerAuditResult = {
serverId: string;
assignedOrganizations: WapAssignedOrganization[];
detectedBots: WapContainerDto[];
validBots: WapContainerDto[];
ghostBots: WapContainerDto[];
missingBots: WapAssignedOrganization[];
expectedCountBots: number;
storedCountBots: number;
runtimeCountBots: number;
countMismatch: boolean;
};
export type WapServerRecalculateCountResult = {
serverId: string;
before: number;
after: number;
};
+10
View File
@@ -5,5 +5,15 @@ const router = Router();
const wapController = new WapController(); const wapController = new WapController();
router.post("/paginate", wapController.getServers); router.post("/paginate", wapController.getServers);
router.post("/update", wapController.updateServer);
router.post("/audit", wapController.auditServer);
router.post("/recalculate-count", wapController.recalculateCount);
router.post("/delete", wapController.deleteServer);
router.post("/bot/start", wapController.startBot);
router.post("/bot/stop", wapController.stopBot);
router.post("/bot/restart", wapController.restartBot);
router.post("/bot/qr", wapController.getBotQr);
router.post("/bot/delete", wapController.deleteBot);
router.post("/bot/detach", wapController.detachOrganizationServer);
export default router; export default router;
+97 -1
View File
@@ -2,7 +2,19 @@ import { getSysAdminNonce } from "../helpers/GetSysAdminNonce";
import { getApiHost } from "../helpers/GetApiHost"; import { getApiHost } from "../helpers/GetApiHost";
import axios from "axios"; import axios from "axios";
import { getPayload } from "../helpers/GetPayload"; import { getPayload } from "../helpers/GetPayload";
import { PaginateWapServerParams, PaginateWapServerResults } from "../models/WapServers.Model"; import {
PaginateWapServerParams,
PaginateWapServerResults,
WapServerAuditResult,
WapServerByIdParams,
WapServerDeleteResult,
WapServerDetachResult,
WapServerOrganizationActionParams,
WapServerQrResult,
WapServerRecalculateCountResult,
UpdateWapServerParams,
IWapServer,
} from "../models/WapServers.Model";
export class WapService { export class WapService {
public async getWapServers(filters: PaginateWapServerParams): Promise<PaginateWapServerResults> { public async getWapServers(filters: PaginateWapServerParams): Promise<PaginateWapServerResults> {
@@ -20,4 +32,88 @@ export class WapService {
return resultado; return resultado;
} }
public async auditWapServer(data: WapServerByIdParams): Promise<WapServerAuditResult> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/wapserver/audit`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 10000,
});
return response.data as WapServerAuditResult;
}
public async updateWapServer(data: UpdateWapServerParams): Promise<IWapServer> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/wapserver/update`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 5000,
});
return response.data as IWapServer;
}
public async recalculateWapServerCount(data: WapServerByIdParams): Promise<WapServerRecalculateCountResult> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/wapserver/recalculate-count`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 5000,
});
return response.data as WapServerRecalculateCountResult;
}
public async deleteWapServer(data: WapServerByIdParams): Promise<WapServerDeleteResult> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/wapserver/delete`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 15000,
});
return response.data as WapServerDeleteResult;
}
private async postBotAction<T>(path: string, data: WapServerOrganizationActionParams, timeout = 10000): Promise<T> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/wapserver/bot/${path}`, postData, {
headers: { "Content-Type": "application/json" },
timeout,
});
return response.data as T;
}
public async startBot(data: WapServerOrganizationActionParams): Promise<void> {
await this.postBotAction("start", data);
}
public async stopBot(data: WapServerOrganizationActionParams): Promise<void> {
await this.postBotAction("stop", data);
}
public async restartBot(data: WapServerOrganizationActionParams): Promise<void> {
await this.postBotAction("restart", data, 15000);
}
public async getBotQr(data: WapServerOrganizationActionParams): Promise<WapServerQrResult> {
return await this.postBotAction<WapServerQrResult>("qr", data);
}
public async deleteBot(data: WapServerOrganizationActionParams): Promise<void> {
await this.postBotAction("delete", data);
}
public async detachOrganizationServer(data: WapServerOrganizationActionParams): Promise<WapServerDetachResult> {
return await this.postBotAction<WapServerDetachResult>("detach", data);
}
} }