nodeDataAnalysis.blade.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. @extends('admin.layouts')
  2. @section('css')
  3. <link href="/assets/global/vendor/bootstrap-select/bootstrap-select.min.css" rel="stylesheet">
  4. <link href="/assets/global/vendor/bootstrap-datepicker/bootstrap-datepicker.min.css" rel="stylesheet">
  5. @endsection
  6. @section('content')
  7. <div class="page-content container-fluid w-xl-p75 w-xxl-p100">
  8. <div class="card card-shadow">
  9. <div class="card-block p-30">
  10. <form class="form-row">
  11. <x-admin.filter.selectpicker class="col-xxl-2 col-md-3 col-sm-4" name="nodes" :title="trans('admin.logs.user_traffic.choose_node')" :multiple="true" :options="$nodes" />
  12. <div class="form-group col-xxl-1 col-md-3 col-4 btn-group">
  13. <button class="btn btn-primary" type="submit">{{ trans('common.search') }}</button>
  14. <button class="btn btn-danger" type="button" onclick="resetSearchForm()">{{ trans('common.reset') }}</button>
  15. </div>
  16. </form>
  17. </div>
  18. </div>
  19. @isset($data)
  20. <div class="row mx-0">
  21. <div class="col-md-12 col-xxl-7 card card-shadow">
  22. <div class="card-block p-md-30">
  23. <div class="row pb-20">
  24. <div class="col-md-4 col-sm-6">
  25. <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.hourly_traffic') }}</div>
  26. </div>
  27. <div class="col-md-8 col-sm-6">
  28. <form class="form-row float-right">
  29. <div class="form-group">
  30. <select class="form-control show-tick" id="hour_date" name="hour_date" data-plugin="selectpicker"
  31. data-style="btn-outline btn-primary" title="{{ trans('admin.report.select_hourly_date') }}">
  32. @foreach ($hour_dates as $date)
  33. <option value="{{ $date }}">{{ $date }}</option>
  34. @endforeach
  35. </select>
  36. </div>
  37. </form>
  38. </div>
  39. </div>
  40. <canvas id="hourlyBar"></canvas>
  41. </div>
  42. </div>
  43. <div class="col-md-12 col-xxl-5 card card-shadow">
  44. <div class="card-block p-md-30">
  45. <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.daily_distribution') }}</div>
  46. <div class="d-flex justify-content-around">
  47. <canvas id="dailyPie"></canvas>
  48. </div>
  49. </div>
  50. </div>
  51. <div class="col-12 offset-xxl-1 col-xxl-10 card card-shadow">
  52. <div class="card-block p-md-30">
  53. <div class="row pb-20">
  54. <div class="col-md-4 col-sm-6">
  55. <div class="blue-grey-700 font-size-26 font-weight-500">{{ trans('admin.report.daily_traffic') }}</div>
  56. </div>
  57. <div class="col-md-8 col-sm-6">
  58. <form class="form-row float-right" onsubmit="handleFormSubmit(event, this);">
  59. <div class="form-group">
  60. <div class="input-group input-daterange" data-plugin="datepicker">
  61. <div class="input-group-prepend">
  62. <span class="input-group-text"><i class="icon wb-calendar" aria-hidden="true"></i></span>
  63. </div>
  64. <input class="form-control" name="start" type="text"
  65. value="{{ Request::query('start', now()->startOfMonth()->toDateString()) }}" autocomplete="off" />
  66. <div class="input-group-prepend">
  67. <span class="input-group-text">{{ trans('common.to') }}</span>
  68. </div>
  69. <input class="form-control" name="end" type="text" value="{{ Request::query('end', now()->toDateString()) }}"
  70. autocomplete="off" />
  71. <div class="input-group-addon">
  72. <button class="btn btn-primary" type="submit">{{ trans('common.search') }}</button>
  73. </div>
  74. </div>
  75. </div>
  76. </form>
  77. </div>
  78. </div>
  79. <canvas id="dailyBar"></canvas>
  80. </div>
  81. </div>
  82. </div>
  83. @endisset
  84. </div>
  85. @endsection
  86. @section('javascript')
  87. <script src="/assets/global/vendor/chart-js/chart.min.js"></script>
  88. <script src="/assets/global/vendor/chart-js/chartjs-plugin-datalabels.min.js"></script>
  89. <script src="/assets/global/vendor/bootstrap-select/bootstrap-select.min.js"></script>
  90. <script src="/assets/global/js/Plugin/bootstrap-select.js"></script>
  91. <script src="/assets/global/vendor/bootstrap-datepicker/bootstrap-datepicker.min.js"></script>
  92. @if (app()->getLocale() !== 'en')
  93. <script src="/assets/global/vendor/bootstrap-datepicker/locales/bootstrap-datepicker.{{ str_replace('_', '-', app()->getLocale()) }}.min.js" charset="UTF-8">
  94. </script>
  95. @endif
  96. <script src="/assets/global/js/Plugin/bootstrap-datepicker.js"></script>
  97. <script type="text/javascript">
  98. const nodeData = @json($data);
  99. const getRandomColor = (name) => {
  100. const hash = name.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0);
  101. const hue = (hash % 360 + Math.random() * 50) % 360;
  102. const saturation = 50 + (hash % 30);
  103. const lightness = 40 + (hash % 20);
  104. return `hsla(${hue}, ${saturation}%, ${lightness}%, 0.55)`;
  105. };
  106. const generateNodeColorMap = (nodeNames) =>
  107. Object.fromEntries(Object.entries(nodeNames).map(([id, name]) => [id, getRandomColor(name)]));
  108. const optimizeDatasets = (datasets) => {
  109. const dataByDate = datasets.reduce((acc, dataset) => {
  110. dataset.data.forEach(item => {
  111. acc[item.time] = acc[item.time] || [];
  112. acc[item.time].push({
  113. id: dataset.label,
  114. total: parseFloat(item.total)
  115. });
  116. });
  117. return acc;
  118. }, {});
  119. const allNodeIds = datasets.map(d => d.label);
  120. const optimizedData = Object.entries(dataByDate).map(([date, dayData]) => ({
  121. time: date,
  122. data: allNodeIds.map(id => dayData.find(item => item.id === id)?.total || 0),
  123. total: dayData.reduce((sum, item) => sum + item.total, 0)
  124. }));
  125. return datasets.map((dataset, index) => ({
  126. ...dataset,
  127. data: optimizedData.map(day => ({
  128. time: day.time,
  129. total: day.data[index]
  130. }))
  131. }));
  132. };
  133. const generateDatasets = (flows, nodeColorMap) =>
  134. Object.entries(flows.reduce((acc, flow) => {
  135. acc[flow.id] = acc[flow.id] || [];
  136. acc[flow.id].push({
  137. time: flow.time,
  138. total: parseFloat(flow.total),
  139. name: flow.name
  140. });
  141. return acc;
  142. }, {})).map(([nodeId, data]) => ({
  143. label: data[0].name,
  144. backgroundColor: nodeColorMap[nodeId],
  145. borderColor: nodeColorMap[nodeId],
  146. data,
  147. fill: true
  148. }));
  149. const createBarChart = (elementId, labels, datasets, labelTail, unit = "GiB") => {
  150. const optimizedDatasets = optimizeDatasets(datasets);
  151. new Chart(document.getElementById(elementId), {
  152. type: "bar",
  153. data: {
  154. labels: labels || optimizedDatasets[0]?.data.map(d => d.time),
  155. datasets: optimizedDatasets
  156. },
  157. plugins: [ChartDataLabels],
  158. options: {
  159. parsing: {
  160. xAxisKey: "time",
  161. yAxisKey: "total"
  162. },
  163. scales: {
  164. x: {
  165. stacked: true
  166. },
  167. y: {
  168. stacked: true
  169. }
  170. },
  171. responsive: true,
  172. plugins: {
  173. legend: {
  174. labels: {
  175. padding: 10,
  176. usePointStyle: true,
  177. pointStyle: "circle",
  178. font: {
  179. size: 14
  180. }
  181. }
  182. },
  183. tooltip: {
  184. mode: "index",
  185. intersect: false,
  186. callbacks: {
  187. title: context => `${context[0].label} ${labelTail}`,
  188. label: context => {
  189. const dataset = context.dataset;
  190. const value = dataset.data[context.dataIndex]?.total || context.parsed.y;
  191. return `${dataset.label || ""}: ${value.toFixed(2)} ${unit}`;
  192. }
  193. }
  194. },
  195. datalabels: {
  196. display: true,
  197. align: "end",
  198. anchor: "end",
  199. formatter: (value, context) => {
  200. if (context.datasetIndex === context.chart.data.datasets.length - 1) {
  201. let total = context.chart.data.datasets.reduce((sum, dataset) => sum + dataset.data[context.dataIndex].total,
  202. 0);
  203. return total.toFixed(2) + unit;
  204. }
  205. return null;
  206. }
  207. }
  208. }
  209. }
  210. });
  211. };
  212. const createDoughnutChart = (elementId, labels, data, colors, date) => {
  213. Chart.register({
  214. id: "totalLabel",
  215. beforeDraw(chart) {
  216. const {
  217. ctx,
  218. chartArea,
  219. data
  220. } = chart;
  221. if (!chartArea || !data.datasets.length) return;
  222. const total = data.datasets[0].data.reduce((acc, val) => acc + val, 0);
  223. if (typeof total !== "number") return;
  224. const {
  225. width,
  226. height,
  227. top,
  228. left
  229. } = chartArea;
  230. const text = `${date}\n${total.toFixed(2)} GiB`;
  231. ctx.save();
  232. ctx.font = "bold 32px Roboto";
  233. ctx.fillStyle = "black";
  234. ctx.textAlign = "center";
  235. ctx.textBaseline = "middle";
  236. const lineHeight = 40;
  237. text.split("\n").forEach((line, index) => {
  238. ctx.fillText(line, left + width / 2, top + height / 2 - lineHeight / 2 + index * lineHeight);
  239. });
  240. ctx.restore();
  241. }
  242. });
  243. new Chart(document.getElementById(elementId), {
  244. type: "doughnut",
  245. data: {
  246. labels,
  247. datasets: [{
  248. data,
  249. backgroundColor: colors
  250. }]
  251. },
  252. options: {
  253. responsive: true,
  254. plugins: {
  255. legend: {
  256. display: false
  257. },
  258. tooltip: {
  259. callbacks: {
  260. label: tooltipItem => {
  261. const dataset = tooltipItem.dataset;
  262. const currentValue = dataset.data[tooltipItem.dataIndex];
  263. const label = tooltipItem.label || "";
  264. return `${label}: ${currentValue.toFixed(2)} G`;
  265. }
  266. }
  267. },
  268. datalabels: {
  269. color: "#fff",
  270. formatter: (value, context) => {
  271. const total = context.dataset.data.reduce((sum, val) => sum + val, 0);
  272. const percentage = (value / total * 100).toFixed(1);
  273. const label = context.chart.data.labels[context.dataIndex];
  274. return percentage > 1 ? `${label} ${percentage}%` : "";
  275. },
  276. anchor: "center",
  277. rotation: function(ctx) {
  278. const valuesBefore = ctx.dataset.data.slice(0, ctx.dataIndex).reduce((a, b) => a + b, 0);
  279. const sum = ctx.dataset.data.reduce((a, b) => a + b, 0);
  280. const rotation = ((valuesBefore + ctx.dataset.data[ctx.dataIndex] / 2) / sum * 360);
  281. return rotation < 180 ? rotation - 90 : rotation + 90;
  282. },
  283. font: {
  284. weight: "bold",
  285. size: 16,
  286. family: "Roboto"
  287. }
  288. }
  289. }
  290. },
  291. plugins: [ChartDataLabels, "totalLabel"]
  292. });
  293. };
  294. const generatePieData = (flows, nodeColorMap) => {
  295. return {
  296. labels: flows.map(flow => flow.name),
  297. data: flows.map(flow => flow.total),
  298. colors: flows.map(flow => nodeColorMap[flow.id])
  299. };
  300. };
  301. const initCharts = () => {
  302. if (nodeData) {
  303. const nodeColorMap = generateNodeColorMap(nodeData.nodes);
  304. createBarChart("hourlyBar", nodeData.hours.map(String), generateDatasets(nodeData.hourlyFlows, nodeColorMap), @json(trans_choice('common.hour', 2)),
  305. "GiB");
  306. createBarChart("dailyBar", "", generateDatasets(nodeData.dailyFlows, nodeColorMap), "", "GiB");
  307. const lastDate = nodeData.dailyFlows[nodeData.dailyFlows.length - 1].time;
  308. const lastDayData = nodeData.dailyFlows.filter(flow => flow.time === lastDate);
  309. const {
  310. labels,
  311. data,
  312. colors
  313. } = generatePieData(lastDayData, nodeColorMap);
  314. createDoughnutChart("dailyPie", labels, data, colors, lastDate);
  315. }
  316. };
  317. const handleFormSubmit = (event, form) => {
  318. event.preventDefault();
  319. let urlParams = new URLSearchParams(window.location.search);
  320. let formData = new FormData(form);
  321. for (let [key, value] of formData.entries()) {
  322. value ? urlParams.set(key, value) : urlParams.delete(key);
  323. }
  324. window.location.href = `${window.location.pathname}?${urlParams.toString()}`;
  325. };
  326. document.addEventListener("DOMContentLoaded", () => {
  327. const hourDateSelect = document.getElementById("hour_date");
  328. if (hourDateSelect) {
  329. hourDateSelect.addEventListener("change", (event) => handleFormSubmit(event, event.target.form));
  330. $(hourDateSelect).selectpicker("val", new URLSearchParams(window.location.search).get("hour_date") || @json(now()->toDateString()));
  331. }
  332. $(".input-daterange").datepicker({
  333. startDate: nodeData.start_date,
  334. endDate: new Date(),
  335. language: document.documentElement.lang || 'en',
  336. autoclose: true,
  337. todayHighlight: true
  338. });
  339. populateFormFromQueryParams();
  340. initCharts();
  341. });
  342. </script>
  343. @endsection