From 32de8f9d7391a8e00f7f0c1aa78e8bbf9e6c32c9 Mon Sep 17 00:00:00 2001 From: gsd Date: Thu, 12 Feb 2026 20:26:17 +0300 Subject: [PATCH] packet stats --- dbService.py | 3 - ui/package-lock.json | 19 ++ ui/package.json | 3 +- ui/src/app/app-routing.module.ts | 6 +- ui/src/app/app.component.ts | 3 +- ui/src/app/app.module.ts | 11 +- .../messages/MessageHistory.component.ts | 19 +- .../components/nodes/DirectNodes.component.ts | 75 -------- .../components/nodes/nodes-list.component.ts | 127 ++++++++++++++ ui/src/app/components/nodes/nodes.styles.scss | 5 - .../packet/NetworkStatus.component.ts | 164 ++++++++++++++++++ ui/src/app/entities/PacketGroup.ts | 17 ++ ui/src/app/utils/PortNums.ts | 62 +++---- ui/src/app/utils/Utils.ts | 20 +++ ui/src/styles.scss | 24 +++ 15 files changed, 419 insertions(+), 139 deletions(-) delete mode 100644 ui/src/app/components/nodes/DirectNodes.component.ts create mode 100644 ui/src/app/components/nodes/nodes-list.component.ts create mode 100644 ui/src/app/components/packet/NetworkStatus.component.ts create mode 100644 ui/src/app/entities/PacketGroup.ts create mode 100644 ui/src/app/utils/Utils.ts diff --git a/dbService.py b/dbService.py index bb21fc9..ad21764 100644 --- a/dbService.py +++ b/dbService.py @@ -137,15 +137,12 @@ class PacketDbService: if packetsSumNode: groupPipe["$group"]["_id"] = "$from" - print(packetsPerNode, packetsSumNode) pipeline.append(groupPipe) - print(pipeline) ###print(pipeline) collection = self.dbStore['packet'] c = await collection.aggregate(pipeline) l = await c.to_list() - print(l[0]) return [PacketGroup(p, packetsPerNode, packetsSumNode) for p in l] class DbService(NodeDbService, PacketDbService): diff --git a/ui/package-lock.json b/ui/package-lock.json index 16f3b3b..b58db08 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -18,6 +18,7 @@ "@angular/platform-browser": "^14.2.0", "@angular/platform-browser-dynamic": "^14.2.0", "@angular/router": "^14.2.0", + "chart.js": "^4.5.1", "rxjs": "~7.5.0", "tslib": "^2.3.0", "zone.js": "~0.11.4" @@ -2971,6 +2972,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -4501,6 +4508,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", diff --git a/ui/package.json b/ui/package.json index 059a08b..d26780d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -20,6 +20,7 @@ "@angular/platform-browser": "^14.2.0", "@angular/platform-browser-dynamic": "^14.2.0", "@angular/router": "^14.2.0", + "chart.js": "^4.5.1", "rxjs": "~7.5.0", "tslib": "^2.3.0", "zone.js": "~0.11.4" @@ -37,4 +38,4 @@ "karma-jasmine-html-reporter": "~2.0.0", "typescript": "~4.7.2" } -} \ No newline at end of file +} diff --git a/ui/src/app/app-routing.module.ts b/ui/src/app/app-routing.module.ts index 2262358..1443882 100644 --- a/ui/src/app/app-routing.module.ts +++ b/ui/src/app/app-routing.module.ts @@ -1,12 +1,14 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import {DirectNodesComponent} from "./components/nodes/DirectNodes.component"; +import {NodesListComponent} from "./components/nodes/nodes-list.component"; import {BotCommandsComponent} from "./components/botCommands/BotCommands.component"; import {MessageHistoryComponent} from "./components/messages/MessageHistory.component"; +import {NetworkStatusComponent} from "./components/packet/NetworkStatus.component"; const routes: Routes = [ - {path: "nodes/direct", component: DirectNodesComponent}, + {path: "nodes/:type", component: NodesListComponent}, {path: "messages", component: MessageHistoryComponent}, + {path: "network/status", component: NetworkStatusComponent}, {path: "", component: BotCommandsComponent} ]; diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 3674400..27b8a4c 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -22,9 +22,10 @@ export class AppComponent implements OnInit { userNode!: NodeDTO routes: {name: string, url: string}[] = [ - //{name: "Команды бота", url:""}, + {name: "Статистика сети", url:"network/status"}, {name: "История сообщений", url:"messages"}, {name: "Прямые ноды", url:"nodes/direct"}, + {name: "Все ноды", url:"nodes/list"}, //{name: "Все ноды", url:""}, ] diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index bbd2447..f579ade 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -11,7 +11,7 @@ import {MatButtonModule} from "@angular/material/button"; import {HttpClientModule, HTTP_INTERCEPTORS} from "@angular/common/http"; import {BotCommandsComponent} from "./components/botCommands/BotCommands.component"; import {MessageHistoryComponent} from "./components/messages/MessageHistory.component"; -import {DirectNodesComponent, NodeDtoSortPipe} from "./components/nodes/DirectNodes.component"; +import {NodesListComponent, NodeDtoSearchPipe, NodeDtoSortPipe} from "./components/nodes/nodes-list.component"; import {AllNodesComponent} from "./components/nodes/AllNodes.component"; import {AuthDialog, AuthInterceptor} from "./auth/AuthInterceptor"; import {MatDialogModule} from "@angular/material/dialog"; @@ -23,15 +23,17 @@ import {MatSnackBarModule} from "@angular/material/snack-bar"; import {MatDividerModule} from "@angular/material/divider"; import {MatCardModule} from "@angular/material/card"; import {MatSelectModule} from "@angular/material/select"; +import {NetworkStatusComponent} from "./components/packet/NetworkStatus.component"; @NgModule({ declarations: [ AppComponent, BotCommandsComponent, MessageHistoryComponent, - DirectNodesComponent, + NodesListComponent, AllNodesComponent, - AuthDialog + AuthDialog, + NetworkStatusComponent ], imports: [ BrowserModule, @@ -52,7 +54,8 @@ import {MatSelectModule} from "@angular/material/select"; MatDividerModule, MatCardModule, NodeDtoSortPipe, - MatSelectModule + MatSelectModule, + NodeDtoSearchPipe ], providers: [{ provide: HTTP_INTERCEPTORS, diff --git a/ui/src/app/components/messages/MessageHistory.component.ts b/ui/src/app/components/messages/MessageHistory.component.ts index 299825d..9f9e99c 100644 --- a/ui/src/app/components/messages/MessageHistory.component.ts +++ b/ui/src/app/components/messages/MessageHistory.component.ts @@ -4,6 +4,7 @@ import {MessageDTO} from "../../entities/MessageDTO"; import {NodeDTO} from "../../entities/NodeDTO"; import {KeyValueMap} from "../../entities/KeyValueMap"; import {Subscription} from "rxjs"; +import {numToColor} from "../../utils/Utils"; @Component({ selector: "app-message-history", @@ -21,6 +22,7 @@ import {Subscription} from "rxjs"; }) export class MessageHistoryComponent implements OnInit, OnDestroy { constructor(private http: HttpClient) {} + numToColor = numToColor; loading: boolean = false; canLoadMoreMessage: boolean = true; @@ -101,21 +103,4 @@ export class MessageHistoryComponent implements OnInit, OnDestroy { ) }) } - - numToColor(num:number) { - // Приводим к беззнаковому 32-битному и перемешиваем биты - let n = num >>> 0; - // Мультипликативная хеш-функция с простыми числами - n = (n * 2654435761) >>> 0; // константа из Knuth - n ^= (n >> 16); - n = (n * 0x85EBCA6B) >>> 0; - n ^= (n >> 13); - n = (n * 0xC2B2AE35) >>> 0; - n ^= (n >> 16); - - const r = (n >> 16) & 0xFF; - const g = (n >> 8) & 0xFF; - const b = n & 0xFF; - return `rgba(${r}, ${g}, ${b}, 0.2)` - } } diff --git a/ui/src/app/components/nodes/DirectNodes.component.ts b/ui/src/app/components/nodes/DirectNodes.component.ts deleted file mode 100644 index 1efc2ab..0000000 --- a/ui/src/app/components/nodes/DirectNodes.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -import {Component, OnInit} from "@angular/core"; -import {HttpClient} from "@angular/common/http"; -import {NodeDTO} from "../../entities/NodeDTO"; - -//todo abs this -@Component({ - selector: 'app-direct-nodes', - styleUrls: ['nodes.styles.scss'], - template: ` - -
-
- - Сортировать по ... - - - {{s.name}} - - - -
-
- - - {{node.long_name}} - {{node.short_name}} ({{node.num}}) - - - - - - - - -
-
- ` -}) -export class DirectNodesComponent implements OnInit { - constructor(private http: HttpClient) { - } - - nodes: NodeDTO[] = []; - sortVars:{name: string, type: string}[] = [ - {name: "Последнему пингу", type: "ts"}, - {name: "SNR", type: "snr"}, - //{name: "Имени", type: "long_name"} - ] - sort: {name: string, type: string} = this.sortVars[0] - - ngOnInit(): void { - this.http.get(`api/nodes/direct`).subscribe( - (res) => this.nodes = res as NodeDTO[] - ) - } -} - -import { Pipe, PipeTransform } from '@angular/core'; -@Pipe({ - name: 'NodeDtoSort', - standalone: true -}) -export class NodeDtoSortPipe implements PipeTransform { - - transform(values: NodeDTO[], field: string = ""): NodeDTO[] { - if (field == "") return values; - return values.sort((n1,n2) => - { - // @ts-ignore - return n2[field] - n1[field] - }); - } -} diff --git a/ui/src/app/components/nodes/nodes-list.component.ts b/ui/src/app/components/nodes/nodes-list.component.ts new file mode 100644 index 0000000..dd6ce02 --- /dev/null +++ b/ui/src/app/components/nodes/nodes-list.component.ts @@ -0,0 +1,127 @@ +import {Component, OnInit} from "@angular/core"; +import {HttpClient} from "@angular/common/http"; +import {NodeDTO} from "../../entities/NodeDTO"; +import {numToColor} from "../../utils/Utils"; + +//todo abs this +@Component({ + selector: 'app-direct-nodes', + styleUrls: ['nodes.styles.scss'], + template: ` + +
+
+ + Сортировать по ... + + + {{s.name}} + + + + + Искать по ... + + + {{s.name}} + + + + + Искать по ... + + +
+
+ + + {{node.long_name}} + {{node.short_name}} ({{node.num}}) + + + + + + + + +
+
+ ` +}) +export class NodesListComponent implements OnInit { + numToColor = numToColor + constructor(private http: HttpClient, + private route: ActivatedRoute) { + } + + nodes: NodeDTO[] = []; + sortVars:{name: string, type: string}[] = [ + {name: "Последнему пингу", type: "ts"}, + {name: "SNR", type: "snr"}, + //{name: "Имени", type: "long_name"} + ] + sort: {name: string, type: string} = this.sortVars[0] + + searchVars: {name: string, type: string}[] = [ + {name: "Длинному имени", type: "long_name"}, + {name: "Короткому имени", type: "short_name"}, + {name: "Хопов больше чем", type: "hops_away"} + ] + search: {name: string, type: string} = this.searchVars[0] + searchContent: string = ""; + + ngOnInit(): void { + this.route.params.subscribe( + (params) => { + this.http.get(`api/nodes/${params['type']}`).subscribe( + (res) => this.nodes = res as NodeDTO[] + ) + } + ) + } +} + +import { Pipe, PipeTransform } from '@angular/core'; +import {ActivatedRoute} from "@angular/router"; +@Pipe({ + name: 'NodeDtoSort', + standalone: true +}) +export class NodeDtoSortPipe implements PipeTransform { + + transform(values: NodeDTO[], field: string = ""): NodeDTO[] { + if (field == "") return values; + return values.sort((n1,n2) => + { + // @ts-ignore + return n2[field] - n1[field] + }); + } +} + +@Pipe({ + name: 'NodeDtoSearch', + standalone: true +}) +export class NodeDtoSearchPipe implements PipeTransform { + + transform(values: NodeDTO[], field: string = "", search: any = ""): NodeDTO[] { + if (field == "" || search == "") return values; + return values.filter((node) => { + switch (field) { + case "long_name": + return node.long_name && node.long_name.toLowerCase().indexOf(search.toLowerCase()) != -1; + case "short_name": + return node.short_name && node.short_name.toLowerCase().indexOf(search.toLowerCase()) != -1; + case "hops_away": + return node.hops_away != null && node.hops_away > Number.parseInt(search); + default: return true; + } + }) + } +} diff --git a/ui/src/app/components/nodes/nodes.styles.scss b/ui/src/app/components/nodes/nodes.styles.scss index 4b5a21a..e69de29 100644 --- a/ui/src/app/components/nodes/nodes.styles.scss +++ b/ui/src/app/components/nodes/nodes.styles.scss @@ -1,5 +0,0 @@ -.card-wrapper { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 24px; -} diff --git a/ui/src/app/components/packet/NetworkStatus.component.ts b/ui/src/app/components/packet/NetworkStatus.component.ts new file mode 100644 index 0000000..63e6ff0 --- /dev/null +++ b/ui/src/app/components/packet/NetworkStatus.component.ts @@ -0,0 +1,164 @@ +import {Component, OnInit} from "@angular/core"; +import {HttpClient} from "@angular/common/http"; +import {PacketGroup} from "../../entities/PacketGroup"; +import {Chart} from "chart.js/auto"; +import {numToColor} from "../../utils/Utils"; +import {KeyValueMap} from "../../entities/KeyValueMap"; +import {NodeDTO} from "../../entities/NodeDTO"; + +@Component({ + selector: "app-network-status", + template: ` +
+
+ + + {{p1.chart}} + + +
+
+ ` +}) +export class NetworkStatusComponent implements OnInit { + DAY = 86400; + WEEK = this.DAY * 7; + MONTH = this.DAY * 30; + + nodesNames: KeyValueMap = {} + + //общая статичтика по perPortNum за день\неделю\месяц / ?= + //top кто насрал пакетами всего за день\неделю\месяц / ?=packetsSumNode=true + graphs:any[] = [ + { + header: "Cтатистика пакетов в сети", + type: 'perPortNum', + cards:[{ + chart: Chart, + canvasId: "DayPerPB", + params: "?=", + before: new Date().getTime()/1000, + after: (new Date().getTime()/1000) - this.DAY, + config: this.generateConfigTChart("Cтатистика по пакетам в сети за день") + },{ + chart: Chart, + canvasId: "WeekPerPB", + params: "?=", + before: new Date().getTime()/1000, + after: (new Date().getTime()/1000) - this.WEEK, + config: this.generateConfigTChart("Cтатистика по пакетам в сети за неделю") + },{ + chart: Chart, + canvasId: "MonthPerPB", + params: "?=", + before: new Date().getTime()/1000, + after: (new Date().getTime()/1000) - this.MONTH, + config: this.generateConfigTChart("Cтатистика по пакетам в сети за месяц") + }] + }, + { + header: "Общая статистика пакетов в сети", + type: 'perSumNode', + cards:[{ + chart: Chart, + canvasId: "DaySumPB", + params: "?packetsSumNode=true", + before: new Date().getTime()/1000, + after: (new Date().getTime()/1000) - this.DAY, + config: this.generateConfigTChart("Количество пакетов от пользоватей за день", false) + },{ + chart: Chart, + canvasId: "WeekSumPB", + params: "?packetsSumNode=true", + before: new Date().getTime()/1000, + after: (new Date().getTime()/1000) - this.WEEK, + config: this.generateConfigTChart("Количество пакетов от пользоватей за неделю", false) + },{ + chart: Chart, + canvasId: "MonthSumPB", + params: "?packetsSumNode=true", + before: new Date().getTime()/1000, + after: (new Date().getTime()/1000) - this.MONTH, + config: this.generateConfigTChart("Количество пакетов от пользоватей за месяц", false) + }] + } + ]; + constructor(private http: HttpClient) {} + + generateConfigTChart(name: string, legend: boolean = true) { + return { + type: 'pie', + data: {}, + options: { + responsive: true, + plugins: { + legend: { + position: 'bottom', + display: legend + }, + title: { + display: true, + text: name + } + } + } + } + } + + ngOnInit(): void { + this.http.get(`api/nodes/list`).subscribe( + (obj) => { + (obj as NodeDTO[]).forEach( + (node) => { + this.nodesNames[`${node.num}`] = node; + } + ) + } + ).add( + () => { + this.graphs.forEach( + (graph) => { + graph.cards.forEach((settings:any) => { + this.http.get(`api/packet/stats${settings.params}&before=${settings.before}&after=${settings.after}`) + .subscribe((data) => { + settings.config.data = { + labels: [], + datasets: [ + { + label: settings.canvasId, + data: [], + backgroundColor: [] + } + ] + }; + (data as Object[]).map((obj) => PacketGroup.fromDto(obj)).sort((p1, p2) => p2.count - p1.count).forEach( + (d) => { + switch (graph.type) { + case "perPortNum": { + settings.config.data.labels.push(`${d.portnumName} - ${d.count}`) + settings.config.data.datasets[0].data.push(d.count) + settings.config.data.datasets[0].backgroundColor.push(numToColor(d.portnum, 0)) + break; + } + case "perSumNode": { + const name = `${d.from}` in this.nodesNames ? this.nodesNames[`${d.from}`].long_name : `${d.from}` + settings.config.data.labels.push(`${name} - ${d.count}`) + settings.config.data.datasets[0].data.push(d.count) + settings.config.data.datasets[0].backgroundColor.push(numToColor(d.from, 0)) + break; + } + } + } + ) + //if (settings.chart) + // settings.chart.destroy() + + settings.chart = new Chart(settings.canvasId, settings.config) + }) + }) + } + ) + } + ) + } +} diff --git a/ui/src/app/entities/PacketGroup.ts b/ui/src/app/entities/PacketGroup.ts new file mode 100644 index 0000000..e8ac716 --- /dev/null +++ b/ui/src/app/entities/PacketGroup.ts @@ -0,0 +1,17 @@ +import {PortNums} from "../utils/PortNums"; + +export class PacketGroup { + count!: number; + portnum!: number; + portnumName!:string; + from!: number; + type!: string; + + static fromDto(data:any):PacketGroup { + const p = new PacketGroup(); + Object.assign(p, data); + // @ts-ignore + p.portnumName = p.portnum in PortNums ? PortNums[p.portnum] : "UNKNOWN OR MISSED" + return p; + } +} diff --git a/ui/src/app/utils/PortNums.ts b/ui/src/app/utils/PortNums.ts index 462d3c6..d576e64 100644 --- a/ui/src/app/utils/PortNums.ts +++ b/ui/src/app/utils/PortNums.ts @@ -1,32 +1,32 @@ -export class PortNums { - 0: "UNKNOWN_APP"; - 1: "TEXT_MESSAGE_APP"; - 2: "REMOTE_HARDWARE_APP"; - 3: "POSITION_APP"; - 4: "NODEINFO_APP"; - 5: "ROUTING_APP"; - 6: "ADMIN_APP"; - 7: "TEXT_MESSAGE_COMPRESSED_APP"; - 8: "WAYPOINT_APP"; - 9: "AUDIO_APP"; - 10: "DETECTION_SENSOR_APP"; - 11: "ALERT_APP"; - 32: "REPLY_APP"; - 33: "IP_TUNNEL_APP"; - 34: "PAXCOUNTER_APP"; - 64: "SERIAL_APP"; - 65: "STORE_FORWARD_APP"; - 66: "RANGE_TEST_APP"; - 67: "TELEMETRY_APP"; - 68: "ZPS_APP"; - 69: "SIMULATOR_APP"; - 70: "TRACEROUTE_APP"; - 71: "NEIGHBORINFO_APP"; - 72: "ATAK_PLUGIN"; - 73: "MAP_REPORT_APP"; - 74: "POWERSTRESS_APP"; - 76: "RETICULUM_TUNNEL_APP"; - 256: "PRIVATE_APP"; - 257: "ATAK_FORWARDER"; - 511: "MAX"; +export let PortNums = { + 0: "UNKNOWN_APP", + 1: "TEXT_MESSAGE_APP", + 2: "REMOTE_HARDWARE_APP", + 3: "POSITION_APP", + 4: "NODEINFO_APP", + 5: "ROUTING_APP", + 6: "ADMIN_APP", + 7: "TEXT_MESSAGE_COMPRESSED_APP", + 8: "WAYPOINT_APP", + 9: "AUDIO_APP", + 10: "DETECTION_SENSOR_APP", + 11: "ALERT_APP", + 32: "REPLY_APP", + 33: "IP_TUNNEL_APP", + 34: "PAXCOUNTER_APP", + 64: "SERIAL_APP", + 65: "STORE_FORWARD_APP", + 66: "RANGE_TEST_APP", + 67: "TELEMETRY_APP", + 68: "ZPS_APP", + 69: "SIMULATOR_APP", + 70: "TRACEROUTE_APP", + 71: "NEIGHBORINFO_APP", + 72: "ATAK_PLUGIN", + 73: "MAP_REPORT_APP", + 74: "POWERSTRESS_APP", + 76: "RETICULUM_TUNNEL_APP", + 256: "PRIVATE_APP", + 257: "ATAK_FORWARDER", + 511: "MAX", } diff --git a/ui/src/app/utils/Utils.ts b/ui/src/app/utils/Utils.ts new file mode 100644 index 0000000..35f58d5 --- /dev/null +++ b/ui/src/app/utils/Utils.ts @@ -0,0 +1,20 @@ +export function numToColor(num:number, alpha = 0.2) { + // Приводим к беззнаковому 32-битному и перемешиваем биты + let n = num >>> 0; + // Мультипликативная хеш-функция с простыми числами + n = (n * 2654435761) >>> 0; // константа из Knuth + n ^= (n >> 16); + n = (n * 0x85EBCA6B) >>> 0; + n ^= (n >> 13); + n = (n * 0xC2B2AE35) >>> 0; + n ^= (n >> 16); + + const r = (n >> 16) & 0xFF; + const g = (n >> 8) & 0xFF; + const b = n & 0xFF; + + if (alpha == 0) + return `rgb(${r}, ${g}, ${b})` + else + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} diff --git a/ui/src/styles.scss b/ui/src/styles.scss index 7e7239a..b1375a9 100644 --- a/ui/src/styles.scss +++ b/ui/src/styles.scss @@ -2,3 +2,27 @@ html, body { height: 100%; } body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } + +.card-wrapper { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 24px; +} + +.card-wrapper-250 { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 24px; +} + +.card-wrapper-350 { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 24px; +} + +.card-wrapper-450 { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); + gap: 24px; +}