From 7617d340fa9e6558ff868d513b399f1e7aa216e5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?V=C3=A1clav=20Barto=C5=A1?= <bartos@cesnet.cz>
Date: Wed, 26 Aug 2020 10:59:54 +0200
Subject: [PATCH] Completely reworked to support multiple observable fields

Needs some more testing, but everything seems to work well.
---
 thehive_button/package.json                 |   4 +-
 thehive_button/public/create_case.js        | 101 +++
 thehive_button/public/main.js               | 112 +---
 thehive_button/public/options_editor.js     | 176 +++++
 thehive_button/public/options_template.html |   8 -
 thehive_button/public/request_handler.js    | 195 ++++++
 thehive_button/public/vis.less              |   3 -
 thehive_button/public/vis_controller.js     | 706 +++++++++-----------
 thehive_button/server/routes/newcase.js     |   3 +-
 9 files changed, 820 insertions(+), 488 deletions(-)
 create mode 100644 thehive_button/public/create_case.js
 create mode 100644 thehive_button/public/options_editor.js
 delete mode 100644 thehive_button/public/options_template.html
 create mode 100644 thehive_button/public/request_handler.js
 delete mode 100644 thehive_button/public/vis.less

diff --git a/thehive_button/package.json b/thehive_button/package.json
index 5dfa6de..e1c070d 100644
--- a/thehive_button/package.json
+++ b/thehive_button/package.json
@@ -1,10 +1,10 @@
 {
   "name": "thehive_button",
-  "version": "0.3.0",
+  "version": "1.0.0",
   "description": "Visualisation plugin which creates a simple button to create a new case in The Hive.",
   "main": "index.js",
   "kibana": {
-    "version": "7.2.0"
+    "version": "7.4.2"
   },
   "scripts": {
     "lint": "eslint .",
diff --git a/thehive_button/public/create_case.js b/thehive_button/public/create_case.js
new file mode 100644
index 0000000..fc8edd6
--- /dev/null
+++ b/thehive_button/public/create_case.js
@@ -0,0 +1,101 @@
+// Functions to send data to Kibana endpoints
+
+import chrome from 'ui/chrome';
+
+// Create a new Case in The Hive via its API
+// Return a Promise which resolves to object with ID of the new case ('id' attr) or error message ('error' attr)
+export function createTheHiveCase(base_url, api_key, title, descr, severity, startDate, owner, flag, tlp, tags) {
+  // Prepare data
+  var data = JSON.stringify({
+    "base_url": base_url,
+    "api_key": api_key,
+    "body": {
+      "title": title,
+      "description": descr,
+      "severity": severity, // number: 1=low, 2=medium, 3=high
+      "startDate": startDate,
+      "owner": owner, // user name the case will be assigned to
+      "flag": flag, // bool
+      "tlp": tlp, // number: 0=white, 1=green, 2=amber, 3=red
+      "tags": tags, // array of strings
+    }
+  });
+  console.log("TheHiveButton: Sending request to API endpoint 'new_case':", data);
+  var kibana_endpoint_url = chrome.addBasePath('/api/thehive_button/new_case');
+
+  return new Promise(function (resolve, reject) {
+    // Create AJAX request
+    var xhr = new XMLHttpRequest();
+    
+    // Listener to process reply
+    xhr.onreadystatechange = function () {
+      if (this.readyState != 4) {
+        return; // response not ready yet
+      }
+      if (this.status == 200) {
+        const resp = JSON.parse(this.responseText);
+        console.log("TheHiveButton: Response from backend:", resp);
+        if ("error" in resp) {
+          resolve({"error": resp.error});
+        }
+        else if (resp.status_code != 201) {
+          resolve({"error": "Unexpected reply received from The Hive: [" + resp.status_code + "] " + resp.status_msg});
+        }
+        else {
+          resolve({"id": resp.body.id}); // return ID of the new case
+        }
+      }
+      else {
+        console.log("TheHiveButton: Error " + this.status + ": " + this.statusText);
+        resolve({"error": "Error " + this.status + ": " + this.statusText});
+      }
+    }
+
+    // Send the AJAX request
+    xhr.open("POST", kibana_endpoint_url);
+    xhr.setRequestHeader("Content-Type", "application/json");
+    xhr.setRequestHeader("kbn-xsrf", "thehive_plugin"); // this header must be set, although its content is probably irrelevant
+    xhr.send(data);
+  });
+}
+
+// Add observables to an existing Case in The Hive
+// (send the list of observables to our backend endpoint, it pushes them to The Hive)
+export function addCaseObservables(base_url, api_key, caseid, observables) {
+  const kibana_endpoint_url = chrome.addBasePath('/api/thehive_button/add_observables');
+  const data = JSON.stringify({
+    "base_url": base_url,
+    "api_key": api_key,
+    "caseid": caseid,
+    "observables": observables,
+  });
+  console.log("TheHiveButton: Sending request to API endpoint 'add_observables':", data);
+  
+  return new Promise(function (resolve, reject) {
+    // Create AJAX request
+    var xhr = new XMLHttpRequest();
+    
+    // Listener to process reply
+    xhr.onreadystatechange = function () {
+      if (this.readyState != 4) {
+        return; // response not ready yet
+      }
+      if (this.status == 200) {
+        const resp = JSON.parse(this.responseText);
+        console.log("TheHiveButton: Response from backend:", resp);
+        resolve(resp);
+      }
+      else {
+        console.log("TheHiveButton: Error " + this.status + ": " + this.statusText);
+        resolve({"error": "Error " + this.status + ": " + this.statusText});
+      }
+    }
+
+    // Send the AJAX request
+    xhr.open("POST", kibana_endpoint_url);
+    xhr.setRequestHeader("Content-Type", "application/json");
+    xhr.setRequestHeader("kbn-xsrf", "thehive_plugin"); // this header must be set, although its content is probably irrelevant
+    xhr.send(data);
+  });
+}
+
diff --git a/thehive_button/public/main.js b/thehive_button/public/main.js
index 3631569..ee46d73 100644
--- a/thehive_button/public/main.js
+++ b/thehive_button/public/main.js
@@ -1,52 +1,18 @@
-//import './vis.less';
 import { THEHIVE_API_KEY, THEHIVE_URL, THEHIVE_OWNER } from './env';
-//import { VisController } from './vis_controller';
 import { TheHiveButtonVisComponent } from './vis_controller';
+import { theHiveButtonRequestHandlerProvider } from './request_handler';
+import { optionsEditor } from './options_editor';
 
 import { VisFactoryProvider } from 'ui/vis/vis_factory';
 import { VisTypesRegistryProvider } from 'ui/registry/vis_types';
-import { Schemas } from 'ui/vis/editors/default/schemas';
-//import { Status } from 'ui/vis/update_status';
 import { DefaultEditorSize } from 'ui/vis/editor_size';
 
-import optionsTemplate from './options_template.html';
-
-/*
-TODO: It would be better to compose options tab of EUI React elements,
-but this probably needs at least kibana 7.4.
-See this for example of a custom editor:
-  https://github.com/elastic/kibana/tree/master/src/plugins/vis_type_markdown/public 
-
-import React from 'react';
-import {
-  EuiForm,
-  EuiFormRow,
-  EuiFieldText,
-} from '@elastic/eui';
-
-import { VisOptionsProps } from 'src/plugins/vis_default_editor/public';
-
-function optionsEditor({ stateParams, setValue }) {
-  return (
-  <EuiForm>
-    <EuiFormRow label="Base URL of The Hive">
-      <EuiFieldText value={stateParams.url} />
-    </EuiFormRow>
-    <EuiFormRow label="API key">
-      <EuiFieldText value={stateParams.apikey} />
-    </EuiFormRow>
-    <EuiFormRow label="Username" helpText="Used as the owner of cases created from here">
-      <EuiFieldText value={stateParams.owner} />
-    </EuiFormRow>
-  </EuiForm>
-);
-*/
 
 function TheHiveButtonVisProvider(Private) {
   const VisFactory = Private(VisFactoryProvider);
 
-  console.log("default URL:", THEHIVE_URL);
-  console.log("default API key:", THEHIVE_API_KEY);
+  //console.log("default URL:", THEHIVE_URL);
+  //console.log("default API key:", THEHIVE_API_KEY);
 
   return VisFactory.createReactVisualization({
     name: 'thehive_button',
@@ -61,65 +27,25 @@ function TheHiveButtonVisProvider(Private) {
         url: THEHIVE_URL,
         apikey: THEHIVE_API_KEY,
         owner: THEHIVE_OWNER,
+        obsFields: [], // list of objects, e.g. {name: "clientip", type: "ip", cnt: 100}
       }
     },
-//    editor: optionsEditor,
-     editor: 'default',
-    editorConfig: {
-      optionsTemplate: optionsTemplate,
-      defaultSize: DefaultEditorSize.MEDIUM,
-      //enableAutoApply: true,
-      schemas: new Schemas([
-        // TODO all this (metric and bucket "terms") could be pre-defind
-        // (just a selection of fields to read Observables from would be nice)
-        // But I don't know how and where to pass the values manually.
-        /* Allowed params (from /src/legacy/ui/public/vis/editors/default/schemas.d.ts)
-            aggFilter: string | string[];
-            editor: boolean | string;
-            group: AggGroupNames;
-            max: number;
-            min: number;
-            name: string;
-            params: AggParam[];
-            title: string;
-        */
-        {
-          group: 'metrics',
-          name: 'metric',
-          title: "Metric (not used, ignore this)",
-          min: 1,
-          max: 1,
-          aggFilter: 'count',
-          defaults: [
-            {
-              type: 'count',
-              schema: 'metric',
-            },
-          ],
-          editor: '<div class="hintbox"><i class="fa fa-danger text-info"></i> Some metric must be defined here, but it\'s setting is irrelevant. Just go to "Buckets" below and set up the field to get Observbles from.</div>'
-        },
+    //editor: 'default',
+    editorConfig:  {
+      optionTabs: [
         {
-          group: 'buckets',
-          name: 'group',
-          title: 'Observables',
-          min: 0,
-          //max: 1,
-          aggFilter: ['terms'],
-          defaults: [
-            {
-              type: 'terms',
-              schema: 'group',
-              field: 'ip',
-              size: 1000,
-              orderBy: 'alphabetical', 
-              order: 'ascending',
-            }
-          ]
-        },
-      ]),
+          name: "options",
+          title: "Options",
+          editor: optionsEditor,
+        }
+      ],
+      defaultSize: DefaultEditorSize.LARGE,
     },
-    //requestHandler: 'courier', // default
-    //responseHandler: 'default', // return data as a table, see https://www.elastic.co/guide/en/kibana/7.2/development-visualization-response-handlers.html
+//       optionsTemplate: optionsEditor, //optionsTemplate,
+//       //enableAutoApply: true,
+//     },
+    requestHandler: 'theHiveButtonRequestHandler', // own request handler
+    responseHandler: 'none', // pass data as returned by requestHandler
   });
 }
 
diff --git a/thehive_button/public/options_editor.js b/thehive_button/public/options_editor.js
new file mode 100644
index 0000000..38762bd
--- /dev/null
+++ b/thehive_button/public/options_editor.js
@@ -0,0 +1,176 @@
+import React from 'react';
+import {
+  EuiForm,
+  EuiFormRow,
+  EuiTitle,
+  EuiSpacer,
+  EuiFieldText,
+  EuiFieldNumber,
+  EuiSelect,
+  EuiFlexGroup,
+  EuiFlexItem,
+  EuiButton,
+  EuiButtonIcon,
+} from '@elastic/eui';
+
+// Default data types in The Hive
+const DEFAULT_THE_HIVE_TYPES = [
+  '',
+  'autonomous-system',
+  'domain',
+  'file',
+  'filename',
+  'fqdn',
+  'hash',
+  'ip',
+  'mail',
+  'mail_subject',
+  'regexp',
+  'registry',
+  'uri_path',
+  'url',
+  'user-agent',
+  'other',	
+];
+
+// Options for EuiSelect for selection of field's data type in TheHive
+const typesOptions = DEFAULT_THE_HIVE_TYPES.map( dt => ({value: dt, text: dt}) );
+
+export function optionsEditor(props) {
+  //console.log("editor render(), props:", props);
+  const { stateParams, setValue, setValidity, vis } = props;
+  
+  // onClick/onChange handlers
+  const obsAddNew = () => {
+    const newObsFields = [...stateParams.obsFields, {name: "", type: "", cnt: 100}];
+    // For some reason, first click on the button after editor is loaded does
+    // nothing. Calling setValue twice here fixes it.  
+    setValue("obsFields", newObsFields);
+    setValue("obsFields", newObsFields);
+//     setValidity(false); // since new row is empty, form is always invalid
+  };
+  const obsRemove = (ix) => {
+    let newArray = [...stateParams.obsFields];
+    newArray.splice(ix, 1);
+    setValue("obsFields", newArray);
+//     validate();
+  }
+  const obsSetName = (ix, name) => {
+    let newArray = [...stateParams.obsFields];
+    newArray[ix].name = name;
+    setValue("obsFields", newArray);
+//     validate();
+  } 
+  const obsSetType = (ix, type) => {
+    let newArray = [...stateParams.obsFields];
+    newArray[ix].type = type;
+    setValue("obsFields", newArray);
+//     validate();
+  }
+  const obsSetCnt = (ix, cnt) => {
+    let newArray = [...stateParams.obsFields];
+    newArray[ix].cnt = parseInt(cnt);
+    setValue("obsFields", newArray);
+//     validate();
+  }
+//   const validate = () => {
+//     let valid = true;
+//     for (let field of stateParams.obsFields) {
+//       if (field.name == "" || field.type == "" || field.cnt == "") {
+//         valid = false;
+//         break;
+//       }
+//     }
+//     // TODO check for duplicate fields
+//     setValidity(valid);
+//   }
+  
+  // Get list of all fields in index (except those beginning with "_" or "@")
+  // and create "options" parameter for EuiSelect.
+  // Also, fields with "aggregatable=false" are removed, as they can't be used
+  // with "terms" aggregation we need.
+  // See this for details: https://www.elastic.co/guide/en/elasticsearch/reference/7.x/fielddata.html
+  // Empty field is added at the beginning, meaning "no selection yet".
+  const fieldOptions = [{value: "", text: ""}].concat(
+    vis.indexPattern.fields.raw.filter( f => (f.name[0] != "_" && f.name[0] != "@" && f.aggregatable) ).map( f => ({value: f.name, text: `${f.name} (${f.type})`}) )
+  );
+
+  return <EuiForm>
+    <EuiFormRow fullWidth={true} label="Base URL of The Hive">
+      <EuiFieldText
+        fullWidth={true}
+        value={stateParams.url}
+        onChange={e => setValue('url', e.target.value)}
+        isInvalid={stateParams.url == ""}
+      />
+    </EuiFormRow>
+    <EuiFlexGroup>
+      <EuiFlexItem grow={1}>
+        <EuiFormRow label="API key to access The Hive" helpText="API key of a user with write permission.">
+          <EuiFieldText
+            fullWidth={true}
+            value={stateParams.apikey}
+            onChange={e => setValue('apikey', e.target.value)}
+            isInvalid={stateParams.apikey == ""}
+          />
+        </EuiFormRow>
+      </EuiFlexItem>
+      <EuiFlexItem grow={1}>
+        <EuiFormRow label="Assignee" helpText="User to assign created cases to. Must be a valid username from The Hive instance.">
+          <EuiFieldText
+            value={stateParams.owner}
+            onChange={e => setValue('owner', e.target.value)}
+            isInvalid={stateParams.owner == ""}
+          />
+        </EuiFormRow>
+      </EuiFlexItem>
+    </EuiFlexGroup>
+    <EuiTitle size="s"><h3>Fields to get potential observables from ...</h3></EuiTitle>
+    <EuiSpacer size="s" />
+    {stateParams.obsFields.map( (field, ix) => (
+      <EuiFlexGroup key={ix} gutterSize="s">
+        <EuiFlexItem grow={3}>
+          <EuiFormRow label="Field name">
+            <EuiSelect
+              options={fieldOptions}
+              value={field.name}
+              onChange={ e => obsSetName(ix, e.target.value) }
+              isInvalid={field.name == ""}
+            />
+          </EuiFormRow>
+        </EuiFlexItem>
+        <EuiFlexItem grow={2}>
+          <EuiFormRow label="Data type in The Hive">
+            <EuiSelect
+              options={typesOptions}
+              value={field.type}
+              onChange={ e => obsSetType(ix, e.target.value) }
+              isInvalid={field.type == ""}
+            />
+          </EuiFormRow>
+        </EuiFlexItem>
+        <EuiFlexItem grow={1}>
+          <EuiFormRow label="Max items shown">
+            <EuiFieldNumber
+              min={1}
+              max={1000}
+              value={parseInt(field.cnt)}
+              onChange={ e => obsSetCnt(ix, e.target.value) }
+              isInvalid={!(field.cnt > 0)}
+            />
+          </EuiFormRow>
+        </EuiFlexItem>
+        <EuiFlexItem grow={false}>
+          <EuiFormRow hasEmptyLabelSpace>
+            <EuiButtonIcon iconType="trash" iconSize="m" color="danger" aria-label="Remove field" onClick={ e => obsRemove(ix) } />
+          </EuiFormRow>
+        </EuiFlexItem>
+      </EuiFlexGroup>
+    ))}
+    <EuiFlexGroup>
+      <EuiFlexItem grow={false}>
+        <EuiButton iconType="plusInCircleFilled" color="primary" onClick={obsAddNew}>Add new field ...</EuiButton>
+      </EuiFlexItem>
+    </EuiFlexGroup>
+  </EuiForm>
+}
diff --git a/thehive_button/public/options_template.html b/thehive_button/public/options_template.html
deleted file mode 100644
index ef99657..0000000
--- a/thehive_button/public/options_template.html
+++ /dev/null
@@ -1,8 +0,0 @@
-<div class="form-group">
-  <p><label>Base URL of The Hive</label>
-  <input ng-model="editorState.params.url" class=form-control /></p>
-  <p><label>API key</label>
-  <input ng-model="editorState.params.apikey" class=form-control /></p>
-  <p><label>User name to use as the owner of cases created from here</label>
-  <input ng-model="editorState.params.owner" class=form-control /></p>
-</div>
diff --git a/thehive_button/public/request_handler.js b/thehive_button/public/request_handler.js
new file mode 100644
index 0000000..bdbb0f4
--- /dev/null
+++ b/thehive_button/public/request_handler.js
@@ -0,0 +1,195 @@
+import { CourierRequestHandlerProvider as courierRequestHandlerProvider } from 'ui/vis/request_handlers/courier';
+import { SearchSourceProvider } from 'ui/courier/search_source';
+import { RequestAdapter, DataAdapter } from 'ui/inspector/adapters';
+import { VisRequestHandlersRegistryProvider } from 'ui/registry/vis_request_handlers';
+import { AggConfig } from 'ui/vis/agg_config';
+import { AggConfigs } from 'ui/vis/agg_configs';
+import { getTime } from 'ui/timefilter/get_time';
+import { i18n } from '@kbn/i18n';
+import { has } from 'lodash';
+import { calculateObjectHash } from 'ui/vis/lib/calculate_object_hash';
+import { getRequestInspectorStats, getResponseInspectorStats } from 'ui/courier/utils/courier_inspector_utils';
+import chrome from 'ui/chrome';
+
+// Maximum number of unique values of each field (observables) to fetch
+const MAX_NUMBER_OF_TERMS = 5;
+
+const handleCourierRequest = courierRequestHandlerProvider().handler;
+
+// Register new RaquestHandlerProvider 
+const theHiveButtonRequestHandlerProvider = function () {
+  return {
+    name: 'theHiveButtonRequestHandler',
+    handler: theHiveButtonRequestHandler,
+  }
+}
+VisRequestHandlersRegistryProvider.register(theHiveButtonRequestHandlerProvider);
+
+export {theHiveButtonRequestHandlerProvider, theHiveButtonRequestHandler};
+
+
+// The request handler function itself
+async function theHiveButtonRequestHandler(params) {
+  //console.log("theHiveButtonRequestHandler params:", params);
+  
+  let index = params.index;
+  let partialRows = params.partialRows;
+  let metricsAtAllLevels = params.metricsAtAllLevels;
+  let timeRange = params.timeRange;
+  let query = params.query;
+  let filters = params.filters;
+  let inspectorAdapters = params.inspectorAdapters;
+  let queryFilter = params.queryFilter;
+  let forceFetch = params.forceFetch;
+  // our own confiuration:
+  // list of fields to get potential observables from
+  // (each "field" is object {name: str, type: str, cnt: int})
+  let obsFields = params.visParams.obsFields;
+  
+  // filter out invalid field specifications
+  obsFields = obsFields.filter( f => (f.name != "" && f.type != "" && f.cnt > 0) );
+  
+  if (obsFields.length == 0) {
+    //console.log("theHiveButtonRequestHandler: Empty obsFields, nothing to do")
+    return {} // no fields specified, nothing to do
+  }
+
+  // === Prepare request to ask for unique values of all selected fields ===
+
+  // Construct a query for ElasticSearch
+  // Get "terms" (most common unique values) for each field of obsFields
+  const aggs_dsl = {}
+  for (let field of obsFields) {
+    aggs_dsl[field.name] = {
+      terms: {
+          field: field.name,
+          size: field.cnt,
+          order: {_count: "desc"}
+        }
+    };
+  }
+  //console.log("aggs_dsl:", aggs_dsl);
+  
+  // Create empty AggConfigs
+  // (We could pass specifications of a metric and the buckets here,
+  //  but default processing functions assume multiple buckets are sub-buckets,
+  //  which is not what we want. So we must do a "hack" and manually create  
+  //  query directly in format for ElasticSearch)
+  const aggs = new AggConfigs(params.index, []);
+  
+  // === Some magic to get searchSource object ===
+  // (inspired by https://github.com/fbaligand/kibana-enhanced-table/blob/7.4/public/data_load/enhanced-table-request-handler.js)
+  // (I don't understand it, but it works)
+
+  let $injector = await chrome.dangerouslyGetActiveInjector();
+  let Private = $injector.get('Private');
+  let SearchSource = Private(SearchSourceProvider);
+  let searchSource = new SearchSource();
+  searchSource.setField('index', index);
+  searchSource.setField('size', 0);
+
+  inspectorAdapters.requests = new RequestAdapter();
+  inspectorAdapters.data = new DataAdapter();
+  
+
+  // === Execute query ===
+  // We could call standard "courier" here, but it tries to convert the response
+  // to a table, which fails in our case, so we copied the main code of courier
+  // and modified it here.
+  
+  const abortSignal = false;
+  
+  const timeFilterSearchSource = searchSource.createChild({ callParentStartHandlers: true });
+  const requestSearchSource = timeFilterSearchSource.createChild({ callParentStartHandlers: true });
+
+  aggs.setTimeRange(timeRange);
+
+  // For now we need to mirror the history of the passed search source, since
+  // the request inspector wouldn't work otherwise.
+  Object.defineProperty(requestSearchSource, 'history', {
+    get() {
+      return searchSource.history;
+    },
+    set(history) {
+      return searchSource.history = history;
+    }
+  });
+
+  // This has been modified to override DSL format by ours
+//   requestSearchSource.setField('aggs', function () {
+//     return aggs.toDsl(metricsAtAllLevels);
+//   });
+  requestSearchSource.setField('aggs', aggs_dsl); 
+
+  requestSearchSource.onRequestStart((searchSource, searchRequest) => {
+    return aggs.onSearchRequestStart(searchSource, searchRequest);
+  });
+
+  if (timeRange) {
+    timeFilterSearchSource.setField('filter', () => {
+      return getTime(searchSource.getField('index'), timeRange);
+    });
+  }
+
+  requestSearchSource.setField('filter', filters);
+  requestSearchSource.setField('query', query);
+
+  const reqBody = await requestSearchSource.getSearchRequestBody();
+
+  const queryHash = calculateObjectHash(reqBody);
+  // We only need to reexecute the query, if forceFetch was true or the hash of the request body has changed
+  // since the last request
+  const shouldQuery = forceFetch || (searchSource.lastQuery !== queryHash);
+
+  if (shouldQuery) {
+    inspectorAdapters.requests.reset();
+    const request = inspectorAdapters.requests.start(
+      i18n.translate('common.ui.vis.courier.inspector.dataRequest.title', { defaultMessage: 'Data' }),
+      {
+        description: i18n.translate('common.ui.vis.courier.inspector.dataRequest.description',
+          { defaultMessage: 'This request queries Elasticsearch to fetch the data for the visualization.' }),
+      }
+    );
+    request.stats(getRequestInspectorStats(requestSearchSource));
+
+    try {
+      // Abort any in-progress requests before fetching again
+      if (abortSignal) {
+        abortSignal.addEventListener('abort', () => requestSearchSource.cancelQueued());
+      }
+
+      const response = await requestSearchSource.fetch();
+      //console.log("raw response:", response);
+
+      searchSource.lastQuery = queryHash;
+
+      request
+        .stats(getResponseInspectorStats(searchSource, response))
+        .ok({ json: response });
+
+      searchSource.rawResponse = response;
+    } catch(e) {
+      // Log any error during request to the inspector
+      request.error({ json: e });
+      throw e;
+    } finally {
+      // Add the request body no matter if things went fine or not
+      requestSearchSource.getSearchRequestBody().then(req => {
+        request.json(req);
+      });
+    }
+  }
+
+  // === Copy of courier code ends here, now we parse the response ===
+  
+  const resp = searchSource.rawResponse;
+  // Return as object containing a list of unique values (terms) for each 
+  // requested field
+  let unique_values_lists = {}
+  for (let field of obsFields) {
+    unique_values_lists[field.name] = resp.aggregations[field.name].buckets.map( (x) => x.key );
+  }
+
+  //console.log("Final lists:", unique_values_lists);
+  return unique_values_lists;
+}
diff --git a/thehive_button/public/vis.less b/thehive_button/public/vis.less
deleted file mode 100644
index b6f887a..0000000
--- a/thehive_button/public/vis.less
+++ /dev/null
@@ -1,3 +0,0 @@
-.myvis-container-div {
-  padding: 1em;
-}
diff --git a/thehive_button/public/vis_controller.js b/thehive_button/public/vis_controller.js
index 3018390..8b23222 100644
--- a/thehive_button/public/vis_controller.js
+++ b/thehive_button/public/vis_controller.js
@@ -1,6 +1,6 @@
 //import { Status } from 'ui/vis/update_status';
-import chrome from 'ui/chrome';
 import { toastNotifications } from 'ui/notify';
+import { createTheHiveCase, addCaseObservables } from './create_case';
 //import vis_template from './vis_template.html';
 
 import React, { Component } from 'react';
@@ -16,6 +16,7 @@ import {
   EuiTitle,
   EuiFlexGroup,
   EuiFlexItem,
+  EuiSpacer,
   EuiForm,
   EuiFormRow,
   EuiFieldText,
@@ -26,27 +27,16 @@ import {
   makeId,
 } from '@elastic/eui';
 
-// TODO:
-// - Explicit reset button
-// - Reset when data (query) changes
-// - Hide modal when clicked outside
-
 
 // ********** React components **********
 
 // Main React component - the root of visualization
 export class TheHiveButtonVisComponent extends Component {
   render() {
-    //console.log(this.props);
-    let ips = [];
-    if (this.props.visData) {
-      const first_col_id = this.props.visData.columns[0].id;
-      ips = this.props.visData.rows.map(row => row[first_col_id]);
-      //console.log(ips);
-    }
+    //console.log("TheHiveButtonVisComponent.render(), props:", this.props);
     return (
       <div>
-        <NewCaseButton params={this.props.vis.params} observables={ips} />
+        <NewCaseButton params={this.props.vis.params} observables={this.props.visData} />
       </div>
     );
   }
@@ -63,55 +53,86 @@ export class TheHiveButtonVisComponent extends Component {
 // Button to show the pop-up window (modal)
 // Props:
 //  .params - visualization parameters (from vis.params)
-//  .observables - list of IP addrsses or other observables to add to the Case
+//  .observables - object with lists of potential observables to add to the Case
+//     for each field in params.obsFields there should be a key in this object
+//     containing list of observables (this is returned by request_handler) 
 class NewCaseButton extends Component {
 
   constructor(props) {
     super(props);
-    const n_obs = props.observables.length;
-    
-    // initial state of form fields - used to reset form
-    this.initial_case_state = {
-      // Case parameters
-      title: "",
-      description: "\n\n--\nCreated from Kibana",
-      severity: "2", // medium
-      tlp: "2", // amber
-      tags: [], // TODO (not implemented yet)
-      // An array of values for each editable part of observables, prefilled with default values
-      obsDescrs: new Array(n_obs).fill(""),
-      obsTLPs: new Array(n_obs).fill(2), // TODO (not implemented yet)
-      obsIOCs: new Array(n_obs).fill(false),
-      obsTags: new Array(n_obs).fill([]), // TODO (not implemented yet)
-      obsSelected: new Array(), // list of indices of selected observables
-    }
+    // Filter out invalid obsField specifications
+    this.obsFields = props.params.obsFields.filter( f => (f.name != "" && f.type != "" && f.cnt > 0) );
+    //console.log("Filtered field specs:", this.obsFields);
     
     // The complete state is here, so it's kept even when modal is closed
     this.state = {
       isModalVisible: false,
-      ...this.initial_case_state, // TODO isn't deep copy needed here (and in reset_form below)?
+      isWorking: false, // used to show a spinner on submit button
+      ...this.create_initial_state(),
     }
+    
+    this.resetCnt = 0; // used to change Modal component key on each form reset
 
     // Each handler function in a class (method) must be "binded" this way
     this.closeModal = this.closeModal.bind(this);
     this.showModal = this.showModal.bind(this);
+    this.resetForm = this.resetForm.bind(this);
 
     this.onTitleChange = this.onTitleChange.bind(this);
     this.onSeverityChange = this.onSeverityChange.bind(this);
     this.onTLPChange = this.onTLPChange.bind(this);
     this.onDescriptionChange = this.onDescriptionChange.bind(this);
     
-    this.onObsDescrChange = this.onObsDescrChange.bind(this);
-    this.onObsTLPChange = this.onObsTLPChange.bind(this);
-    this.onObsIOCChange = this.onObsIOCChange.bind(this);
-    this.onObsTagsChange = this.onObsTagsChange.bind(this);
     this.onObsSelectionChange = this.onObsSelectionChange.bind(this);
+    this.onObsDataChange = this.onObsDataChange.bind(this);
     
     this.submitCase = this.submitCase.bind(this);
   }
 
+  create_initial_state() {
+    // create a new instance of initial state definition
+    let initial_state = {
+      // Case parameters
+      title: "",
+      description: "\n\n--\nCreated from Kibana",
+      severity: "2", // medium
+      tlp: "2", // amber
+      tags: [], // TODO (not implemented yet)
+      obsData: {}, // state of observables form fields (obsData->field->index->{descr,tlp,ioc,tags})
+      obsSel: {}, // list of observable selections (obsSel->field->list_of_selected_indices)
+    }
+    // pre-fill state of each observable to defaults
+    const initial_field_data = {descr: "", tlp: 2, ioc: false, tags: []};
+    for (let field of this.obsFields) {
+      const n_obs = this.props.observables[field.name].length;
+      // fill obsData with new copies of initial_field_data
+      initial_state.obsData[field.name] = new Array(n_obs).fill().map((_)=>({...initial_field_data}));
+      // nothing is selected
+      initial_state.obsSel[field.name] = new Array();
+    }
+    return initial_state;
+  }
+
+  componentDidUpdate(prevProps) {
+    // If list of observables was updated or obsFields setting has changed, 
+    // reset the component state and precomputed variables.
+    if (this.props.observables != prevProps.observables) {
+      if (this.props.params.obsFields != prevProps.params.obsFields) {
+        // when obsFields change, observables must change as well, so this "if"
+        // can be inside the first one.
+        // Filter out invalid obsField specifications
+        this.obsFields = this.props.params.obsFields.filter( f => (f.name != "" && f.type != "" && f.cnt && f.cnt > 0) );
+        //console.log("Filtered field specs:", this.obsFields);
+      }
+      //console.log("New list of observables, resetting form.");
+      this.resetForm();
+    }
+  }
+
   resetForm() {
-    this.setState(this.initial_case_state);
+    this.setState(this.create_initial_state());
+    this.resetCnt += 1; // this changes the key of ModalContent, causing it to be replaced by new DOM elelments (otherwise, not all things are reset properly)
+    this.forceUpdate();
   }
 
   closeModal() {
@@ -136,53 +157,69 @@ class NewCaseButton extends Component {
     this.setState({description: evt.target.value});
   }
 
-  // Event handlers for change of observable parameters
-  onObsDescrChange(evt, rowindex) {
-    const val = evt.target.value;
-    this.setState((state, props) => {
-      // Copy old obsDescrs, update the corresponding item and pass as new state
-      let descrs = [...state.obsDescrs];
-      descrs[rowindex] = val;
-      return {obsDescrs: descrs};
-    });
-  }
-  onObsTLPChange(evt, rowindex) { // TODO
-    const val = evt.target.value;
-    this.setState((state, props) => {
-      // Copy old obsTLPs, update the corresponding item and pass as new state
-      let tlps = [...state.obsTLPs];
-      tlps[rowindex] = val;
-      return {obsTLPs: tlps};
-    });
-  }
-  onObsIOCChange(evt, rowindex) {
-    const val = evt.target.checked;
+  // Event handler for observable (de)selection
+  onObsSelectionChange(fieldName, selectedItems) {
+    // Extract indices from the items and store them into state
+    const selectedIndices = selectedItems.map(item4 => item4.i);
     this.setState((state, props) => {
-      // Copy old obsIOCs, update the corresponding item and pass as new state
-      let iocs = [...state.obsIOCs];
-      iocs[rowindex] = val;
-      return {obsIOCs: iocs};
+      let newObsSel = {...this.state.obsSel};
+      newObsSel[fieldName] = selectedIndices;
+      return {obsSel: newObsSel};
     });
   }
-  onObsTagsChange(evt, rowindex) { // TODO
-    const val = evt.target.value;
+
+  // Event handler for edit of a form field in observable row
+  // - fieldName: which field (table of observables)
+  // - ix: index of the observable in the field's table
+  // - param: one of: descr,tlp,ioc,tags
+  // - value: new value of the form field
+  onObsDataChange(fieldName, ix, param, value) {
     this.setState((state, props) => {
-      // Copy old obsTags, update the corresponding item and pass as new state
-      let tags = [...state.obsTags];
-      tags[rowindex] = val;
-      return {obsTags: tags};
+      let newObsData = {...this.state.obsData};
+      newObsData[fieldName][ix][param] = value;
+      return {obsData: newObsData};
     });
   }
-  
-  // Event handler for row (de)selection
-  onObsSelectionChange(selectedItems) {
-    // Extract indices from the items and store them into state
-    const selectedIndices = selectedItems.map(item => item.i);
-    this.setState({obsSelected: selectedIndices});
+
+  // Render function
+  render() {
+    let modal;
+    if (this.state.isModalVisible) {
+      modal = <ModalContent
+        resetCnt={this.resetCnt} // used to change "key" of modalBody, causing all form fields to be re-created (some things are not reset properly by reseting state only)
+        close={this.closeModal}
+        reset={this.resetForm}
+        fields={this.obsFields}
+        observables={this.props.observables}
+        // form state
+        title={this.state.title}
+        description={this.state.description}
+        severity={this.state.severity}
+        tlp={this.state.tlp}
+        tags={this.state.tags}
+        obsData={this.state.obsData}
+        obsSel={this.state.obsSel}
+        spinner={this.state.isWorking}
+        // event handlers
+        onTitleChange={this.onTitleChange}
+        onSeverityChange={this.onSeverityChange}
+        onTLPChange={this.onTLPChange}
+        onDescriptionChange={this.onDescriptionChange}
+        onObsSelectionChange={this.onObsSelectionChange}
+        onObsDataChange={this.onObsDataChange}
+        submitCase={this.submitCase}
+      />;
+    }
+    return (
+      <div>
+        <EuiButton fill iconType="alert" color="danger" onClick={this.showModal}>Create new Case ...</EuiButton>
+        {modal}
+      </div>
+    );
   }
-  
+
   // Submit case button handler
-  submitCase(evt) {
+  async submitCase(evt) {
     const params = this.props.params;
     
     // Get case parameters
@@ -202,131 +239,104 @@ class NewCaseButton extends Component {
     
     // Get list of selected observables and their params
     let observables = [];
-    let selectionIndices = [...this.state.obsSelected]; // make a copy
-    selectionIndices.sort();
-    for (let i = 0; i < selectionIndices.length; i++) {
-      const j = selectionIndices[i]; // index of a selected obs. in the list of all observables
-      // fill in observable definition according to model at
-      // https://github.com/TheHive-Project/TheHiveDocs/blob/master/api/artifact.md
-      const obs = {
-        dataType: 'ip',
-        data: this.props.observables[j],
-        message: this.state.obsDescrs[j],
-        tlp: this.state.obsTLPs[j],
-        ioc: this.state.obsIOCs[j],
-        tags: this.state.obsTags[j],
-      };
-      observables.push(obs);
+    for (let field of this.obsFields) {
+      let selectionIndices = [...this.state.obsSel[field.name]]; // make a copy
+      selectionIndices.sort();
+      for (let i = 0; i < selectionIndices.length; i++) {
+        const j = selectionIndices[i]; // index of a selected obs. in the list of all observables
+        // fill in observable definition according to model at
+        // https://github.com/TheHive-Project/TheHiveDocs/blob/master/api/artifact.md
+        const obs = {
+          dataType: field.type,
+          data: this.props.observables[field.name][j],
+          message: this.state.obsData[field.name][j].descr,
+          tlp: this.state.obsData[field.name][j].tlp,
+          ioc: this.state.obsData[field.name][j].ioc,
+          tags: this.state.obsData[field.name][j].tags,
+        };
+        observables.push(obs);
+      }
     }
+    
     //console.log("Selected observables:", observables);
     
-    // All data cached in local variables - reset the form fields
-    this.resetForm();
+    // Check '/' at the end of base URL, add it if needed
+    let base_url = params.url;
+    if (base_url[base_url.length-1] != "/") {
+      base_url += "/";
+    }
+    
+    // Show spinner at submit button
+    this.setState({isWorking: true});
     
     // Submit request to create the case, handle response
-    // TODO rewite to await
-    createHiveCase(params.url, params.apikey, title, descr, severity, start_date, owner, flag, tlp, tags)
-      .then((resp) => {
-        if ('error' in resp) {
-          // Error contacting The Hive
-          console.error("TheHiveButton: ERROR when trying to create new case:", resp.error);
-          toastNotifications.addDanger("ERROR: " + resp.error);
-          return;
-        }
+    let resp;
+    resp = await createTheHiveCase(base_url, params.apikey, title, descr, severity, start_date, owner, flag, tlp, tags);
+
+    if ('error' in resp) {
+      // Error contacting The Hive
+      console.error("TheHiveButton: ERROR when trying to create new case:", resp.error);
+      toastNotifications.addDanger("ERROR: " + resp.error);
+      this.setState({isWorking: false}); // Hide spinner
+      return;
+    }
 
-        console.log("TheHiveButton: Case created:", resp);
-        const case_id = resp.id;
-        const case_url = params.url + "index.html#/case/" + case_id + "/details";
-        
-        // Show notification
-        let obs_text;
-        if (observables.length > 0) {
-          obs_text = "Adding " + observables.length + " observables in background ...";
-        }
-        else {
-          obs_text = "(no observables added)";
-        }
-        toastNotifications.add({
-          title: "Case created",
-          color: "success",
-          iconType: "checkInCircleFilled",
-          text: (
-            <div>
-              <p><b><a href={case_url} target="_blank">Edit the new Case</a></b></p>
-              <p>{obs_text}</p>
-            </div>
-          ),
-        });
-         
-        // Open a new window with the case in The Hive
-        // (adding observables may take some time, so the case is opened first;
-        //  The Hive web is dynamic so the observables appear as they are added)
-        window.open(case_url, '_blank');
-        
-        if (observables.length == 0)
-          return;
-        
-        // Submit request to add observables
-        console.log("TheHiveButton: adding " + observables.length + " observables ...");
-        addCaseObservables(params.url, params.apikey, case_id, observables)
-          .then((resp) => {
-            if ('error' in resp) {
-              console.error("TheHiveButton: ERROR when trying to add observables: " + resp.error);
-              toastNotifications.addDanger("ERROR when trying to add observables: " + resp.error);
-            }
-            else {
-              console.log("TheHiveButton: Done, observables added.");
-              toastNotifications.add("Done, observables added.");
-            }
-          }); // addObservables().then() func
-      }); // createHiveCase.then() func
+    console.log("TheHiveButton: Case created:", resp);
+    const case_id = resp.id;
+    const case_url = base_url + "index.html#/case/" + case_id + "/details";
+    
+    // Show notification
+    let obs_text;
+    if (observables.length > 0) {
+      obs_text = "Adding " + observables.length + " observables in background ...";
+    }
+    else {
+      obs_text = "(no observables added)";
+    }
+    toastNotifications.add({
+      title: "Case created",
+      color: "success",
+      iconType: "checkInCircleFilled",
+      text: (
+        <div>
+          <p><b><a href={case_url} target="_blank">Edit the new Case</a></b></p>
+          <p>{obs_text}</p>
+        </div>
+      ),
+    });
     
-    // Close the popup window
+    // Close the popup window, reset form fields and hide spinner
     this.closeModal();
-  }
-
-  // Render function
-  render() {
-    let modal;
-    if (this.state.isModalVisible) {
-      modal = <ModalContent
-        close={this.closeModal}
-        observables={this.props.observables}
-        // form state
-        title={this.state.title}
-        description={this.state.description}
-        severity={this.state.severity}
-        tlp={this.state.tlp}
-        tags={this.state.tags}
-        obsDescrs={this.state.obsDescrs}
-        obsTLPs={this.state.obsTLPs}
-        obsIOCs={this.state.obsIOCs}
-        obsTags={this.state.obsTags}
-        obsSelected={this.state.obsSelected}
-        // event handlers
-        onTitleChange={this.onTitleChange}
-        onSeverityChange={this.onSeverityChange}
-        onTLPChange={this.onTLPChange}
-        onDescriptionChange={this.onDescriptionChange}
-        onObsDescrChange={this.onObsDescrChange}
-        onObsTLPChange={this.onObsTLPChange}
-        onObsIOCChange={this.onObsIOCChange}
-        onObsTagsChange={this.onObsTagsChange}
-        onObsSelectionChange={this.onObsSelectionChange}
-        submitCase={this.submitCase}
-      />;
+    this.resetForm();
+    this.setState({isWorking: false});
+     
+    // Open a new window with the case in The Hive
+    // (adding observables may take some time, so the case is opened first;
+    //  The Hive web is dynamic so the observables appear as they are added)
+    window.open(case_url, '_blank');
+    
+    if (observables.length == 0)
+      return;
+    
+    // Submit request to add observables
+    console.log("TheHiveButton: adding " + observables.length + " observables ...");
+    resp = await addCaseObservables(base_url, params.apikey, case_id, observables);
+    
+    if ('error' in resp) {
+      console.error("TheHiveButton: ERROR when trying to add observables: " + resp.error);
+      toastNotifications.addDanger("ERROR when trying to add observables: " + resp.error);
+    }
+    else {
+      console.log("TheHiveButton: Done, observables added.");
+      toastNotifications.add("Done, observables added.");
     }
-    return (
-      <div>
-        <EuiButton fill iconType="alert" color="danger" onClick={this.showModal}>Create new Case ...</EuiButton>
-        {modal}
-      </div>
-    );
   }
 }
 
 
 // The popup window with a form
+// props:
+//  - spinner: when true, disable form and show a spinner over it
 class ModalContent extends Component {
   constructor(props) {
     super(props);
@@ -344,79 +354,22 @@ class ModalContent extends Component {
       {value: "2", inputDisplay: "amber"},
       {value: "3", inputDisplay: "red"},
     ];
-    
-    // Table columns definition
-    this.columns = [
-      {
-        field: "id",
-        name: "Observable",
-      },
-      {
-        field: "descr",
-        name: "Description",
-        description: "Description of the observable in the context of the case",
-        render: (value, item) => (<EuiFieldText
-          value={item.descr}
-          onChange={(e) => this.props.onObsDescrChange(e, item.i)}
-          disabled={!item.selected}
-        />)
-      },
-      /*{
-        field: "tlp",
-        name: "TLP",
-        dataType: "number",
-        // TODO render and process changes
-      },*/
-      {
-        field: "ioc",
-        name: "Is IOC",
-        dataType: "boolean",
-        description: "Indicates if the observable is an IOC",
-        render: (value, item) => (<EuiCheckbox
-          id={"ioc-checkbox-"+item.id}
-          checked={item.ioc}
-          onChange={(e) => this.props.onObsIOCChange(e, item.i)}
-          disabled={!item.selected}
-        />)
-      },
-      /*{
-        field: "tags",
-        name: "Tags",
-        // TODO render and process changes
-      },*/
-    ]
-    
-    // Create a reference to observables table, so it's node can be accessed in componentDidMount
-    this.obsTableRef = React.createRef();
   }
   
   // Main render function
   render() {
-    const obs = this.props.observables;
-    // Create data for the observables table
-    let table_data = new Array(obs.length);
-    for (let i = 0; i < obs.length; i++) {
-      table_data[i] = {
-        id: obs[i],
-        descr: this.props.obsDescrs[i],
-        tlp: this.props.obsTLPs[i],
-        ioc: this.props.obsIOCs[i],
-        tags: this.props.obsTags[i],
-        // auxiliary fields, not shown in table:
-        i: i, // row index
-        selected: this.props.obsSelected.includes(i),
-      };
-    }
-    this.table_data = table_data; // needed in componentDidMount
-    
+    // TODO: replace Modal with Flyout?
+
+    // Note: onClick on EuiOverlayMask causes close of modal when clicked outside,
+    // implementation inspired by PR: https://github.com/elastic/eui/pull/3462/files#diff-c8fda532e48f75c94c343247cbc6b2d3R53-R60
     return (
-      <EuiOverlayMask>
+      <EuiOverlayMask onClick={(evt) => {if (evt.target.classList.contains("euiOverlayMask")) this.props.close();} }>
         <EuiModal onClose={this.props.close} maxWidth={false} initialFocus="[name=title]">
           <EuiModalHeader>
             <EuiModalHeaderTitle>Create a new case in The Hive</EuiModalHeaderTitle>
           </EuiModalHeader>
 
-          <EuiModalBody>
+          <EuiModalBody key={this.props.resetCnt}>
             <EuiForm style={{width: "800px"}}>
               <EuiFlexGroup>
                 <EuiFlexItem grow={1}>
@@ -453,157 +406,150 @@ class ModalContent extends Component {
                 />
               </EuiFormRow>
               
-              <EuiTitle size="s"><h3>Add observables from current query ...</h3></EuiTitle>
-              <EuiBasicTable
-                ref={this.obsTableRef}
-                columns={this.columns}
-                items={table_data}
-                itemId={(item) => item.id}
-                selection={ {onSelectionChange: this.props.onObsSelectionChange} }
-                noItemsMessage="No observables found"
-                rowProps={{
-                  // Hack to allow selection by clicking anywhere in the table row
-                  // (except input elements)
-                  onClick: (e) => {
-                    if (e.target.tagName != "INPUT") {
-                      // simulate click on the first checkbox in the row to (de)select the row
-                      e.currentTarget.querySelector("input").click();
-                      e.currentTarget.blur(); // without this the focus remains on the row after click (results in different color)
-                    }
-                  },
-                  tabIndex: "-1", // prevents focus on row by keyboard navigation
-                }}
-              />
+              {this.props.fields.length > 0 && <EuiTitle size="s"><h3>Add observables from current query ...</h3></EuiTitle>}
+              {this.props.fields.map((field,ix) => (
+                <ObservablesTable
+                  key={field.name + ":" + this.props.resetCnt}
+                  fieldName={field.name}
+                  observables={this.props.observables[field.name]}
+                  obsData={this.props.obsData[field.name]}
+                  obsSel={this.props.obsSel[field.name]}
+                  onObsSelectionChange={this.props.onObsSelectionChange}
+                  onObsDataChange={this.props.onObsDataChange}
+                />
+              ))}
             </EuiForm>
           </EuiModalBody>
 
           <EuiModalFooter>
-            <EuiButtonEmpty onClick={this.props.close}>Cancel</EuiButtonEmpty>
-            <EuiButton onClick={this.props.submitCase} fill>Create Case</EuiButton>
+            <EuiButtonEmpty onClick={this.props.close}>Close</EuiButtonEmpty>
+            <EuiButtonEmpty onClick={this.props.reset}>Reset</EuiButtonEmpty>
+            <EuiButton onClick={this.props.submitCase} fill isLoading={this.props.spinner}>Create Case</EuiButton>
           </EuiModalFooter>
         </EuiModal>
       </EuiOverlayMask>
     );
   }
+}
+
+// Table of potential observables taken from a given field, allowing to select
+// which observables to send to The Hive.
+// Props:
+//  fieldName - name of the field this table is for
+//  observables - list of observable IDs of this field
+//  obsData - array of objects specifying state of form fields in the table (.descr, .tlp, ...)
+//  obsSel - array of indices of selected observables
+class ObservablesTable extends Component {
+
+  constructor(props) {
+    super(props);
+    
+    // Table columns definition
+    this.columns = [
+      {
+        field: "id",
+        name: "Observable",
+      },
+      {
+        field: "descr",
+        name: "Description",
+        description: "Description of the observable in the context of the case",
+        render: (value, item1) => (<EuiFieldText
+          value={item1.descr}
+          onChange={(e) => this.props.onObsDataChange(props.fieldName, item1.i, "descr", e.target.value)}
+          disabled={!item1.selected}
+        />)
+      },
+      /*{
+        field: "tlp",
+        name: "TLP",
+        dataType: "number",
+        // TODO render and process changes
+      },*/
+      {
+        field: "ioc",
+        name: "Is IOC",
+        dataType: "boolean",
+        description: "Indicates if the observable is an IOC",
+        render: (value, item2) => (<EuiCheckbox
+          id={"ioc-checkbox-"+item2.id}
+          checked={item2.ioc}
+          onChange={(e) => this.props.onObsDataChange(props.fieldName, item2.i, "ioc", e.target.checked)}
+          disabled={!item2.selected}
+        />)
+      },
+      /*{
+        field: "tags",
+        name: "Tags",
+        // TODO render and process changes
+      },*/
+    ]
+    
+    // Create a reference to EuiBasicTable, so it's node can be accessed in componentDidMount
+    this.tableRef = React.createRef();
+  }
+
+  render() {
+    // Table data definition (convert props to format suitable for EuiBasicTable)
+    const n_obs = this.props.observables.length;
+    this.table_data = new Array(n_obs);
+    for (let i = 0; i < n_obs; i++) {
+      this.table_data[i] = {
+        id: this.props.observables[i],
+        descr: this.props.obsData[i].descr,
+        tlp: this.props.obsData[i].tlp,
+        ioc: this.props.obsData[i].ioc,
+        tags: this.props.obsData[i].tags,
+        // auxiliary fields, not shown in table:
+        i: i, // row index
+        selected: this.props.obsSel.includes(i),
+      };
+    }
+    
+    return (
+      <>
+        <EuiTitle size="xs"><h4>{this.props.fieldName}</h4></EuiTitle>
+        <EuiBasicTable
+          ref={this.tableRef}
+          columns={this.columns}
+          items={this.table_data}
+          itemId={(item3) => item3.id}
+          selection={ {onSelectionChange: (selectedItems) => this.props.onObsSelectionChange(this.props.fieldName, selectedItems) } }
+          noItemsMessage="No observables found"
+          rowProps={{
+            // Hack to allow selection by clicking anywhere in the table row
+            // (except input elements)
+            onClick: (e) => {
+              if (e.target.tagName != "INPUT") {
+                // simulate click on the first checkbox in the row to (de)select the row
+                e.currentTarget.querySelector("input").click();
+                e.currentTarget.blur(); // without this the focus remains on the row after click (results in different color)
+              }
+            },
+            tabIndex: "-1", // prevents focus on row by keyboard navigation
+          }}
+        />
+        <EuiSpacer size="l" />
+      </>
+    )
+  }
   
   componentDidMount() {
     // There's no way to specify initially selected items in EuiBasicTable by 
     // props, but we may need to select some (in case a user selects some obs.,
     // closes the modal and opens it again).
     // However, the selection is stored as a 'selection' field of table's state,
-    // so here we directly edit the state just after the table crated.
+    // so here we directly edit the state just after the table is created.
     
     // Prepare the 'selection' array - it should contain a list of selected row specifications
     let selection = [];
-    for (let i = 0; i < this.props.obsSelected.length; i++) {
-      selection.push(this.table_data[this.props.obsSelected[i]]);
+    for (let ix of this.props.obsSel) {
+      selection.push(this.table_data[ix]);
     }
     
     // Get ref to EuiBasicTable element and update its state 
-    const table_node = this.obsTableRef.current;
+    const table_node = this.tableRef.current;
     table_node.setState({selection: selection});
   }
 }
 
-
-// ********** Functions to send data to Kibana endpoints **********
-
-
-// Create a new Case in The Hive via its API
-// Return a Promise which resolves to object with ID of the new case ('id' attr) or error message ('error' attr)
-function createHiveCase(base_url, api_key, title, descr, severity, startDate, owner, flag, tlp, tags) {
-  // Prepare data
-  // TODO: check '/' at the end of base URL
-  var data = JSON.stringify({
-    "base_url": base_url,
-    "api_key": api_key,
-    "body": {
-      "title": title,
-      "description": descr,
-      "severity": severity, // number: 1=low, 2=medium, 3=high
-      "startDate": startDate,
-      "owner": owner, // user name the case will be assigned to
-      "flag": flag, // bool
-      "tlp": tlp, // number: 0=white, 1=green, 2=amber, 3=red
-      "tags": tags, // array of strings
-    }
-  });
-  console.log("TheHiveButton: Sending request to API endpoint 'new_case':", data);
-  var kibana_endpoint_url = chrome.addBasePath('/api/thehive_button/new_case');
-
-  return new Promise(function (resolve, reject) {
-    // Create AJAX request
-    var xhr = new XMLHttpRequest();
-    
-    // Listener to process reply
-    xhr.onreadystatechange = function () {
-      if (this.readyState != 4) {
-        return; // response not ready yet
-      }
-      if (this.status == 200) {
-        const resp = JSON.parse(this.responseText);
-        console.log("TheHiveButton: Response from backend:", resp);
-        if ("error" in resp) {
-          resolve({"error": resp.error});
-        }
-        else if (resp.status_code != 201) {
-          resolve({"error": "Unexpected reply received from The Hive: [" + resp.status_code + "] " + resp.status_msg});
-        }
-        else {
-          resolve({"id": resp.body.id}); // return ID of the new case
-        }
-      }
-      else {
-        console.log("TheHiveButton: Error " + this.status + ": " + this.statusText);
-        resolve({"error": "Error " + this.status + ": " + this.statusText});
-      }
-    }
-
-    // Send the AJAX request
-    xhr.open("POST", kibana_endpoint_url);
-    xhr.setRequestHeader("Content-Type", "application/json");
-    xhr.setRequestHeader("kbn-xsrf", "thehive_plugin"); // this header must be set, although its content is probably irrelevant
-    xhr.send(data);
-  });
-}
-
-// Add observables to an existing Case in The Hive
-// (send the list of observables to our backend endpoint, it pushes them to The Hive)
-function addCaseObservables(base_url, api_key, caseid, observables) {
-  const kibana_endpoint_url = chrome.addBasePath('/api/thehive_button/add_observables');
-  const data = JSON.stringify({
-    "base_url": base_url,
-    "api_key": api_key,
-    "caseid": caseid,
-    "observables": observables,
-  });
-  console.log("TheHiveButton: Sending request to API endpoint 'add_observables':", data);
-  
-  return new Promise(function (resolve, reject) {
-    // Create AJAX request
-    var xhr = new XMLHttpRequest();
-    
-    // Listener to process reply
-    xhr.onreadystatechange = function () {
-      if (this.readyState != 4) {
-        return; // response not ready yet
-      }
-      if (this.status == 200) {
-        const resp = JSON.parse(this.responseText);
-        console.log("TheHiveButton: Response from backend:", resp);
-        resolve(resp);
-      }
-      else {
-        console.log("TheHiveButton: Error " + this.status + ": " + this.statusText);
-        resolve({"error": "Error " + this.status + ": " + this.statusText});
-      }
-    }
-
-    // Send the AJAX request
-    xhr.open("POST", kibana_endpoint_url);
-    xhr.setRequestHeader("Content-Type", "application/json");
-    xhr.setRequestHeader("kbn-xsrf", "thehive_plugin"); // this header must be set, although its content is probably irrelevant
-    xhr.send(data);
-  });
-}
-
diff --git a/thehive_button/server/routes/newcase.js b/thehive_button/server/routes/newcase.js
index 6f796ee..175dee8 100644
--- a/thehive_button/server/routes/newcase.js
+++ b/thehive_button/server/routes/newcase.js
@@ -34,7 +34,6 @@ function newCaseHandler(req, resp) {
   //if (!base_url.match(/https?:\/\/.*\//)) {
     return {'error': 'Invalid base URL (it must begin with "http[s]" and end with "/")'};
   }
-  // TODO add "/" to the end automatically
   if (!api_key) {
     return {'error': 'API key not set'};
   }
@@ -123,7 +122,7 @@ function addObservablesHandler(req, resp) {
   
 function addObservable(base_url, api_key, caseid, obs) {
   return new Promise( function(resolve, reject) {
-    console.log("Adding observable:", obs);
+    //console.log("Adding observable:", obs);
     request({
         method: 'POST',
         url: base_url + 'api/case/' + caseid + "/artifact",
-- 
GitLab