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("Нет логов");
+ });
+});