<!--
Copyright Yahoo Inc.
SPDX-License-Identifier: Apache-2.0
-->
<template>

  <div class="spiview-page">

    <ArkimeCollapsible>
      <span class="fixed-header">
        <!-- search navbar -->
        <arkime-search
          @changeSearch="changeSearch"
          :num-matching-sessions="filtered">
        </arkime-search> <!-- /search navbar -->

        <!-- info navbar -->
        <form class="info-nav">
          <div v-if="!dataLoading">
            <!-- field config save button -->
            <b-dropdown
              size="sm"
              no-caret
              class="field-config-menu"
              toggle-class="rounded"
              variant="theme-secondary">
              <template slot="button-content">
                <span class="fa fa-columns"
                  v-b-tooltip.hover
                  title="Save or load custom visible field configurations">
                </span>
              </template>
              <b-dropdown-header>
                <div class="input-group input-group-sm">
                  <input type="text"
                    maxlength="30"
                    class="form-control"
                    @input="debounceNewFieldConfigName"
                    v-model.lazy="newFieldConfigName"
                    placeholder="Enter new field configuration name"
                    @keydown.enter.stop.prevent="saveFieldConfiguration"
                  />
                  <div class="input-group-append">
                    <button type="button"
                      class="btn btn-theme-secondary"
                      :disabled="!newFieldConfigName"
                      @click="saveFieldConfiguration"
                      v-b-tooltip.hover.right
                      title="Save this custom spiview field configuration">
                      <span class="fa fa-save">
                      </span>
                    </button>
                  </div>
                </div>
              </b-dropdown-header>
              <b-dropdown-divider>
              </b-dropdown-divider>
              <transition-group name="list">
                <b-dropdown-item
                  key="config-default"
                  v-b-tooltip.hover.right
                  @click.stop.prevent="loadFieldConfiguration(-1)"
                  title="Reset visible fields to the default fields: Dst IP, Src IP, and Protocols">
                  Arkime Default
                </b-dropdown-item>
                <template v-if="fieldConfigs">
                  <b-dropdown-item
                    v-for="(config, key) in fieldConfigs"
                    :key="config.name"
                    @click.self.stop.prevent="loadFieldConfiguration(key)">
                    <button class="btn btn-xs btn-danger pull-right ml-1"
                      type="button"
                      @click.stop.prevent="deleteFieldConfiguration(config.name, key)">
                      <span class="fa fa-trash-o">
                      </span>
                    </button>
                    <button class="btn btn-xs btn-warning pull-right"
                      type="button"
                      v-b-tooltip.hover.right
                      title="Update this field configuration with the currently visible fields"
                      @click.stop.prevent="updateFieldConfiguration(config.name, key)">
                      <span class="fa fa-save">
                      </span>
                    </button>
                    {{ config.name }}
                  </b-dropdown-item>
                </template>
                <b-dropdown-item
                  v-if="fieldConfigError"
                  key="config-error">
                  <span class="text-danger">
                    <span class="fa fa-exclamation-triangle" />
                    {{ fieldConfigError }}
                  </span>
                </b-dropdown-item>
                <b-dropdown-item
                  v-if="fieldConfigSuccess"
                  key="config-success">
                  <span class="text-success">
                    <span class="fa fa-check" />
                    {{ fieldConfigSuccess }}
                  </span>
                </b-dropdown-item>
              </transition-group>
            </b-dropdown> <!-- /field config save button -->
            <small>
              <strong class="ml-2 text-theme-accent"
                v-if="!error && filtered !== undefined">
                Showing {{ filtered | commaString }} entries filtered from
                {{ total | commaString }} total entries
              </strong>
            </small>
          </div>
          <div v-if="dataLoading"
            class="info-nav-loading">
            <span class="fa fa-spinner fa-lg fa-spin">
            </span>&nbsp;
            <em>
              Loading SPI data
            </em>
            <button type="button"
              :class="{'disabled-aggregations':disabledAggregations}"
              class="btn btn-warning btn-sm pull-right cancel-btn"
              @click="cancelLoading">
              <span class="fa fa-ban">
              </span>&nbsp;
              cancel
            </button>
          </div>
        </form> <!-- /info navbar -->
      </span>

      <!-- warning navbar -->
      <form v-if="staleData && !dataLoading"
        class="loading-nav">
        <div class="form-inline text-theme-accent">
          <span class="fa fa-exclamation-triangle">
          </span>&nbsp;
          <strong>Warning:</strong>
          much of the data below does not match your query
          because the request was canceled.
          <em>
            Click search to reissue your query.
          </em>
          <span class="fa fa-close pull-right cursor-pointer"
            @click="staleData = false">
          </span>
        </div>
      </form> <!-- /warning navbar -->
    </ArkimeCollapsible>

    <!-- visualizations -->
    <arkime-visualizations
      v-if="mapData && graphData && showToolBars"
      :primary="true"
      :map-data="mapData"
      :graph-data="graphData"
      @fetchMapData="fetchVizData"
      :timelineDataFilters="timelineDataFilters">
    </arkime-visualizations> <!-- /visualizations -->

    <div class="spiview-content mr-1 ml-1">

      <!-- page error -->
      <arkime-error
        v-if="error"
        :message="error"
        class="mt-5 mb-5">
      </arkime-error> <!-- /page error -->

      <!-- spiview panels -->
      <div role="tablist">
        <b-card no-body
          class="mb-1"
          v-for="category in categoryList"
          :key="category">
          <b-card-header
            header-tag="header"
            class="pt-1 pb-1 pl-2 pr-2 cursor-pointer"
            v-b-toggle="category"
            @click="toggleCategory(category)">
            <strong class="category-title">
              {{ category }}
            </strong>
            <span class="when-opened mt-2 fa fa-minus pull-right">
            </span>
            <span class="when-closed mt-2 fa fa-plus pull-right">
            </span>
            <span v-if="categoryObjects[category].loading"
              class="fa fa-spin fa-spinner fa-lg pull-right mt-1 mr-1">
            </span>
            <span v-if="!categoryObjects[category].loading">
              <button class="btn btn-theme-secondary btn-sm pull-right mr-1"
                title="Load all of the values in this category"
                @click.stop.prevent="toggleAllValues(category, true)">
                Load All
              </button>
              <button class="btn btn-theme-primary btn-sm pull-right mr-1"
                title="Unload all of the values in this category"
                @click.stop.prevent="toggleAllValues(category, false)">
                Unload All
              </button>
            </span>
            <span v-if="categoryObjects[category].protocols"
              class="pull-right">
              <span v-for="(value, key) in categoryObjects[category].protocols"
                :key="key"
                @click.stop
                class="protocol-value">
                <strong>
                  <arkime-session-field
                    :field="{dbField:'ipProtocol', exp:'protocols', type:'lotermfield', group:'general', transform:'ipProtocolLookup'}"
                    :expr="'protocols'"
                    :value="key"
                    :pull-left="true"
                    :parse="false"
                    :session-btn="true">
                  </arkime-session-field>
                </strong>
                <sup>({{ value | commaString }})</sup>
              </span>
            </span>
          </b-card-header>
          <b-collapse :visible="categoryObjects[category].isopen"
            :id="category">
            <b-card-body>
              <!-- toggle buttons -->
              <div class="card-text btn-drawer mt-1 mr-1 ml-1"
                :ref="category + '-btn-drawer'">
                <div class="btn-container">
                  <form class="form-inline">
                    <transition-group name="fade-list">
                      <input type="text"
                        class="form-control form-control-sm mr-1 mb-1 no-transition"
                        placeholder="Search for fields to display in this category"
                        @input="updateFilteredFields(category, $event.target.value)"
                        key="input"
                      />
                      <span class="small"
                        key="no-results"
                        v-if="!categoryObjects[category].fields.length">
                        <span class="fa fa-fw fa-exclamation-circle">
                        </span>&nbsp;
                        No results match your query
                      </span>
                      <template v-if="categoryObjects[category].spi">
                        <span class="small"
                          key="no-fields"
                          v-if="categoryObjects[category].filteredFields && !categoryObjects[category].filteredFields.length">
                          <span class="fa fa-fw fa-exclamation-circle">
                          </span>&nbsp;
                          No fields match your query
                        </span>
                        <span v-for="field in categoryObjects[category].filteredFields"
                          :key="field.dbField">
                          <b-dropdown split
                            size="sm"
                            variant="default"
                            class="mr-1 mb-1 field-dropdown"
                            :text="field.friendlyName"
                            v-b-tooltip.hover
                            :title="field.help"
                            boundary="viewport"
                            @click="toggleSpiData(field, true, true)"
                            :class="{'active':categoryObjects[category].spi[field.dbField] && categoryObjects[category].spi[field.dbField].active}">
                            <b-dropdown-item
                              @click="exportUnique(field.dbField, 0)">
                              Export Unique {{ field.friendlyName }}
                            </b-dropdown-item>
                            <b-dropdown-item
                              @click="exportUnique(field.dbField, 1)">
                              Export Unique {{ field.friendlyName }} with counts
                            </b-dropdown-item>
                            <b-dropdown-item
                              @click="openSpiGraph(field.dbField)">
                              Open {{ field.friendlyName }} SPI Graph
                            </b-dropdown-item>
                            <field-actions
                              :separator="true"
                              :expr="field.exp"
                            />
                          </b-dropdown>
                        </span>
                      </template>
                    </transition-group>
                  </form>
                </div>
                <div class="text-center btn-drawer-toggle cursor-pointer"
                  @click="toggleBtnDrawer(category + '-btn-drawer')">
                  <span class="when-opened mt-2 fa fa-angle-double-up">
                  </span>
                  <span class="when-closed mt-2 fa fa-angle-double-down">
                  </span>
                </div>
              </div> <!-- toggle buttons -->
              <div v-if="categoryObjects[category].spi"
                class="mt-3">
                <!-- spiview field -->
                <transition-group :name="spiviewFieldTransition">
                  <template v-for="(value, key) in categoryObjects[category].spi">
                    <div :key="key"
                      v-if="value.active"
                      class="spi-buckets pr-1 pl-1 pb-1">
                      <!-- spiview field label button -->
                      <b-dropdown
                        size="sm"
                        variant="default"
                        class="field-dropdown"
                        :text="value.field.friendlyName">
                        <b-dropdown-item
                          @click="toggleSpiData(value.field, true, true)">
                          Hide {{ value.field.friendlyName }}
                        </b-dropdown-item>
                        <b-dropdown-item
                          @click="exportUnique(value.field.dbField, 0)">
                          Export Unique {{ value.field.friendlyName }}
                        </b-dropdown-item>
                        <b-dropdown-item
                          @click="exportUnique(value.field.dbField, 1)">
                          Export Unique {{ value.field.friendlyName }} with counts
                        </b-dropdown-item>
                        <b-dropdown-item
                          @click="openSpiGraph(value.field.dbField)">
                          Open {{ value.field.friendlyName }} SPI Graph
                        </b-dropdown-item>
                        <b-dropdown-item
                          @click="pivot(value)">
                          Pivot on {{ value.field.friendlyName }}
                        </b-dropdown-item>
                        <field-actions
                          :separator="true"
                          :expr="value.field.exp"
                        />
                      </b-dropdown> <!-- spiview field label button -->
                      <!-- spiview field data -->
                      <span v-if="value && value.value && value.value.buckets">
                        <span v-for="bucket in value.value.buckets"
                          :key="bucket.key">
                          <span v-if="bucket.key || bucket.key === 0"
                            class="small spi-bucket mr-1 no-wrap">
                            <arkime-session-field
                              :field="value.field"
                              :value="bucket.key"
                              :expr="value.field.exp"
                              :parse="true"
                              :pull-left="true"
                              :session-btn="true">
                            </arkime-session-field>
                            <sup>({{ bucket.doc_count | commaString }})</sup>
                          </span>
                        </span>
                      </span>
                      <!-- /spiview field data -->
                      <!-- spiview no data -->
                      <em class="small"
                        v-if="!value.loading && !value.error && (!value.value || !value.value.buckets.length)">
                        No data for this field
                        <span v-if="canceled && !value.value">
                          (request was canceled)
                        </span>
                      </em> <!-- /spiview no data -->
                      <!-- spiview field more/less values -->
                      <a v-if="value.count && value.count > 100 && value.value && value.value.buckets && value.value.buckets.length && value.value.buckets.length >= 100"
                         @click="showValues(value, false, value.value.buckets.length)"
                         class="btn btn-link btn-xs"
                         style="text-decoration:none;">
                        ...less
                      </a>
                      <a v-if="value && value.value && value.value.doc_count_error_upper_bound < value.value.sum_other_doc_count"
                        @click="showValues(value, true, value.value.buckets.length)"
                        class="btn btn-link btn-xs"
                        style="text-decoration:none;">
                        more...
                      </a> <!-- /spiview field more/less values -->
                      <!-- spiview field loading -->
                      <span v-if="value.loading"
                        class="fa fa-spinner fa-spin">
                      </span> <!-- /spiview field loading -->
                      <!-- spiview field error -->
                      <span v-if="value.error"
                        class="text-danger ml-2">
                        <span class="fa fa-exclamation-triangle">
                        </span>&nbsp;
                        {{ value.error }}
                      </span> <!-- /spiview field error -->
                    </div>
                  </template> <!-- /spiview field -->
                </transition-group>
              </div>
            </b-card-body>
          </b-collapse>
        </b-card>
      </div> <!-- /spiview panels -->

    </div>

  </div>

</template>

<script>
import Vue from 'vue';

import SessionsService from '../sessions/SessionsService';
import ConfigService from '../utils/ConfigService';
import FieldService from '../search/FieldService';
import UserService from '../users/UserService';

import ArkimeError from '../utils/Error';
import ArkimeSearch from '../search/Search';
import ArkimeVisualizations from '../visualizations/Visualizations';
import ArkimeCollapsible from '../utils/CollapsibleWrapper';
import FieldActions from '../sessions/FieldActions';

// import utils
import Utils from '../utils/utils';

const defaultSpi = 'destination.ip:100,protocol:100,source.ip:100';

let newQuery = true;
let openedCategories = false;

// object to store loading categories and how many fields are loading within
let categoryLoadingCounts = {};

// save currently executing promise
let pendingPromise;

let timeout;
let inputTimeout;
let newConfigTimeout;

export default {
  name: 'Spiview',
  components: {
    ArkimeError,
    ArkimeSearch,
    ArkimeVisualizations,
    ArkimeCollapsible,
    FieldActions
  },
  data: function () {
    return {
      error: '',
      canceled: false,
      loading: true,
      dataLoading: true,
      loadingVisualizations: true,
      staleData: undefined,
      filtered: 0,
      fieldConfigs: undefined,
      graphData: undefined,
      mapData: undefined,
      categoryList: [],
      categoryObjects: {},
      spiQuery: this.$route.query.spi,
      // field config vars
      newFieldConfigName: '',
      fieldConfigError: '',
      fieldConfigSuccess: '',
      spiviewFieldTransition: ''
    };
  },
  computed: {
    query: function () {
      return {
        facets: 1,
        date: this.$store.state.timeRange,
        startTime: this.$store.state.time.startTime,
        stopTime: this.$store.state.time.stopTime,
        bounding: this.$route.query.bounding || 'last',
        interval: this.$route.query.interval || 'auto',
        view: this.$route.query.view || undefined,
        expression: this.$store.state.expression || undefined,
        cluster: this.$route.query.cluster || undefined
      };
    },
    user: function () {
      return this.$store.state.user;
    },
    timelineDataFilters: function () {
      const filters = this.user.settings.timelineDataFilters;
      return filters.map(i => FieldService.getField(i));
    },
    showToolBars: function () {
      return this.$store.state.showToolBars;
    },
    fields: function () {
      return this.$store.state.fieldsArr;
    },
    disabledAggregations: function () {
      return this.$store.state.disabledAggregations;
    }
  },
  watch: {
    '$store.state.fetchGraphData': function (value) {
      if (value) { this.fetchGraphData(); }
    }
  },
  mounted: function () {
    if (!this.spiQuery) { // there's no list of fields in the url params
      // so get what's saved in the db
      UserService.getPageConfig('spiview').then((response) => {
        this.fieldConfigs = response.fieldConfigs;
        this.spiQuery = response.spiviewFields.visibleFields || defaultSpi;
        this.issueQueries(true);
      }).catch((error) => {
        this.spiQuery = defaultSpi;
        this.issueQueries();
      });
    } else {
      this.issueQueries();
    }

    ConfigService.getFieldActions();
  },
  methods: {
    /* exposed page functions ---------------------------------------------- */
    /**
     * Filters field butttons by the search filter
     * @param {string} categoryName The name (key) of the category to filter fields
     * @param {string} searchFilter The string to search for in fields
     */
    updateFilteredFields: function (categoryName, searchFilter) {
      if (inputTimeout) { clearTimeout(inputTimeout); }

      inputTimeout = setTimeout(() => {
        const category = this.categoryObjects[categoryName];
        let fields = category.fields;

        fields = this.$options.filters.searchFields(searchFilter, fields);
        fields = this.sortFields(fields);

        Vue.set(this.categoryObjects[categoryName], 'filteredFields', fields);
      }, 400);
    },
    /**
     * Toggles the view of field spi data by updating the active state
     * or fetching new spi data as necessary
     * Also updates the spi query parameter in the url
     * @param {object} field      The field to get spi data for
     * @param {bool} issueQuery   Whether to issue query for the data
     * @param {bool} saveFields   Whether to save the visible fields
     * @returns {string} spiQuery The query string for the toggled on fields
     *                            e.g. 'lp:200,fp:100'
     */
    toggleSpiData: function (field, issueQuery, saveFields) {
      Vue.set(field, 'active', !field.active);

      let spiData;
      if (this.categoryObjects[field.group].spi) {
        spiData = this.categoryObjects[field.group].spi[field.dbField];
      }

      let addToQuery = false;
      let spiQuery = '';

      if (spiData) { // spi data exists, so we need to toggle active state
        Vue.set(spiData, 'active', !spiData.active);
        addToQuery = spiData.active;
        // if spiData was not populated with a value and it's now active
        // we need to show get the spi data from the server
        if (!spiData.value && spiData.active) { this.getSingleSpiData(field); }
      } else { // spi data doesn't exist, so fetch it
        addToQuery = true;
        if (issueQuery) { this.getSingleSpiData(field); }
      }

      // update spi query parameter by adding or removing field id
      if (addToQuery) {
        if (this.spiQuery && this.spiQuery !== '') {
          this.spiQuery += ',';
        }
        this.spiQuery += spiQuery += `${field.dbField}:100`;
      } else {
        const spiParamsArray = this.spiQuery.split(',');
        for (let i = 0, len = spiParamsArray.length; i < len; ++i) {
          if (spiParamsArray[i].includes(field.dbField)) {
            spiParamsArray.splice(i, 1);
            break;
          }
        }
        this.spiQuery = spiParamsArray.join(',');
      }

      // save field state if method was invoked from field button click
      if (saveFields) { this.saveFieldState(); }

      return spiQuery;
    },
    /* Cancels the loading of all server requests */
    cancelLoading: function () {
      if (pendingPromise) {
        pendingPromise.source.cancel();
        pendingPromise = null;
      }

      this.canceled = true; // indicate cancellation for future requests
      this.dataLoading = false;
      this.staleData = newQuery;

      // set loading to false for all categories and fields
      for (const key in this.categoryObjects) {
        const cat = this.categoryObjects[key];
        cat.loading = false;
        if (cat.spi) {
          for (const field in cat.spi) {
            cat.spi[field].loading = false;
          }
        }
      }
    },
    /**
     * Saves the open categories to spiview-collapsible localStorage
     * @param {string} catName The name of the category
     */
    toggleCategory: function (catName) {
      this.categoryObjects[catName].isopen = !this.categoryObjects[catName].isopen;

      if (localStorage) {
        if (localStorage['spiview-collapsible']) {
          let visiblePanels = localStorage['spiview-collapsible'];
          if (!this.categoryObjects[catName].isopen) {
            const split = visiblePanels.split(',');
            for (let i = 0, len = split.length; i < len; ++i) {
              if (split[i].includes(catName)) {
                split.splice(i, 1);
                break;
              }
            }
            visiblePanels = split.join(',');
          } else if (!visiblePanels.includes(catName)) {
            if (visiblePanels !== '') { visiblePanels += ','; }
            visiblePanels += `${catName}`;
          }

          localStorage['spiview-collapsible'] = visiblePanels;
        } else {
          localStorage['spiview-collapsible'] = catName;
        }
      }
    },
    /**
     * Shows more values for a specific field
     * @param {object} value - The value to get more spi data for
     * @param {bool} more - Whether to display more or less values
     * @param {number} currLen - The current length of the field values
     */
    showValues: function (value, more, currLen) {
      let count;
      const field = value.field;
      if (this.spiQuery.includes(field.dbField) || this.spiQuery.includes(field.dbField2)) {
        // make sure field is in the spi query parameter
        const spiParamsArray = this.spiQuery.split(',');
        for (let i = 0, len = spiParamsArray.length; i < len; ++i) {
          if (spiParamsArray[i].includes(field.dbField) || spiParamsArray[i].includes(field.dbField2)) {
            const spiParam = spiParamsArray[i].split(':');
            if (more) {
              // get the max of the requested length and the actual results length
              // since the results could have been filtered and there are many more results
              spiParam[1] = Math.max(parseInt(spiParam[1]), Math.round(currLen / 100) * 100);
              count = spiParam[1] = spiParam[1] + 100;
            } else {
              // get the min of the requested length and the actual results length
              // since the results could have been filtered and there are many fewer resutls
              spiParam[1] = Math.min(parseInt(spiParam[1]), Math.round(currLen / 100) * 100);
              count = spiParam[1] = spiParam[1] - 100;
              count = Math.max(count, 100); // min is 100
            }
            spiParamsArray[i] = spiParam.join(':');
            break;
          }
        }
        this.spiQuery = spiParamsArray.join(',');
      }

      value.count = count;

      this.saveFieldState();

      this.getSingleSpiData(field, count);
    },
    /**
     * Show/hide all values for a category
     * @param {string} categoryName The name of the category to toggle values for
     * @param {bool} load           Whether to load (or unload) all values
     */
    toggleAllValues: function (categoryName, load) {
      let query = '';
      const category = this.categoryObjects[categoryName];

      for (let i = 0, len = category.fields.length; i < len; ++i) {
        const field = category.fields[i];
        if (category.spi && category.spi[field.dbField]) {
          const spiData = category.spi[field.dbField];
          if ((spiData.active && !load) ||
             (!spiData.active && load)) {
            // the spi data for this field is already visible and we don't want
            // it to be, or it's NOT visible and we want it to be
            this.toggleSpiData(field);
          }
        } else if (load) { // spi data doesn't exist in the category
          if (query) { query += ','; }
          query += this.toggleSpiData(field);
        }
      }

      if (load && query) { this.getSpiData(query); }

      this.saveFieldState();
    },
    toggleBtnDrawer: function (ref) {
      $(this.$refs[ref]).toggleClass('expanded');
    },
    /**
     * Opens a new browser tab containing all the unique values for a given field
     * @param {string} exp  The field id to display unique values for
     * @param {int} counts  Whether to display the unique values with counts (1 or 0)
     */
    exportUnique: function (exp, counts) {
      SessionsService.exportUniqueValues(exp, counts, this.$route.query);
    },
    /**
     * Opens the spi graph page in a new browser tab
     * @param {string} fieldID The field id (dbField) to display spi graph data for
     */
    openSpiGraph: function (fieldID) {
      SessionsService.openSpiGraph(fieldID, this.$route.query);
    },
    /**
     * Opens a new sessions page with a list of values as the search expression
     * @param {object} bucket The bucket of data
     */
    pivot: function (bucket) {
      const fieldExp = bucket.field.exp;

      const values = [];
      for (const val of bucket.value.buckets) {
        values.push(val.key);
      }

      const valueStr = `[${values.join(',')}]`;
      const expression = this.$options.filters.buildExpression(fieldExp, valueStr, '==');

      const routeData = this.$router.resolve({
        path: '/sessions',
        query: {
          ...this.$route.query,
          expression
        }
      });

      window.open(routeData.href, '_blank');
    },
    /* debounces input to new field config to speed it up */
    debounceNewFieldConfigName: function (e) {
      if (newConfigTimeout) { clearTimeout(newConfigTimeout); }
      newConfigTimeout = setTimeout(() => {
        this.newFieldConfigName = e.target.value;
      }, 400);
    },
    /* Saves a custom spiview fields configuration */
    saveFieldConfiguration: function () {
      if (!this.newFieldConfigName) {
        this.fieldConfigError = 'You must name your new spiview field configuration';
        return;
      }

      const data = {
        name: this.newFieldConfigName,
        fields: this.spiQuery
      };

      UserService.createLayout('spiview', data).then((response) => {
        data.name = response.name;

        if (!this.fieldConfigs) { this.fieldConfigs = []; }
        this.fieldConfigs.push(data);

        this.newFieldConfigName = null;
        this.fieldConfigsOpen = false;
        this.fieldConfigError = false;
      }).catch((error) => {
        this.fieldConfigError = error.text;
      });
    },
    /**
     * Loads a previously saved custom spiview fields configuration and
     * reloads the fields
     * If no index is given, loads the default spiview fields
     * @param {int} index The index in the array of the spiview fields configs to load
     */
    loadFieldConfiguration: function (index) {
      if (index !== -1) {
        this.spiQuery = this.fieldConfigs[index].fields;
      } else {
        this.spiQuery = defaultSpi;
      }

      this.saveFieldState();
      this.restart();
    },
    /**
     * Deletes a previously saved custom spiview fields configuration
     * @param {string} spiName  The name of the spiview fields config to remove
     * @param {int} index       The index in the array of the spiview fields config to remove
     */
    deleteFieldConfiguration: function (spiName, index) {
      UserService.deleteLayout('spiview', spiName).then(() => {
        this.fieldConfigs.splice(index, 1);
        this.fieldConfigError = false;
      }).catch((error) => {
        this.fieldConfigError = error.text;
      });
    },
    /**
     * Updates a previously saved custom spiview fields configuration
     * @param {string} spiName  The name of the spiview fields config to update
     * @param {int} index       The index in the array of the spiview fields config to update
     */
    updateFieldConfiguration: function (spiName, index) {
      const data = {
        name: spiName,
        fields: this.spiQuery
      };

      UserService.updateLayout('spiview', data).then((response) => {
        this.fieldConfigs[index] = data;
        this.fieldConfigError = false;
        this.fieldConfigSuccess = response.text;
        setTimeout(() => { this.fieldConfigSuccess = ''; }, 5000);
      }).catch((error) => {
        this.fieldConfigError = error.text;
      });
    },
    /* event functions ----------------------------------------------------- */
    changeSearch: function () {
      newQuery = true;

      if (pendingPromise) { // if there's already a req (or series of reqs)
        this.cancelLoading(); // cancel any current requests
        timeout = setTimeout(() => { // wait for promise abort to complete
          this.getSpiData(this.spiQuery);
        }, 100);
      } else {
        this.getSpiData(this.spiQuery);
      }
    },
    fetchVizData: function (graphData) {
      const spiParamsArray = this.spiQuery.split(',');
      let field = spiParamsArray[0].split(':')[0];
      if (!field) { field = 'destination.ip'; }

      const query = this.constructQuery(field, 100);
      query.facets = 1; // Force facets for map/graph data

      this.get(query).promise
        .then((response) => {
          if (response.error) { this.error = response.error; }
          this.mapData = response.map;
          if (graphData) {
            this.graphData = response.graph;
          }
        })
        .catch((error) => {
          this.error = error.text;
        });
    },
    fetchGraphData: function () {
      this.mapData = undefined;
      this.graphData = undefined;

      this.fetchVizData(true);
    },
    /* helper functions ---------------------------------------------------- */
    constructQuery: function (dbField, count) {
      return {
        facets: newQuery ? '1' : '0', // Only get facets for initial query for performance
        spi: `${dbField}:${count}`,
        date: this.query.date,
        startTime: this.query.startTime,
        stopTime: this.query.stopTime,
        expression: this.query.expression,
        bounding: this.query.bounding,
        interval: this.query.interval,
        view: this.query.view,
        cluster: this.query.cluster
      };
    },
    get: function (query) {
      const source = Vue.axios.CancelToken.source();

      Utils.setFacetsQuery(query, 'spiview');
      // need to reset this because ^ sets it to 1 if forced aggs are on
      query.facets = newQuery ? '1' : '0';

      // set whether map is open on the spiview page
      if (localStorage.getItem('spiview-open-map') === 'true') {
        query.map = true;
      }
      // set whether visualizations are open on the spiview page
      if (localStorage.getItem('spiview-hide-viz') === 'true') {
        query.facets = 0;
      }

      const promise = new Promise((resolve, reject) => {
        const options = {
          method: 'POST',
          params: query,
          cancelToken: source.token,
          url: 'api/spiview'
        };

        Vue.axios(options).then((response) => {
          if (response.data.bsqErr) {
            response.data.error = response.data.bsqErr;
          }

          resolve(response.data);
        }).catch((error) => {
          if (!Vue.axios.isCancel(error)) {
            reject(error);
          }
        });
      });

      return { promise, source };
    },
    issueQueries: function () {
      this.categorizeFields(); // IMPORTANT: kicks off initial query for spi data!
      if (!this.fieldConfigs) { this.getSpiviewFieldConfigs(); }
    },
    categorizeFields: function () {
      this.loading = false;
      this.error = false;
      this.categoryObjects = {};

      for (let i = 0, len = this.fields.length; i < len; ++i) {
        const field = this.fields[i];

        field.active = false;

        if (field.noFacet || field.regex ||
          (field.type && field.type.match(/textfield/))) {
          continue;
        }

        if (this.categoryObjects[field.group]) {
          // already created, just add a new field
          this.categoryObjects[field.group].fields.push(field);
        } else { // create it
          Vue.set(this.categoryObjects, field.group, {
            fields: [field],
            spi: {}
          });
        }
      }

      // sorted list of categories for the view
      this.categoryList = Object.keys(this.categoryObjects).sort();
      this.categoryList.splice(this.categoryList.indexOf('general'), 1);

      if (this.$constants.SPIVIEW_CATEGORY_ORDER) {
        const catOrder = this.$constants.SPIVIEW_CATEGORY_ORDER.split(',');
        for (let i = catOrder.length - 1; i >= 0; i--) {
          const cat = catOrder[i];
          if (this.categoryList.includes(cat)) {
            this.categoryList.splice(this.categoryList.indexOf(cat), 1);
            this.categoryList.unshift(cat);
          }
        }
      }

      // general always at the top
      this.categoryList.unshift('general');

      this.getSpiData(this.spiQuery); // IMPORTANT: queries for spi data!
    },
    getSpiData: function (spiQuery) {
      if (!spiQuery) { return; }

      if (!Utils.checkClusterSelection(this.query.cluster, this.$store.state.esCluster.availableCluster.active, this).valid) {
        pendingPromise = null;
        this.dataLoading = false;
        return;
      }

      // reset loading counts for categories
      categoryLoadingCounts = {};

      this.dataLoading = true;
      this.staleData = false;
      this.canceled = false;
      this.error = false;

      const spiParamsArray = spiQuery.split(',');

      const tasks = [];
      let category;

      // get each field from the spi query parameter and issue
      // a query for one field at a time
      for (let i = 0, len = spiParamsArray.length; i < len; ++i) {
        const param = spiParamsArray[i];
        const split = param.split(':');
        const fieldID = split[0];
        const count = Math.max(split[1], 100); // min is 100
        const field = FieldService.getField(fieldID);

        if (field) {
          category = this.setupCategory(this.categoryObjects, field);

          category.isopen = true; // open the category to display the field
          category.loading = true; // loading is set to false in getSingleSpiData

          // count the number of fields fetched for each category
          this.countCategoryFieldsLoading(category, true);

          const spiData = category.spi[field.dbField];

          Vue.set(field, 'active', true);
          Vue.set(spiData, 'active', true);
          Vue.set(spiData, 'error', false);
          Vue.set(spiData, 'loading', true);

          const promise = () => {
            return new Promise((resolve, reject) => {
              resolve(this.getSingleSpiData(field, count).promise);
            });
          };

          tasks.push(promise);
        }
      }

      if (!openedCategories) { this.openCategories(); }

      if (tasks.length) {
        // start processing tasks serially
        this.serial(tasks).then((response) => { // returns the last result in the series
          if (response && response.error) {
            this.error = response.error;
          }
          this.dataLoading = false;
          pendingPromise = null;
          this.spiviewFieldTransition = 'list';
        }).catch((error) => {
          this.error = error.text || error;
          this.dataLoading = false;
          pendingPromise = null;
          this.spiviewFieldTransition = 'list';
        });
      } else if (this.fields) {
        // if we couldn't figure out the fields to request,
        // request the default ones
        this.getSpiData(defaultSpi);
      }
    },
    /**
     * Gets spi data for the specified field and adds it to the category object
     * @param {object} field  The field to get spi data for
     * @param {int} count     The amount of spi data to query for
     */
    getSingleSpiData: function (field, count) {
      const category = this.setupCategory(this.categoryObjects, field);
      const spiData = category.spi[field.dbField];

      // don't continue if the active flag is defined and false
      if (spiData.active !== undefined && !spiData.active) { return; }

      if (!count) { count = 100; } // default amount of spi data to retrieve

      Vue.set(spiData, 'active', true);
      Vue.set(spiData, 'loading', true);
      Vue.set(spiData, 'error', false);

      const query = this.constructQuery(field.dbField, count);

      pendingPromise = this.get(query);

      pendingPromise.promise
        .then((response) => {
          this.countCategoryFieldsLoading(category, false);

          if (response.error) { spiData.error = response.error; }

          // only update the requested spi data
          Vue.set(spiData, 'loading', false);
          Vue.set(spiData, 'value', response.spi[field.dbField]);
          Vue.set(spiData, 'count', count);

          if (newQuery) { // this data comes back with every request
            // we should show it in the view ASAP (on first request)
            newQuery = false;
            this.mapData = response.map;
            this.graphData = response.graph;
            this.protocols = response.protocols;
            this.total = response.recordsTotal;
            this.filtered = response.recordsFiltered;
            this.loadingVisualizations = false;

            this.updateProtocols();
          }
        })
        .catch((error) => {
          this.countCategoryFieldsLoading(category, false);

          // display error for the requested spi data
          spiData.loading = false;
          spiData.error = error.text;
          this.loadingVisualizations = false;
        });

      return pendingPromise;
    },
    /* Gets the current user's custom spiview fields configurations */
    getSpiviewFieldConfigs: function () {
      UserService.getLayout('spiview').then((response) => {
        this.fieldConfigs = response;
      }).catch((error) => {
        this.fieldConfigError = error.text;
      });
    },
    /**
     * Chains sequential promises together
     * @param {object} tasks          List or map of tasks to complete
     * @returns {promise} prevPromise The previously executed promise
     */
    serial: function (tasks) {
      let prevPromise;

      for (const task of tasks) {
        if (!prevPromise) { // first task
          prevPromise = task();
        } else { // subsequent tasks
          prevPromise = prevPromise.then(task);
        }
      }

      return prevPromise;
    },
    /**
     * Adds and sorts filtered fields for a category
     * Sorts them by active state then friendlyName
     * @param {array} fields The array of fields to sort
     */
    sortFields: function (fields) {
      return fields.sort((a, b) => {
        const bool = (a.active === b.active) ? 0 : a.active ? -1 : 1;
        const str = a.friendlyName.localeCompare(b.friendlyName);

        return bool || str;
      });
    },
    /* opens categories that were opened in a previous session
       should only run once on page load */
    openCategories: function () {
      openedCategories = true;
      for (const key in this.categoryObjects) {
        const category = this.categoryObjects[key];

        const fields = this.sortFields(category.fields);
        Vue.set(category, 'filteredFields', fields);

        if (localStorage && localStorage['spiview-collapsible']) {
          if (localStorage['spiview-collapsible'].includes(key)) {
            category.isopen = true;
          } else {
            continue;
          }
        }
      }
    },
    /* updates protocols and protocol counts for categories
       should only run when issuing a new query */
    updateProtocols: function () {
      // clean up any old protocols
      for (const c in this.categoryObjects) {
        this.categoryObjects[c].protocols = {};
      }

      for (const key in this.protocols) {
        let category;

        // find the category that the protocol belongs to
        if (this.categoryObjects[key]) {
          category = this.categoryObjects[key];
        } else { // categorize special protocols that don't match category
          if (key === 'tcp' || key === 'udp' || key === 'icmp' || key === 'sctp' || key === 'esp') {
            category = this.categoryObjects.general;
          } else if (key === 'smtp' || key === 'lmtp') {
            category = this.categoryObjects.email;
          }
        }

        if (category) {
          category.protocols[key] = this.protocols[key];
        }
      }
    },
    /* restarts the page with a new query by deactivating unnecessary spi data,
       canceling any pending promises/timeouts, and issuing a new query */
    restart: function () {
      newQuery = true;
      openedCategories = false;
      categoryLoadingCounts = {};

      this.deactivateSpiData(); // hide any removed fields from spi url param

      if (pendingPromise) { // if there's already a req (or series of reqs)
        this.cancelLoading(); // cancel any current requests
        timeout = setTimeout(() => { // wait for promise abort to complete
          this.getSpiData(this.spiQuery);
        }, 100);
      } else {
        this.getSpiData(this.spiQuery);
      }
    },
    /* deactivate spi data that is no longer in url params */
    deactivateSpiData: function () {
      const spiParamsArray = this.spiQuery.split(',');
      for (const key in this.categoryObjects) {
        const category = this.categoryObjects[key];
        for (const k in category.spi) {
          const spiData = category.spi[k];
          if (spiData.active) {
            let inactive = true;
            for (let i = 0, len = spiParamsArray.length; i < len; ++i) {
              // if it exists in spi url param, it's still active
              if (spiParamsArray[i] === k) { inactive = false; }
            }
            // it's no longer in the spi url param, so it's not active
            if (inactive) { spiData.active = false; }
          }
        }
      }
    },
    /**
     * Finds the category that contains the given field and (if necessary) sets
     * it up to display spi data
     * @param {object} catMap     Map of spiview field categories
     * @param {object} field      The field to setup the category for
     * @returns {object} category The updated category that the field belongs to
     */
    setupCategory: function (catMap, field) {
      const category = catMap[field.group];

      category.name = field.group;

      if (!category.spi) { category.spi = {}; }

      if (!category.spi[field.dbField]) {
        Vue.set(category.spi, field.dbField, { field });
      }

      return category;
    },
    /**
     * Counts category fields that are being loaded so the user can know
     * when there are fields in the category that are loading
     * @param {object} category The category to count
     * @param {bool} increment  Whether to increment or decrement the count
     */
    countCategoryFieldsLoading: function (category, increment) {
      if (increment) {
        if (categoryLoadingCounts[category.name]) {
          ++categoryLoadingCounts[category.name];
        } else {
          categoryLoadingCounts[category.name] = 1;
        }
      } else {
        if (categoryLoadingCounts[category.name] &&
            categoryLoadingCounts[category.name] > 1) {
          --categoryLoadingCounts[category.name];
        } else {
          category.loading = false;
        }
      }
    },
    /* saves the visible fields */
    saveFieldState: function () {
      UserService.saveState({ visibleFields: this.spiQuery }, 'spiview');
    }
  },
  beforeDestroy: function () {
    // reset state variables
    newQuery = true;
    openedCategories = false;
    categoryLoadingCounts = {};

    if (timeout) { clearTimeout(timeout); }

    if (pendingPromise) { // if there's  a req (or series of reqs)
      pendingPromise.source.cancel();
      pendingPromise = null;
    }
  }
};
</script>

<style>
/* make field buttons tiny */
.spiview-page .btn-group.dropdown.field-dropdown > button {
  padding: 0 5px;
  font-size: .75rem;
}
/* show active button */
.spiview-page .btn-group.dropdown.active > button:not(.dropdown-toggle) {
  color: var(--color-foreground, #333);
  background-color: var(--color-gray-light);
  box-shadow: inset 0 3px 5px rgba(0, 0, 0, .25);
}
/* bold field label dropdown buttons */
.spiview-page .spi-buckets > div.btn-group.dropdown > button {
  font-weight: 600;
}
/* wide field config dropdown */
.spiview-page form.info-nav .field-config-menu .dropdown-menu {
  min-width: 300px;
  max-width: 400px;
}
</style>

<style scoped>
.spiview-page {
  overflow-x: hidden;
}

/* info navbar --------------------- */
.spiview-page form.info-nav {
  height: 42px;
  padding: var(--px-md);
  background-color: var(--color-quaternary-lightest);
}

.spiview-page form.info-nav .field-config-menu .dropdown-header {
  padding: 0 2px;
}

/* spiview content ----------------- */
.spiview-page > .spiview-content {
  padding-top: 10px;
}

/* panels -------------------------- */
.spiview-page .card-body {
  padding: 0;
}

/* +/- button on panel header */
.collapsed > .when-opened,
:not(.collapsed) > .when-closed {
  display: none;
}

.spiview-page .protocol-value {
  margin-right: .75rem;
  font-size: .9rem;
}

/* value counts */
.spiview-page sup {
  margin-left: -8px;
}

/* larger titles */
.spiview-page strong.category-title {
  font-size: 1.3rem;
}

/* btn drawer for toggling values -- */
.spiview-page .btn-drawer-toggle {
  background-color: var(--color-gray-lighter);
  margin-right: -4px;
  margin-left: -4px;
}
.spiview-page .btn-drawer .form-control-sm {
  height: 22px;
  padding: 2px 6px;
  font-size: 12px;
  line-height: 1.5;
  border-radius: 3px;
  vertical-align: top;
  width: 300px;
}
.spiview-page .btn-drawer .btn-container {
  max-height: 22px;
  overflow: hidden;
  transition: all .8s;
}

.spiview-page .btn-drawer.expanded .btn-container {
  max-height: 999px;
  overflow: visible;
  animation: 7s delay-overflow;
}
@keyframes delay-overflow {
  from { overflow: hidden; }
}

/* expand drawer button icon */
.spiview-page .btn-drawer.expanded .fa-angle-double-up {
  display: block;
}
.spiview-page .btn-drawer:not(.expanded) .fa-angle-double-up {
  display: none;
}
.spiview-page .btn-drawer.expanded .fa-angle-double-down {
  display: none;
}
.spiview-page .btn-drawer:not(.expanded) .fa-angle-double-down {
  display: block;
}

/* stripes! */
.spiview-page .spi-buckets:nth-child(odd) {
  background-color: var(--color-quaternary-lightest);
}

/* force wrapping */
.spiview-page .spi-bucket {
  display: inline-block;
}

/* canel btn */
.cancel-btn {
  margin-top: -4px;
  margin-right: 80px;
}
.cancel-btn.disabled-aggregations {
  margin-right: 160px;
}
</style>
