diff --git a/hook/bindAdminpanel.ts b/hook/bindAdminpanel.ts index ddb38eaf..fea3c2de 100644 --- a/hook/bindAdminpanel.ts +++ b/hook/bindAdminpanel.ts @@ -7,10 +7,12 @@ export default function bindAdminpanel() { let ProductCatalog: any; let ProductMediaManager: any; + let OrderLogsViewerControl: any; let initializeWidgets: any; try { ProductCatalog = require("../libs/adminpanel/ProductCatalog/ProductCatalog").ProductCatalog; ProductMediaManager = require("../libs/adminpanel/ProductMediaManager/ProductMediaManager").ProductMediaManager; + OrderLogsViewerControl = require("../libs/adminpanel/controls/OrderLogsViewerControl").OrderLogsViewerControl; initializeWidgets = require("../lib/adminpanel/widgets").initializeWidgets; } catch (e) { sails.log.warn("Adminpanel bindings are skipped: failed to load adminpanel modules", e); @@ -23,9 +25,15 @@ export default function bindAdminpanel() { catalogHandler.add(productCatalog); // Product media manager bind - const mediaManagerHandler = sails.hooks.adminpanel.adminizer.mediaManagerHandler - const productMediaManager = new ProductMediaManager() - mediaManagerHandler.add(productMediaManager) + const mediaManagerHandler = sails.hooks.adminpanel.adminizer.mediaManagerHandler + const productMediaManager = new ProductMediaManager() + mediaManagerHandler.add(productMediaManager) + + // Order logs custom viewer control bind + const controlsHandler = sails.hooks.adminpanel.adminizer.controlsHandler; + if (!controlsHandler.get("jsonEditor", "order-logs-viewer")) { + controlsHandler.add(new OrderLogsViewerControl(sails.hooks.adminpanel.adminizer)); + } // Initialize dashboard widgets initializeWidgets(); diff --git a/lib/adminpanel/src/controls/order-logs-viewer.jsx b/lib/adminpanel/src/controls/order-logs-viewer.jsx new file mode 100644 index 00000000..34632c11 --- /dev/null +++ b/lib/adminpanel/src/controls/order-logs-viewer.jsx @@ -0,0 +1,147 @@ +import React, { useMemo, useState } from 'react'; + +const LEVELS = ['debug', 'info', 'warn', 'error']; +const LEVEL_COLOR = { + debug: '#60a5fa', + info: '#34d399', + warn: '#fbbf24', + error: '#f87171', +}; + +function safeStringify(value) { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function normalizeLogs(initialValue) { + if (!Array.isArray(initialValue)) return []; + + return initialValue.map((item, index) => { + if (!item || typeof item !== 'object') { + return { + index, + timestamp: 'unknown-time', + level: 'info', + module: 'unknown-module', + message: String(item), + data: undefined, + }; + } + + const level = LEVELS.includes(item.level) ? item.level : 'info'; + return { + index, + timestamp: item.timestamp || 'unknown-time', + level, + module: item.module || 'unknown-module', + message: item.message || '', + data: item.data, + }; + }); +} + +function OrderLogsViewer({ initialValue }) { + const logs = useMemo(() => normalizeLogs(initialValue), [initialValue]); + const [query, setQuery] = useState(''); + const [activeLevels, setActiveLevels] = useState(() => new Set(LEVELS)); + + const counters = useMemo(() => { + return logs.reduce((acc, log) => { + acc[log.level] = (acc[log.level] || 0) + 1; + return acc; + }, { debug: 0, info: 0, warn: 0, error: 0 }); + }, [logs]); + + const filtered = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + + return logs.filter((log) => { + if (!activeLevels.has(log.level)) return false; + if (!normalizedQuery) return true; + + const searchText = `${log.timestamp} ${log.level} ${log.module} ${log.message} ${safeStringify(log.data)}`.toLowerCase(); + return searchText.includes(normalizedQuery); + }); + }, [logs, activeLevels, query]); + + const toggleLevel = (level) => { + setActiveLevels((prev) => { + const next = new Set(prev); + if (next.has(level)) { + next.delete(level); + } else { + next.add(level); + } + return next; + }); + }; + + return ( +
+
+ {LEVELS.map((level) => { + const active = activeLevels.has(level); + return ( + + ); + })} + setQuery(e.target.value)} + placeholder="Поиск по логам" + style={{ + marginLeft: 'auto', + minWidth: 220, + background: '#020617', + border: '1px solid #334155', + color: '#e2e8f0', + borderRadius: 6, + padding: '4px 8px', + }} + /> +
+ +
+ {filtered.length === 0 && ( +
Логи не найдены
+ )} + + {filtered.map((log) => ( +
+
+ [{log.timestamp}] + {log.level.toUpperCase()} + {log.module} + {log.message ? — {log.message} : null} +
+ {log.data !== undefined && ( +
{safeStringify(log.data)}
+ )} +
+ ))} +
+
+ ); +} + +export default OrderLogsViewer; diff --git a/lib/adminpanel/vite.config.js b/lib/adminpanel/vite.config.js index 79fe7606..7bb5215c 100644 --- a/lib/adminpanel/vite.config.js +++ b/lib/adminpanel/vite.config.js @@ -12,7 +12,8 @@ export default defineConfig({ cssCodeSplit: true, lib: { entry: { - StockManager: path.resolve(__dirname, 'src/stock-manager.jsx') + StockManager: path.resolve(__dirname, 'src/stock-manager.jsx'), + OrderLogsViewer: path.resolve(__dirname, 'src/controls/order-logs-viewer.jsx') }, formats: ['es'], }, diff --git a/libs/adminpanel/controls/OrderLogsViewerControl.ts b/libs/adminpanel/controls/OrderLogsViewerControl.ts new file mode 100644 index 00000000..813d00bf --- /dev/null +++ b/libs/adminpanel/controls/OrderLogsViewerControl.ts @@ -0,0 +1,37 @@ +import { AbstractControls, Config, ControlType, Path } from "adminizer"; + +export class OrderLogsViewerControl extends AbstractControls { + readonly name: string = "order-logs-viewer"; + readonly type: ControlType = "jsonEditor"; + + readonly path: Path = { + cssPath: "", + jsPath: { + dev: `${this.routPrefix}/assets/stockmanager/OrderLogsViewer.js`, + production: `${this.routPrefix}/assets/stockmanager/OrderLogsViewer.js`, + }, + }; + + readonly config: Config = { + theme: "dark", + readOnly: true, + }; + + getConfig(): Config { + return this.config; + } + + getJsPath(): string { + return process.env.VITE_ENV === "dev" + ? this.path.jsPath.dev + : this.path.jsPath.production; + } + + getCssPath(): string | undefined { + return undefined; + } + + getName(): string { + return this.name; + } +} diff --git a/libs/adminpanel/controls/orderLogsViewerHelper.ts b/libs/adminpanel/controls/orderLogsViewerHelper.ts new file mode 100644 index 00000000..5f6e3e34 --- /dev/null +++ b/libs/adminpanel/controls/orderLogsViewerHelper.ts @@ -0,0 +1,101 @@ +import { OrderLogEntry, OrderLogLevel } from "../../OrderLogHelper"; + +export type ViewerLogLevel = OrderLogLevel; + +export interface ViewerLogRecord { + index: number; + timestamp: string; + level: ViewerLogLevel; + module: string; + message: string; + data?: unknown; + raw: unknown; +} + +const LOG_LEVELS: ViewerLogLevel[] = ["debug", "info", "warn", "error"]; + +export function normalizeOrderLogs(input: unknown): ViewerLogRecord[] { + if (!Array.isArray(input)) { + return []; + } + + return input.map((item: unknown, index: number): ViewerLogRecord => { + const fallback: ViewerLogRecord = { + index, + timestamp: "unknown-time", + level: "info", + module: "unknown-module", + message: String(item), + raw: item, + }; + + if (typeof item !== "object" || item === null) { + return fallback; + } + + const candidate = item as Partial & { [k: string]: unknown }; + const level = typeof candidate.level === "string" && LOG_LEVELS.includes(candidate.level as ViewerLogLevel) + ? candidate.level as ViewerLogLevel + : "info"; + + return { + index, + timestamp: typeof candidate.timestamp === "string" ? candidate.timestamp : "unknown-time", + level, + module: typeof candidate.module === "string" ? candidate.module : "unknown-module", + message: typeof candidate.message === "string" ? candidate.message : "", + data: candidate.data, + raw: item, + }; + }); +} + +export function filterOrderLogs(logs: ViewerLogRecord[], levels: Set, search: string): ViewerLogRecord[] { + const query = search.trim().toLowerCase(); + + return logs.filter((item: ViewerLogRecord) => { + if (!levels.has(item.level)) { + return false; + } + + if (!query) { + return true; + } + + const serialized = `${item.timestamp} ${item.level} ${item.module} ${item.message} ${safeStringify(item.data)}`.toLowerCase(); + return serialized.includes(query); + }); +} + +export function safeStringify(data: unknown): string { + if (data === undefined) { + return ""; + } + + if (typeof data === "string") { + return data; + } + + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } +} + +export function summarizeOrderLogs(logs: unknown): string { + const normalized = normalizeOrderLogs(logs); + + if (normalized.length === 0) { + return "Нет логов"; + } + + const counts = normalized.reduce((acc: Record, item: ViewerLogRecord) => { + acc[item.level] = (acc[item.level] || 0) + 1; + return acc; + }, {}); + + return ["debug", "info", "warn", "error"] + .map((level: string) => `${level}:${counts[level] || 0}`) + .join(" "); +} diff --git a/libs/adminpanel/models/bind.ts b/libs/adminpanel/models/bind.ts index 4d3049c3..07795101 100644 --- a/libs/adminpanel/models/bind.ts +++ b/libs/adminpanel/models/bind.ts @@ -1,5 +1,6 @@ -import { GroupConfig } from "./lib/group"; -import { ProductConfig } from "./lib/product"; +import { GroupConfig } from "./lib/group"; +import { ProductConfig } from "./lib/product"; +import { OrderConfig } from "./lib/order"; export const models = { user: { @@ -23,11 +24,13 @@ export const models = { edit: GroupConfig.edit(), add: GroupConfig.add(), }, - order: { - model: 'order', - title: 'Orders', - icon: 'shopping_cart' - }, + order: { + model: 'order', + title: 'Orders', + icon: 'shopping_cart', + list: OrderConfig.list(), + edit: OrderConfig.edit(), + }, bonusprogram: { model: 'bonusprogram', title: 'Bonus programs', diff --git a/libs/adminpanel/models/lib/order.ts b/libs/adminpanel/models/lib/order.ts new file mode 100644 index 00000000..d56a8895 --- /dev/null +++ b/libs/adminpanel/models/lib/order.ts @@ -0,0 +1,37 @@ +import { CreateUpdateConfig, FieldsModels } from "adminizer"; +import { summarizeOrderLogs } from "../../controls/orderLogsViewerHelper"; + +export class OrderConfig { + static listFields: FieldsModels = { + logs: { + title: "Logs", + displayModifier(value: unknown) { + return summarizeOrderLogs(value); + }, + }, + }; + + static editFields: FieldsModels = { + logs: { + title: "Order logs", + type: "json", + disabled: true, + tooltip: "Просмотр логов заказа: чёрный консольный вьювер с фильтрами по уровням", + options: { + name: "order-logs-viewer", + }, + }, + }; + + public static list(): { fields: FieldsModels } { + return { + fields: this.listFields, + }; + } + + public static edit(): CreateUpdateConfig { + return { + fields: this.editFields, + }; + } +} diff --git a/test/integration/adminpanel_order_logs_format.test.ts b/test/integration/adminpanel_order_logs_format.test.ts new file mode 100644 index 00000000..2644eeee --- /dev/null +++ b/test/integration/adminpanel_order_logs_format.test.ts @@ -0,0 +1,56 @@ +import { expect } from "chai"; +import { + filterOrderLogs, + normalizeOrderLogs, + summarizeOrderLogs, +} from "../../libs/adminpanel/controls/orderLogsViewerHelper"; + +describe("adminpanel Order logs viewer helper", function () { + it("normalizes logs and keeps expected fields", function () { + const logs = normalizeOrderLogs([ + { + timestamp: "2026-01-01T10:00:00.000Z", + level: "info", + module: "core", + message: "Order created", + }, + { + timestamp: "2026-01-01T10:02:00.000Z", + level: "error", + module: "payment", + message: "Gateway timeout", + }, + ]); + + expect(logs).to.have.length(2); + expect(logs[0].level).to.equal("info"); + expect(logs[1].level).to.equal("error"); + }); + + it("filters logs by levels and search", function () { + const normalized = normalizeOrderLogs([ + { timestamp: "2026-01-01", level: "debug", module: "core", message: "step-1" }, + { timestamp: "2026-01-01", level: "error", module: "payment", message: "timeout" }, + ]); + + const filteredByLevel = filterOrderLogs(normalized, new Set(["error"]), ""); + expect(filteredByLevel).to.have.length(1); + expect(filteredByLevel[0].level).to.equal("error"); + + const filteredBySearch = filterOrderLogs(normalized, new Set(["debug", "error", "info", "warn"]), "timeout"); + expect(filteredBySearch).to.have.length(1); + expect(filteredBySearch[0].message).to.equal("timeout"); + }); + + it("returns level summary for list view", function () { + const summary = summarizeOrderLogs([ + { timestamp: "1", level: "debug", module: "core", message: "d" }, + { timestamp: "2", level: "info", module: "core", message: "i" }, + { timestamp: "3", level: "warn", module: "core", message: "w" }, + { timestamp: "4", level: "error", module: "core", message: "e" }, + ]); + + expect(summary).to.equal("debug:1 info:1 warn:1 error:1"); + expect(summarizeOrderLogs(undefined)).to.equal("Нет логов"); + }); +});