nodeDataAnalysis.blade.php 17 KB

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