github.com/topsteplocal/gophish@v0.6.0/static/js/src/app/campaign_results.js (about) 1 var map = null 2 var doPoll = true; 3 4 // statuses is a helper map to point result statuses to ui classes 5 var statuses = { 6 "Email Sent": { 7 color: "#1abc9c", 8 label: "label-success", 9 icon: "fa-envelope", 10 point: "ct-point-sent" 11 }, 12 "Emails Sent": { 13 color: "#1abc9c", 14 label: "label-success", 15 icon: "fa-envelope", 16 point: "ct-point-sent" 17 }, 18 "In progress": { 19 label: "label-primary" 20 }, 21 "Queued": { 22 label: "label-info" 23 }, 24 "Completed": { 25 label: "label-success" 26 }, 27 "Email Opened": { 28 color: "#f9bf3b", 29 label: "label-warning", 30 icon: "fa-envelope-open", 31 point: "ct-point-opened" 32 }, 33 "Clicked Link": { 34 color: "#F39C12", 35 label: "label-clicked", 36 icon: "fa-mouse-pointer", 37 point: "ct-point-clicked" 38 }, 39 "Success": { 40 color: "#f05b4f", 41 label: "label-danger", 42 icon: "fa-exclamation", 43 point: "ct-point-clicked" 44 }, 45 //not a status, but is used for the campaign timeline and user timeline 46 "Email Reported": { 47 color: "#45d6ef", 48 label: "label-info", 49 icon: "fa-bullhorn", 50 point: "ct-point-reported" 51 }, 52 "Error": { 53 color: "#6c7a89", 54 label: "label-default", 55 icon: "fa-times", 56 point: "ct-point-error" 57 }, 58 "Error Sending Email": { 59 color: "#6c7a89", 60 label: "label-default", 61 icon: "fa-times", 62 point: "ct-point-error" 63 }, 64 "Submitted Data": { 65 color: "#f05b4f", 66 label: "label-danger", 67 icon: "fa-exclamation", 68 point: "ct-point-clicked" 69 }, 70 "Unknown": { 71 color: "#6c7a89", 72 label: "label-default", 73 icon: "fa-question", 74 point: "ct-point-error" 75 }, 76 "Sending": { 77 color: "#428bca", 78 label: "label-primary", 79 icon: "fa-spinner", 80 point: "ct-point-sending" 81 }, 82 "Retrying": { 83 color: "#6c7a89", 84 label: "label-default", 85 icon: "fa-clock-o", 86 point: "ct-point-error" 87 }, 88 "Scheduled": { 89 color: "#428bca", 90 label: "label-primary", 91 icon: "fa-clock-o", 92 point: "ct-point-sending" 93 }, 94 "Campaign Created": { 95 label: "label-success", 96 icon: "fa-rocket" 97 } 98 } 99 100 var statusMapping = { 101 "Email Sent": "sent", 102 "Email Opened": "opened", 103 "Clicked Link": "clicked", 104 "Submitted Data": "submitted_data", 105 "Email Reported": "reported", 106 } 107 108 // This is an underwhelming attempt at an enum 109 // until I have time to refactor this appropriately. 110 var progressListing = [ 111 "Email Sent", 112 "Email Opened", 113 "Clicked Link", 114 "Submitted Data" 115 ] 116 117 var campaign = {} 118 var bubbles = [] 119 120 function dismiss() { 121 $("#modal\\.flashes").empty() 122 $("#modal").modal('hide') 123 $("#resultsTable").dataTable().DataTable().clear().draw() 124 } 125 126 // Deletes a campaign after prompting the user 127 function deleteCampaign() { 128 swal({ 129 title: "Are you sure?", 130 text: "This will delete the campaign. This can't be undone!", 131 type: "warning", 132 animation: false, 133 showCancelButton: true, 134 confirmButtonText: "Delete Campaign", 135 confirmButtonColor: "#428bca", 136 reverseButtons: true, 137 allowOutsideClick: false, 138 showLoaderOnConfirm: true, 139 preConfirm: function () { 140 return new Promise(function (resolve, reject) { 141 api.campaignId.delete(campaign.id) 142 .success(function (msg) { 143 resolve() 144 }) 145 .error(function (data) { 146 reject(data.responseJSON.message) 147 }) 148 }) 149 } 150 }).then(function () { 151 swal( 152 'Campaign Deleted!', 153 'This campaign has been deleted!', 154 'success' 155 ); 156 $('button:contains("OK")').on('click', function () { 157 location.href = '/campaigns' 158 }) 159 }) 160 } 161 162 // Completes a campaign after prompting the user 163 function completeCampaign() { 164 swal({ 165 title: "Are you sure?", 166 text: "Gophish will stop processing events for this campaign", 167 type: "warning", 168 animation: false, 169 showCancelButton: true, 170 confirmButtonText: "Complete Campaign", 171 confirmButtonColor: "#428bca", 172 reverseButtons: true, 173 allowOutsideClick: false, 174 showLoaderOnConfirm: true, 175 preConfirm: function () { 176 return new Promise(function (resolve, reject) { 177 api.campaignId.complete(campaign.id) 178 .success(function (msg) { 179 resolve() 180 }) 181 .error(function (data) { 182 reject(data.responseJSON.message) 183 }) 184 }) 185 } 186 }).then(function () { 187 swal( 188 'Campaign Completed!', 189 'This campaign has been completed!', 190 'success' 191 ); 192 $('#complete_button')[0].disabled = true; 193 $('#complete_button').text('Completed!') 194 doPoll = false; 195 }) 196 } 197 198 // Exports campaign results as a CSV file 199 function exportAsCSV(scope) { 200 exportHTML = $("#exportButton").html() 201 var csvScope = null 202 var filename = campaign.name + ' - ' + capitalize(scope) + '.csv' 203 switch (scope) { 204 case "results": 205 csvScope = campaign.results 206 break; 207 case "events": 208 csvScope = campaign.timeline 209 break; 210 } 211 if (!csvScope) { 212 return 213 } 214 $("#exportButton").html('<i class="fa fa-spinner fa-spin"></i>') 215 var csvString = Papa.unparse(csvScope, {}) 216 var csvData = new Blob([csvString], { 217 type: 'text/csv;charset=utf-8;' 218 }); 219 if (navigator.msSaveBlob) { 220 navigator.msSaveBlob(csvData, filename); 221 } else { 222 var csvURL = window.URL.createObjectURL(csvData); 223 var dlLink = document.createElement('a'); 224 dlLink.href = csvURL; 225 dlLink.setAttribute('download', filename) 226 document.body.appendChild(dlLink) 227 dlLink.click(); 228 document.body.removeChild(dlLink) 229 } 230 $("#exportButton").html(exportHTML) 231 } 232 233 function replay(event_idx) { 234 request = campaign.timeline[event_idx] 235 details = JSON.parse(request.details) 236 url = null 237 form = $('<form>').attr({ 238 method: 'POST', 239 target: '_blank', 240 }) 241 /* Create a form object and submit it */ 242 $.each(Object.keys(details.payload), function (i, param) { 243 if (param == "rid") { 244 return true; 245 } 246 if (param == "__original_url") { 247 url = details.payload[param]; 248 return true; 249 } 250 $('<input>').attr({ 251 name: param, 252 }).val(details.payload[param]).appendTo(form); 253 }) 254 /* Ensure we know where to send the user */ 255 // Prompt for the URL 256 swal({ 257 title: 'Where do you want the credentials submitted to?', 258 input: 'text', 259 showCancelButton: true, 260 inputPlaceholder: "http://example.com/login", 261 inputValue: url || "", 262 inputValidator: function (value) { 263 return new Promise(function (resolve, reject) { 264 if (value) { 265 resolve(); 266 } else { 267 reject('Invalid URL.'); 268 } 269 }); 270 } 271 }).then(function (result) { 272 url = result 273 submitForm() 274 }) 275 return 276 submitForm() 277 278 function submitForm() { 279 form.attr({ 280 action: url 281 }) 282 form.appendTo('body').submit().remove() 283 } 284 } 285 286 function renderTimeline(data) { 287 record = { 288 "first_name": data[2], 289 "last_name": data[3], 290 "email": data[4], 291 "position": data[5], 292 "status": data[6], 293 "send_date": data[7], 294 "reported": data[8] 295 } 296 results = '<div class="timeline col-sm-12 well well-lg">' + 297 '<h6>Timeline for ' + escapeHtml(record.first_name) + ' ' + escapeHtml(record.last_name) + 298 '</h6><span class="subtitle">Email: ' + escapeHtml(record.email) + '</span>' + 299 '<div class="timeline-graph col-sm-6">' 300 $.each(campaign.timeline, function (i, event) { 301 if (!event.email || event.email == record.email) { 302 // Add the event 303 results += '<div class="timeline-entry">' + 304 ' <div class="timeline-bar"></div>' 305 results += 306 ' <div class="timeline-icon ' + statuses[event.message].label + '">' + 307 ' <i class="fa ' + statuses[event.message].icon + '"></i></div>' + 308 ' <div class="timeline-message">' + escapeHtml(event.message) + 309 ' <span class="timeline-date">' + moment.utc(event.time).local().format('MMMM Do YYYY h:mm:ss a') + '</span>' 310 if (event.details) { 311 if (event.message == "Submitted Data") { 312 results += '<div class="timeline-replay-button"><button onclick="replay(' + i + ')" class="btn btn-success">' 313 results += '<i class="fa fa-refresh"></i> Replay Credentials</button></div>' 314 results += '<div class="timeline-event-details"><i class="fa fa-caret-right"></i> View Details</div>' 315 } 316 details = JSON.parse(event.details) 317 if (details.payload) { 318 results += '<div class="timeline-event-results">' 319 results += ' <table class="table table-condensed table-bordered table-striped">' 320 results += ' <thead><tr><th>Parameter</th><th>Value(s)</tr></thead><tbody>' 321 $.each(Object.keys(details.payload), function (i, param) { 322 if (param == "rid") { 323 return true; 324 } 325 results += ' <tr>' 326 results += ' <td>' + escapeHtml(param) + '</td>' 327 results += ' <td>' + escapeHtml(details.payload[param]) + '</td>' 328 results += ' </tr>' 329 }) 330 results += ' </tbody></table>' 331 results += '</div>' 332 } 333 if (details.error) { 334 results += '<div class="timeline-event-details"><i class="fa fa-caret-right"></i> View Details</div>' 335 results += '<div class="timeline-event-results">' 336 results += '<span class="label label-default">Error</span> ' + details.error 337 results += '</div>' 338 } 339 } 340 results += '</div></div>' 341 } 342 }) 343 // Add the scheduled send event at the bottom 344 if (record.status == "Scheduled" || record.status == "Retrying") { 345 results += '<div class="timeline-entry">' + 346 ' <div class="timeline-bar"></div>' 347 results += 348 ' <div class="timeline-icon ' + statuses[record.status].label + '">' + 349 ' <i class="fa ' + statuses[record.status].icon + '"></i></div>' + 350 ' <div class="timeline-message">' + "Scheduled to send at " + record.send_date + '</span>' 351 } 352 results += '</div></div>' 353 return results 354 } 355 356 var renderTimelineChart = function (chartopts) { 357 return Highcharts.chart('timeline_chart', { 358 chart: { 359 zoomType: 'x', 360 type: 'line', 361 height: "200px" 362 }, 363 title: { 364 text: 'Campaign Timeline' 365 }, 366 xAxis: { 367 type: 'datetime', 368 dateTimeLabelFormats: { 369 second: '%l:%M:%S', 370 minute: '%l:%M', 371 hour: '%l:%M', 372 day: '%b %d, %Y', 373 week: '%b %d, %Y', 374 month: '%b %Y' 375 } 376 }, 377 yAxis: { 378 min: 0, 379 max: 2, 380 visible: false, 381 tickInterval: 1, 382 labels: { 383 enabled: false 384 }, 385 title: { 386 text: "" 387 } 388 }, 389 tooltip: { 390 formatter: function () { 391 return Highcharts.dateFormat('%A, %b %d %l:%M:%S %P', new Date(this.x)) + 392 '<br>Event: ' + this.point.message + '<br>Email: <b>' + this.point.email + '</b>' 393 } 394 }, 395 legend: { 396 enabled: false 397 }, 398 plotOptions: { 399 series: { 400 marker: { 401 enabled: true, 402 symbol: 'circle', 403 radius: 3 404 }, 405 cursor: 'pointer', 406 }, 407 line: { 408 states: { 409 hover: { 410 lineWidth: 1 411 } 412 } 413 } 414 }, 415 credits: { 416 enabled: false 417 }, 418 series: [{ 419 data: chartopts['data'], 420 dashStyle: "shortdash", 421 color: "#cccccc", 422 lineWidth: 1, 423 turboThreshold: 0 424 }] 425 }) 426 } 427 428 /* Renders a pie chart using the provided chartops */ 429 var renderPieChart = function (chartopts) { 430 return Highcharts.chart(chartopts['elemId'], { 431 chart: { 432 type: 'pie', 433 events: { 434 load: function () { 435 var chart = this, 436 rend = chart.renderer, 437 pie = chart.series[0], 438 left = chart.plotLeft + pie.center[0], 439 top = chart.plotTop + pie.center[1]; 440 this.innerText = rend.text(chartopts['data'][0].y, left, top). 441 attr({ 442 'text-anchor': 'middle', 443 'font-size': '24px', 444 'font-weight': 'bold', 445 'fill': chartopts['colors'][0], 446 'font-family': 'Helvetica,Arial,sans-serif' 447 }).add(); 448 }, 449 render: function () { 450 this.innerText.attr({ 451 text: chartopts['data'][0].y 452 }) 453 } 454 } 455 }, 456 title: { 457 text: chartopts['title'] 458 }, 459 plotOptions: { 460 pie: { 461 innerSize: '80%', 462 dataLabels: { 463 enabled: false 464 } 465 } 466 }, 467 credits: { 468 enabled: false 469 }, 470 tooltip: { 471 formatter: function () { 472 if (this.key == undefined) { 473 return false 474 } 475 return '<span style="color:' + this.color + '">\u25CF</span>' + this.point.name + ': <b>' + this.y + '</b><br/>' 476 } 477 }, 478 series: [{ 479 data: chartopts['data'], 480 colors: chartopts['colors'], 481 }] 482 }) 483 } 484 485 /* Updates the bubbles on the map 486 487 @param {campaign.result[]} results - The campaign results to process 488 */ 489 var updateMap = function (results) { 490 if (!map) { 491 return 492 } 493 bubbles = [] 494 $.each(campaign.results, function (i, result) { 495 // Check that it wasn't an internal IP 496 if (result.latitude == 0 && result.longitude == 0) { 497 return true; 498 } 499 newIP = true 500 $.each(bubbles, function (i, bubble) { 501 if (bubble.ip == result.ip) { 502 bubbles[i].radius += 1 503 newIP = false 504 return false 505 } 506 }) 507 if (newIP) { 508 bubbles.push({ 509 latitude: result.latitude, 510 longitude: result.longitude, 511 name: result.ip, 512 fillKey: "point", 513 radius: 2 514 }) 515 } 516 }) 517 map.bubbles(bubbles) 518 } 519 520 /** 521 * Creates a status label for use in the results datatable 522 * @param {string} status 523 * @param {moment(datetime)} send_date 524 */ 525 function createStatusLabel(status, send_date) { 526 var label = statuses[status].label || "label-default"; 527 var statusColumn = "<span class=\"label " + label + "\">" + status + "</span>" 528 // Add the tooltip if the email is scheduled to be sent 529 if (status == "Scheduled" || status == "Retrying") { 530 var sendDateMessage = "Scheduled to send at " + send_date 531 statusColumn = "<span class=\"label " + label + "\" data-toggle=\"tooltip\" data-placement=\"top\" data-html=\"true\" title=\"" + sendDateMessage + "\">" + status + "</span>" 532 } 533 return statusColumn 534 } 535 536 /* poll - Queries the API and updates the UI with the results 537 * 538 * Updates: 539 * * Timeline Chart 540 * * Email (Donut) Chart 541 * * Map Bubbles 542 * * Datatables 543 */ 544 function poll() { 545 api.campaignId.results(campaign.id) 546 .success(function (c) { 547 campaign = c 548 /* Update the timeline */ 549 var timeline_series_data = [] 550 $.each(campaign.timeline, function (i, event) { 551 var event_date = moment.utc(event.time).local() 552 timeline_series_data.push({ 553 email: event.email, 554 x: event_date.valueOf(), 555 y: 1 556 }) 557 }) 558 var timeline_series_data = [] 559 $.each(campaign.timeline, function (i, event) { 560 var event_date = moment.utc(event.time).local() 561 timeline_series_data.push({ 562 email: event.email, 563 message: event.message, 564 x: event_date.valueOf(), 565 y: 1, 566 marker: { 567 fillColor: statuses[event.message].color 568 } 569 }) 570 }) 571 var timeline_chart = $("#timeline_chart").highcharts() 572 timeline_chart.series[0].update({ 573 data: timeline_series_data 574 }) 575 /* Update the results donut chart */ 576 var email_series_data = {} 577 // Load the initial data 578 Object.keys(statusMapping).forEach(function (k) { 579 email_series_data[k] = 0 580 }); 581 $.each(campaign.results, function (i, result) { 582 email_series_data[result.status]++; 583 if (result.reported) { 584 email_series_data['Email Reported']++ 585 } 586 // Backfill status values 587 var step = progressListing.indexOf(result.status) 588 for (var i = 0; i < step; i++) { 589 email_series_data[progressListing[i]]++ 590 } 591 }) 592 $.each(email_series_data, function (status, count) { 593 var email_data = [] 594 if (!(status in statusMapping)) { 595 return true 596 } 597 email_data.push({ 598 name: status, 599 y: count 600 }) 601 email_data.push({ 602 name: '', 603 y: campaign.results.length - count 604 }) 605 var chart = $("#" + statusMapping[status] + "_chart").highcharts() 606 chart.series[0].update({ 607 data: email_data 608 }) 609 }) 610 611 /* Update the datatable */ 612 resultsTable = $("#resultsTable").DataTable() 613 resultsTable.rows().every(function (i, tableLoop, rowLoop) { 614 var row = this.row(i) 615 var rowData = row.data() 616 var rid = rowData[0] 617 $.each(campaign.results, function (j, result) { 618 if (result.id == rid) { 619 rowData[8] = moment(result.send_date).format('MMMM Do YYYY, h:mm:ss a') 620 rowData[7] = result.reported 621 rowData[6] = result.status 622 resultsTable.row(i).data(rowData) 623 if (row.child.isShown()) { 624 $(row.node()).find("#caret").removeClass("fa-caret-right") 625 $(row.node()).find("#caret").addClass("fa-caret-down") 626 row.child(renderTimeline(row.data())) 627 } 628 return false 629 } 630 }) 631 }) 632 resultsTable.draw(false) 633 /* Update the map information */ 634 updateMap(campaign.results) 635 $('[data-toggle="tooltip"]').tooltip() 636 $("#refresh_message").hide() 637 $("#refresh_btn").show() 638 }) 639 } 640 641 function load() { 642 campaign.id = window.location.pathname.split('/').slice(-1)[0] 643 var use_map = JSON.parse(localStorage.getItem('gophish.use_map')) 644 api.campaignId.results(campaign.id) 645 .success(function (c) { 646 campaign = c 647 if (campaign) { 648 $("title").text(c.name + " - Gophish") 649 $("#loading").hide() 650 $("#campaignResults").show() 651 // Set the title 652 $("#page-title").text("Results for " + c.name) 653 if (c.status == "Completed") { 654 $('#complete_button')[0].disabled = true; 655 $('#complete_button').text('Completed!'); 656 doPoll = false; 657 } 658 // Setup viewing the details of a result 659 $("#resultsTable").on("click", ".timeline-event-details", function () { 660 // Show the parameters 661 payloadResults = $(this).parent().find(".timeline-event-results") 662 if (payloadResults.is(":visible")) { 663 $(this).find("i").removeClass("fa-caret-down") 664 $(this).find("i").addClass("fa-caret-right") 665 payloadResults.hide() 666 } else { 667 $(this).find("i").removeClass("fa-caret-right") 668 $(this).find("i").addClass("fa-caret-down") 669 payloadResults.show() 670 } 671 }) 672 // Setup the results table 673 resultsTable = $("#resultsTable").DataTable({ 674 destroy: true, 675 "order": [ 676 [2, "asc"] 677 ], 678 columnDefs: [{ 679 orderable: false, 680 targets: "no-sort" 681 }, { 682 className: "details-control", 683 "targets": [1] 684 }, { 685 "visible": false, 686 "targets": [0, 8] 687 }, 688 { 689 "render": function (data, type, row) { 690 return createStatusLabel(data, row[8]) 691 }, 692 "targets": [6] 693 }, 694 { 695 className: "text-center", 696 "render": function (reported, type, row) { 697 if (reported) { 698 return "<i class='fa fa-check-circle text-center text-success'></i>" 699 } else { 700 return "<i class='fa fa-times-circle text-center text-danger'></i>" 701 } 702 }, 703 "targets": [7] 704 } 705 ] 706 }); 707 resultsTable.clear(); 708 var email_series_data = {} 709 var timeline_series_data = [] 710 Object.keys(statusMapping).forEach(function (k) { 711 email_series_data[k] = 0 712 }); 713 $.each(campaign.results, function (i, result) { 714 resultsTable.row.add([ 715 result.id, 716 "<i id=\"caret\" class=\"fa fa-caret-right\"></i>", 717 escapeHtml(result.first_name) || "", 718 escapeHtml(result.last_name) || "", 719 escapeHtml(result.email) || "", 720 escapeHtml(result.position) || "", 721 result.status, 722 result.reported, 723 moment(result.send_date).format('MMMM Do YYYY, h:mm:ss a') 724 ]) 725 email_series_data[result.status]++; 726 if (result.reported) { 727 email_series_data['Email Reported']++ 728 } 729 // Backfill status values 730 var step = progressListing.indexOf(result.status) 731 for (var i = 0; i < step; i++) { 732 email_series_data[progressListing[i]]++ 733 } 734 }) 735 resultsTable.draw(); 736 // Setup tooltips 737 $('[data-toggle="tooltip"]').tooltip() 738 // Setup the individual timelines 739 $('#resultsTable tbody').on('click', 'td.details-control', function () { 740 var tr = $(this).closest('tr'); 741 var row = resultsTable.row(tr); 742 if (row.child.isShown()) { 743 // This row is already open - close it 744 row.child.hide(); 745 tr.removeClass('shown'); 746 $(this).find("i").removeClass("fa-caret-down") 747 $(this).find("i").addClass("fa-caret-right") 748 } else { 749 // Open this row 750 $(this).find("i").removeClass("fa-caret-right") 751 $(this).find("i").addClass("fa-caret-down") 752 row.child(renderTimeline(row.data())).show(); 753 tr.addClass('shown'); 754 } 755 }); 756 // Setup the graphs 757 $.each(campaign.timeline, function (i, event) { 758 if (event.message == "Campaign Created") { 759 return true 760 } 761 var event_date = moment.utc(event.time).local() 762 timeline_series_data.push({ 763 email: event.email, 764 message: event.message, 765 x: event_date.valueOf(), 766 y: 1, 767 marker: { 768 fillColor: statuses[event.message].color 769 } 770 }) 771 }) 772 renderTimelineChart({ 773 data: timeline_series_data 774 }) 775 $.each(email_series_data, function (status, count) { 776 var email_data = [] 777 if (!(status in statusMapping)) { 778 return true 779 } 780 email_data.push({ 781 name: status, 782 y: count 783 }) 784 email_data.push({ 785 name: '', 786 y: campaign.results.length - count 787 }) 788 var chart = renderPieChart({ 789 elemId: statusMapping[status] + '_chart', 790 title: status, 791 name: status, 792 data: email_data, 793 colors: [statuses[status].color, '#dddddd'] 794 }) 795 }) 796 797 if (use_map) { 798 $("#resultsMapContainer").show() 799 map = new Datamap({ 800 element: document.getElementById("resultsMap"), 801 responsive: true, 802 fills: { 803 defaultFill: "#ffffff", 804 point: "#283F50" 805 }, 806 geographyConfig: { 807 highlightFillColor: "#1abc9c", 808 borderColor: "#283F50" 809 }, 810 bubblesConfig: { 811 borderColor: "#283F50" 812 } 813 }); 814 } 815 updateMap(campaign.results) 816 } 817 }) 818 .error(function () { 819 $("#loading").hide() 820 errorFlash(" Campaign not found!") 821 }) 822 } 823 824 var setRefresh 825 826 function refresh() { 827 if (!doPoll) { 828 return; 829 } 830 $("#refresh_message").show() 831 $("#refresh_btn").hide() 832 poll() 833 clearTimeout(setRefresh) 834 setRefresh = setTimeout(refresh, 60000) 835 }; 836 837 838 839 $(document).ready(function () { 840 Highcharts.setOptions({ 841 global: { 842 useUTC: false 843 } 844 }) 845 load(); 846 847 // Start the polling loop 848 setRefresh = setTimeout(refresh, 60000) 849 })