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: `
+
+
+
+
+
+
+
+
+
+ `
+})
+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;
+}