| 1 | <template> |
| 2 | <n-spin v-model:show="loading" class="h-full w-full overflow-hidden" content-class="h-full w-full overflow-hidden"> |
| 3 | <div v-if="org" class="report-panels flex h-full w-full gap-2"> |
| 4 | <div class="panels-container flex h-full grow flex-col gap-4"> |
| 5 | <div class="rows-container"> |
| 6 | <n-scrollbar style="max-height: 100%" trigger="none"> |
| 7 | <div class="drag-wrapper"> |
| 8 | <draggable |
| 9 | v-model="rows" |
| 10 | item-key="id" |
| 11 | :animation="200" |
| 12 | ghost-class="ghost-row" |
| 13 | handle=".pan-area" |
| 14 | :group="{ name: 'rows', pull: false, put: false }" |
| 15 | class="flex flex-col gap-2" |
| 16 | > |
| 17 | <template #item="{ element: row }"> |
| 18 | <div class="row p-3" :class="{ 'height-large': row.height === 2 }"> |
| 19 | <div v-if="!row.panels.length" class="empty-message">Drop panels here</div> |
| 20 | <draggable |
| 21 | v-model="row.panels" |
| 22 | item-key="id" |
| 23 | :animation="200" |
| 24 | ghost-class="ghost-panel" |
| 25 | :group="{ |
| 26 | name: 'panels', |
| 27 | put(to: { el: HTMLElement }) { |
| 28 | return to.el.children.length < 4 |
| 29 | }, |
| 30 | pull: ['panels'] |
| 31 | }" |
| 32 | class="drop-panels-area flex h-full w-full gap-3" |
| 33 | > |
| 34 | <template #header> |
| 35 | <div class="left-box flex justify-end"> |
| 36 | <div class="pan-area"> |
| 37 | <Icon :name="PanIcon" :size="20" /> |
| 38 | </div> |
| 39 | <div class="delete-box"> |
| 40 | <n-tooltip trigger="hover"> |
| 41 | <template #trigger> |
| 42 | <n-button text type="error" @click="removeRow(row)"> |
| 43 | <template #icon> |
| 44 | <Icon :name="CloseIcon" /> |
| 45 | </template> |
| 46 | </n-button> |
| 47 | </template> |
| 48 | Remove Row |
| 49 | </n-tooltip> |
| 50 | </div> |
| 51 | <div class="settings-box"> |
| 52 | <n-popover trigger="click"> |
| 53 | <template #trigger> |
| 54 | <n-button text> |
| 55 | <template #icon> |
| 56 | <Icon :name="RowSettingsIcon" :size="13" /> |
| 57 | </template> |
| 58 | </n-button> |
| 59 | </template> |
| 60 | |
| 61 | <div class="flex items-center gap-3 py-1"> |
| 62 | <div class="text-secondary text-sm">Row height:</div> |
| 63 | <n-switch |
| 64 | v-model:value="row.height" |
| 65 | size="small" |
| 66 | :unchecked-value="1" |
| 67 | :checked-value="2" |
| 68 | > |
| 69 | <template #checked> |
| 70 | <small>Large</small> |
| 71 | </template> |
| 72 | <template #unchecked> |
| 73 | <small>Regular</small> |
| 74 | </template> |
| 75 | </n-switch> |
| 76 | </div> |
| 77 | </n-popover> |
| 78 | </div> |
| 79 | </div> |
| 80 | </template> |
| 81 | <template #item="{ element: panel }"> |
| 82 | <div class="panel"> |
| 83 | <div class="delete-box"> |
| 84 | <n-tooltip trigger="hover"> |
| 85 | <template #trigger> |
| 86 | <n-button |
| 87 | text |
| 88 | type="error" |
| 89 | @click="removePanel(row, panel)" |
| 90 | > |
| 91 | <template #icon> |
| 92 | <Icon :name="CloseIcon" /> |
| 93 | </template> |
| 94 | </n-button> |
| 95 | </template> |
| 96 | Remove Panel |
| 97 | </n-tooltip> |
| 98 | </div> |
| 99 | <div class="dashboard-title"> |
| 100 | {{ panel.dashboardTitle }} |
| 101 | </div> |
| 102 | <div class="content"> |
| 103 | {{ panel.panelTitle }} |
| 104 | </div> |
| 105 | </div> |
| 106 | </template> |
| 107 | </draggable> |
| 108 | </div> |
| 109 | </template> |
| 110 | </draggable> |
| 111 | </div> |
| 112 | </n-scrollbar> |
| 113 | </div> |
| 114 | <div class="toolbar flex items-center justify-between gap-2 pr-4"> |
| 115 | <n-button |
| 116 | v-if="dashboard || panelsReady" |
| 117 | class="add-task-btn mt-0! flex items-center justify-center" |
| 118 | @click="addRow()" |
| 119 | > |
| 120 | <template #icon> |
| 121 | <Icon :name="AddIcon" /> |
| 122 | </template> |
| 123 | <span>Add row</span> |
| 124 | </n-button> |
| 125 | |
| 126 | <div v-if="panelsReady" class="flex items-center gap-2"> |
| 127 | <n-button type="success" :loading @click="print()"> |
| 128 | <template #icon> |
| 129 | <Icon :name="PrintIcon" /> |
| 130 | </template> |
| 131 | Print Report |
| 132 | </n-button> |
| 133 | <n-button type="success" :loading @click="openSettings()"> |
| 134 | <template #icon> |
| 135 | <Icon :name="SettingsIcon" /> |
| 136 | </template> |
| 137 | </n-button> |
| 138 | </div> |
| 139 | </div> |
| 140 | </div> |
| 141 | |
| 142 | <div class="panels-sidebar bg-secondary h-full"> |
| 143 | <n-scrollbar v-if="dashboard && panelsList.length" style="max-height: 100%" trigger="none"> |
| 144 | <div class="p-3"> |
| 145 | <draggable |
| 146 | class="flex flex-col gap-3" |
| 147 | :list="panelsList" |
| 148 | :group="{ name: 'panels', pull: 'clone', put: false }" |
| 149 | :sort="false" |
| 150 | item-key="id" |
| 151 | > |
| 152 | <template #item="{ element: panel }"> |
| 153 | <div class="panel"> |
| 154 | <div class="content"> |
| 155 | {{ panel.panelTitle }} |
| 156 | </div> |
| 157 | </div> |
| 158 | </template> |
| 159 | </draggable> |
| 160 | </div> |
| 161 | </n-scrollbar> |
| 162 | <div v-else class="h-full p-3"> |
| 163 | <div class="empty-message p-3"> |
| 164 | <div v-if="!dashboard"> |
| 165 | Select a |
| 166 | <code>Dashboard</code> |
| 167 | to get the panels |
| 168 | </div> |
| 169 | <div v-else>No Panels found</div> |
| 170 | </div> |
| 171 | </div> |
| 172 | </div> |
| 173 | </div> |
| 174 | |
| 175 | <div v-else class="empty-message"> |
| 176 | Select an |
| 177 | <code>Organization</code> |
| 178 | / |
| 179 | <code>Dashboard</code> |
| 180 | to create the Report |
| 181 | </div> |
| 182 | |
| 183 | <n-drawer |
| 184 | v-model:show="settingDrawerOpen" |
| 185 | :width="500" |
| 186 | style="max-width: 90vw" |
| 187 | :trap-focus="false" |
| 188 | :class="{ 'opacity-0': preloadingPrintSettings }" |
| 189 | display-directive="show" |
| 190 | > |
| 191 | <n-drawer-content title="Report Settings" closable :native-scrollbar="false"> |
| 192 | <PrintSettings @update="printSettings = $event" /> |
| 193 | </n-drawer-content> |
| 194 | </n-drawer> |
| 195 | </n-spin> |
| 196 | </template> |
| 197 | |
| 198 | <script setup lang="ts"> |
| 199 | import type { PrintSettingsData } from "./PrintSettings.vue" |
| 200 | import type { GenerateReportPayload, ReportTimeRange } from "@/api/endpoints/reporting" |
| 201 | import type { Dashboard, Org, Panel } from "@/types/reporting.d" |
| 202 | import { useStorage } from "@vueuse/core" |
| 203 | import { saveAs } from "file-saver" |
| 204 | import _kebabCase from "lodash/kebabCase" |
| 205 | import { NButton, NDrawer, NDrawerContent, NPopover, NScrollbar, NSpin, NSwitch, NTooltip, useMessage } from "naive-ui" |
| 206 | import { computed, onMounted, ref, toRefs, watch } from "vue" |
| 207 | import draggable from "vuedraggable" |
| 208 | import Api from "@/api" |
| 209 | import Icon from "@/components/common/Icon.vue" |
| 210 | import { useSettingsStore } from "@/stores/settings" |
| 211 | import { formatDate } from "@/utils/format" |
| 212 | import * as defaultSettings from "./defaultSettings" |
| 213 | import PrintSettings from "./PrintSettings.vue" |
| 214 | |
| 215 | const props = defineProps<{ |
| 216 | timerange: ReportTimeRange | null |
| 217 | org: Org | null |
| 218 | dashboard: Dashboard | null |
| 219 | panels: Panel[] |
| 220 | }>() |
| 221 | const ROW_GAP = 20 |
| 222 | const ROW_WIDTH = 800 |
| 223 | const ROW_HEIGHT = 320 |
| 224 | |
| 225 | interface OrgData { |
| 226 | id: number |
| 227 | rows: Row[] |
| 228 | } |
| 229 | |
| 230 | interface Row { |
| 231 | id: number |
| 232 | height: 1 | 2 |
| 233 | panels: PanelData[] |
| 234 | } |
| 235 | |
| 236 | interface PanelData { |
| 237 | panelId: number |
| 238 | panelTitle: string |
| 239 | orgId: number |
| 240 | orgName: string |
| 241 | dashboardUID: string |
| 242 | dashboardTitle: string |
| 243 | } |
| 244 | |
| 245 | const { timerange, org, dashboard, panels } = toRefs(props) |
| 246 | |
| 247 | const PanIcon = "carbon:draggable" |
| 248 | const CloseIcon = "carbon:close" |
| 249 | const SettingsIcon = "carbon:settings" |
| 250 | const AddIcon = "carbon:add-alt" |
| 251 | const PrintIcon = "carbon:printer" |
| 252 | const RowSettingsIcon = "carbon:fit-to-height" |
| 253 | const dFormats = useSettingsStore().dateFormat |
| 254 | const message = useMessage() |
| 255 | const loadingPrint = ref(false) |
| 256 | const loading = computed(() => loadingPrint.value) |
| 257 | |
| 258 | const settingDrawerOpen = ref(false) |
| 259 | const printSettings = ref<Partial<PrintSettingsData>>({}) |
| 260 | const preloadingPrintSettings = ref(true) |
| 261 | |
| 262 | const orgs = useStorage<OrgData[]>("report-panel-orgs-data", [], localStorage) |
| 263 | const rows = computed<Row[]>({ |
| 264 | get() { |
| 265 | return orgs.value.find(o => o.id === org.value?.id)?.rows || [] |
| 266 | }, |
| 267 | set(val: Row[]) { |
| 268 | const orgData = orgs.value.find(o => o.id === org.value?.id) |
| 269 | if (orgData?.rows) { |
| 270 | orgData.rows = val |
| 271 | } |
| 272 | } |
| 273 | }) |
| 274 | const panelsList = ref<PanelData[]>([]) |
| 275 | |
| 276 | const panelsReady = computed<number>(() => { |
| 277 | return rows.value.reduce((acc, row) => { |
| 278 | return acc + row.panels.length |
| 279 | }, 0) |
| 280 | }) |
| 281 | |
| 282 | watch(org, val => { |
| 283 | if (val) { |
| 284 | setOrg(val) |
| 285 | } |
| 286 | panelsList.value = [] |
| 287 | }) |
| 288 | |
| 289 | watch(panels, val => { |
| 290 | if (val?.length) { |
| 291 | setPanelsList(val) |
| 292 | |
| 293 | if (!rows.value.length) { |
| 294 | addRow() |
| 295 | } |
| 296 | } |
| 297 | }) |
| 298 | |
| 299 | function openSettings() { |
| 300 | settingDrawerOpen.value = true |
| 301 | } |
| 302 | |
| 303 | function addRow() { |
| 304 | rows.value.push({ |
| 305 | id: Date.now(), |
| 306 | height: 1, |
| 307 | panels: [] |
| 308 | }) |
| 309 | } |
| 310 | |
| 311 | function removeRow(row: Row) { |
| 312 | rows.value.splice( |
| 313 | rows.value.findIndex(o => o.id === row.id), |
| 314 | 1 |
| 315 | ) |
| 316 | } |
| 317 | |
| 318 | function removePanel(row: Row, panel: PanelData) { |
| 319 | row.panels.splice( |
| 320 | row.panels.findIndex(o => o.panelId === panel.panelId), |
| 321 | 1 |
| 322 | ) |
| 323 | } |
| 324 | |
| 325 | function setPanelsList(panels: Panel[]) { |
| 326 | if (org.value && dashboard.value && panels.length) { |
| 327 | panelsList.value = [] |
| 328 | |
| 329 | for (const panel of panels) { |
| 330 | panelsList.value.push({ |
| 331 | panelId: panel.id, |
| 332 | panelTitle: panel.title, |
| 333 | orgId: org.value.id, |
| 334 | orgName: org.value.name, |
| 335 | dashboardUID: dashboard.value.uid, |
| 336 | dashboardTitle: dashboard.value.title |
| 337 | }) |
| 338 | } |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | function setOrg(org: Org) { |
| 343 | const exist = orgs.value.find(o => o.id === org.id) |
| 344 | if (!exist) { |
| 345 | orgs.value.push({ |
| 346 | id: org.id, |
| 347 | rows: [] |
| 348 | }) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | const NUMBER_REGEX = /\d+/ |
| 353 | const LETTER_REGEX = /[a-z]/i |
| 354 | |
| 355 | function print() { |
| 356 | if (!timerange.value) { |
| 357 | return |
| 358 | } |
| 359 | |
| 360 | loadingPrint.value = true |
| 361 | |
| 362 | const timeValue = Number.parseInt(timerange.value.match(NUMBER_REGEX)?.[0] || "1") |
| 363 | const timeUnit = (timerange.value.match(LETTER_REGEX)?.[0] || "h").toLocaleLowerCase() |
| 364 | const timerangeText = `Last ${timeValue} ${timeUnit === "d" ? "Day" : timeUnit === "h" ? "Hour" : "minute"}${ |
| 365 | timeValue > 1 ? "s" : "" |
| 366 | }` |
| 367 | |
| 368 | const payload: GenerateReportPayload = { |
| 369 | timerange: timerange.value, |
| 370 | timerange_text: timerangeText, |
| 371 | logo_base64: printSettings.value.logo || defaultSettings.logo, |
| 372 | company_name: printSettings.value.company || defaultSettings.company, |
| 373 | rows: [] |
| 374 | } |
| 375 | |
| 376 | const density = printSettings.value.retina ? 2 : 1 |
| 377 | |
| 378 | for (const row of rows.value) { |
| 379 | if (row.panels.length) { |
| 380 | const panel_width = ((ROW_WIDTH - ROW_GAP * (row.panels.length - 1)) / row.panels.length) * density |
| 381 | const panel_height = ROW_HEIGHT * density * (row.height || 1) |
| 382 | |
| 383 | payload.rows.push({ |
| 384 | id: row.id, |
| 385 | panels: row.panels.map(o => ({ |
| 386 | org_id: o.orgId, |
| 387 | dashboard_title: o.dashboardTitle, |
| 388 | dashboard_uid: o.dashboardUID, |
| 389 | panel_id: o.panelId, |
| 390 | panel_width, |
| 391 | panel_height, |
| 392 | theme: printSettings.value.theme || defaultSettings.theme |
| 393 | })) |
| 394 | }) |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | const reportFileName = `report${org.value?.name ? `:${_kebabCase(org.value.name)}` : ""}_${formatDate( |
| 399 | new Date(), |
| 400 | dFormats.datetimesec |
| 401 | )}.pdf` |
| 402 | |
| 403 | Api.reporting |
| 404 | .generateReport(payload) |
| 405 | .then(res => { |
| 406 | if (res.data.success) { |
| 407 | const dataUri = `data:application/pdf;base64,${res.data.base64_result}` |
| 408 | saveAs(dataUri, reportFileName) |
| 409 | } else { |
| 410 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 411 | } |
| 412 | }) |
| 413 | .catch(err => { |
| 414 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 415 | }) |
| 416 | .finally(() => { |
| 417 | loadingPrint.value = false |
| 418 | }) |
| 419 | } |
| 420 | |
| 421 | onMounted(() => { |
| 422 | // need to preload print settings from drawer |
| 423 | settingDrawerOpen.value = true |
| 424 | setTimeout(() => { |
| 425 | settingDrawerOpen.value = false |
| 426 | setTimeout(() => { |
| 427 | preloadingPrintSettings.value = false |
| 428 | }, 300) |
| 429 | }, 100) |
| 430 | }) |
| 431 | </script> |
| 432 | |
| 433 | <style lang="scss" scoped> |
| 434 | .empty-message { |
| 435 | border: 2px dashed var(--border-color) !important; |
| 436 | border-radius: var(--border-radius); |
| 437 | height: 100%; |
| 438 | width: 100%; |
| 439 | display: flex; |
| 440 | align-items: center; |
| 441 | justify-content: center; |
| 442 | text-align: center; |
| 443 | line-height: 1.3; |
| 444 | gap: 6px; |
| 445 | } |
| 446 | .report-panels { |
| 447 | overflow: hidden; |
| 448 | |
| 449 | .panel { |
| 450 | aspect-ratio: 1.72; |
| 451 | cursor: move; |
| 452 | |
| 453 | .content { |
| 454 | border-radius: var(--border-radius); |
| 455 | background-color: var(--bg-default-color); |
| 456 | border: 1px solid var(--border-color); |
| 457 | overflow: hidden; |
| 458 | display: flex; |
| 459 | align-items: center; |
| 460 | justify-content: center; |
| 461 | font-size: 12px; |
| 462 | font-weight: bold; |
| 463 | padding: 16px; |
| 464 | text-align: center; |
| 465 | transition: border-color 0.2s; |
| 466 | width: 100%; |
| 467 | height: 100%; |
| 468 | } |
| 469 | |
| 470 | &:hover { |
| 471 | .content { |
| 472 | border-color: rgba(var(--primary-color-rgb) / 0.4); |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | &.ghost-panel { |
| 477 | .content { |
| 478 | border: 2px dashed rgba(var(--primary-color-rgb) / 0.4) !important; |
| 479 | } |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | .panels-container { |
| 484 | overflow: hidden; |
| 485 | |
| 486 | .rows-container { |
| 487 | overflow: hidden; |
| 488 | border-radius: var(--border-radius); |
| 489 | |
| 490 | .drag-wrapper { |
| 491 | padding-right: 16px; |
| 492 | padding-left: 20px; |
| 493 | } |
| 494 | |
| 495 | .row { |
| 496 | border-radius: var(--border-radius); |
| 497 | background-color: var(--bg-secondary-color); |
| 498 | border: 1px solid var(--border-color); |
| 499 | height: 155px; |
| 500 | position: relative; |
| 501 | transition: border-color 0.2s; |
| 502 | |
| 503 | &.height-large { |
| 504 | height: 300px; |
| 505 | } |
| 506 | .empty-message { |
| 507 | position: absolute; |
| 508 | z-index: 0; |
| 509 | width: unset; |
| 510 | height: unset; |
| 511 | inset: calc(var(--spacing) * 3); |
| 512 | } |
| 513 | |
| 514 | .drop-panels-area { |
| 515 | position: absolute; |
| 516 | width: unset; |
| 517 | height: unset; |
| 518 | inset: calc(var(--spacing) * 3); |
| 519 | } |
| 520 | |
| 521 | .left-box { |
| 522 | position: absolute; |
| 523 | top: 0; |
| 524 | left: -32px; |
| 525 | bottom: 0; |
| 526 | width: 25px; |
| 527 | border: 1px solid var(--border-color); |
| 528 | background-color: var(--bg-secondary-color); |
| 529 | border-radius: var(--border-radius); |
| 530 | |
| 531 | .pan-area { |
| 532 | position: absolute; |
| 533 | top: 50%; |
| 534 | left: 2px; |
| 535 | transform: translateY(-50%); |
| 536 | cursor: move; |
| 537 | } |
| 538 | |
| 539 | .delete-box { |
| 540 | position: absolute; |
| 541 | top: 0px; |
| 542 | left: 0px; |
| 543 | right: 0; |
| 544 | background-color: rgba(var(--error-color-rgb) / 0.1); |
| 545 | border-top-left-radius: var(--border-radius); |
| 546 | border-top-right-radius: var(--border-radius); |
| 547 | text-align: center; |
| 548 | padding-top: 4px; |
| 549 | } |
| 550 | |
| 551 | .settings-box { |
| 552 | position: absolute; |
| 553 | bottom: 0px; |
| 554 | left: 0px; |
| 555 | right: 0; |
| 556 | background-color: rgba(var(--hover-color-rgb) / 0.05); |
| 557 | border-bottom-left-radius: var(--border-radius); |
| 558 | border-bottom-right-radius: var(--border-radius); |
| 559 | text-align: center; |
| 560 | padding-top: 4px; |
| 561 | } |
| 562 | |
| 563 | &:hover { |
| 564 | border-color: rgba(var(--primary-color-rgb) / 0.2); |
| 565 | } |
| 566 | } |
| 567 | |
| 568 | .panel { |
| 569 | height: 100%; |
| 570 | aspect-ratio: unset; |
| 571 | flex: 1 1 0px; |
| 572 | position: relative; |
| 573 | |
| 574 | .delete-box { |
| 575 | position: absolute; |
| 576 | top: 4px; |
| 577 | right: 4px; |
| 578 | } |
| 579 | |
| 580 | .dashboard-title { |
| 581 | font-size: 10px; |
| 582 | position: absolute; |
| 583 | top: 0px; |
| 584 | left: 0px; |
| 585 | right: 28px; |
| 586 | padding: 5px 10px; |
| 587 | color: var(--fg-secondary-color); |
| 588 | white-space: nowrap; |
| 589 | overflow: hidden; |
| 590 | text-overflow: ellipsis; |
| 591 | } |
| 592 | } |
| 593 | |
| 594 | &:hover { |
| 595 | border-color: rgba(var(--primary-color-rgb) / 0.2); |
| 596 | } |
| 597 | |
| 598 | &.ghost-row { |
| 599 | border: 2px dashed rgba(var(--primary-color-rgb) / 0.4) !important; |
| 600 | } |
| 601 | } |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | .panels-sidebar { |
| 606 | border-radius: var(--border-radius); |
| 607 | border: 1px solid var(--border-color); |
| 608 | width: 200px; |
| 609 | |
| 610 | :deep() { |
| 611 | .n-scrollbar { |
| 612 | .n-scrollbar-rail { |
| 613 | right: 3px; |
| 614 | } |
| 615 | } |
| 616 | } |
| 617 | |
| 618 | .panel { |
| 619 | width: 100%; |
| 620 | } |
| 621 | } |
| 622 | } |
| 623 | </style> |