Browse Source

feat(newtab): add logs page

Ahmad Kholid 3 years ago
parent
commit
3ec25162f9

+ 12 - 16
src/background/blocks-handler.js

@@ -178,22 +178,18 @@ export function interactionHandler(block) {
       delay: block.name === 'link' ? 5000 : 0,
       callback: (data) => {
         if (objectHasKey(block.data, 'dataColumn')) {
-          const column = Object.values(this.workflow.dataColumns).find(
-            ({ name }) => name === block.data.dataColumn
-          );
-
-          if (column) {
-            const { name } = column;
-
-            if (!objectHasKey(this.data, name)) this.data[name] = [];
-
-            if (Array.isArray(data)) {
-              data.forEach((item) => {
-                this.data[name].push(convertData(item, column.type));
-              });
-            } else {
-              this.data[name].push(convertData(data, column.type));
-            }
+          const { name, type } = Object.values(this.workflow.dataColumns).find(
+            (item) => item.name === block.data.dataColumn
+          ) || { name: 'column', type: 'text' };
+
+          if (!objectHasKey(this.data, name)) this.data[name] = [];
+
+          if (Array.isArray(data)) {
+            data.forEach((item) => {
+              this.data[name].push(convertData(item, type));
+            });
+          } else {
+            this.data[name].push(convertData(data, type));
           }
         }
 

+ 7 - 3
src/background/index.js

@@ -14,6 +14,10 @@ function getWorkflow(workflowId) {
 }
 async function executeWorkflow(workflow) {
   try {
+    const state = await workflowState.get(({ id }) => id === workflow.id);
+
+    if (state.length !== 0) return false;
+
     const engine = new WorkflowEngine(workflow);
 
     engine.init();
@@ -38,11 +42,11 @@ browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
 
       return tab.url.match(isRegex ? new RegExp(url, 'g') : url);
     });
-    const runningWorkflow = (await workflowState.get()).find(
+    const runningWorkflow = await workflowState.get(
       (item) => item.state.tabId === tabId
     );
 
-    if (trigger && !runningWorkflow) {
+    if (trigger && runningWorkflow.length === 0) {
       const workflow = await getWorkflow(trigger.id);
 
       executeWorkflow(workflow);
@@ -72,4 +76,4 @@ const message = new MessageListener('background');
 
 message.on('workflow:execute', executeWorkflow);
 
-chrome.runtime.onMessage.addListener(message.listener());
+browser.runtime.onMessage.addListener(message.listener());

+ 19 - 11
src/background/workflow-engine.js

@@ -28,6 +28,10 @@ function tabRemovedHandler(tabId) {
 
   delete this.connectedTab;
   delete this.tabId;
+
+  if (tasks[this.currentBlock.name].category === 'interaction') {
+    this.destroy('error');
+  }
 }
 function tabUpdatedHandler(tabId, changeInfo) {
   const listener = this.tabUpdatedListeners[tabId];
@@ -116,9 +120,14 @@ class WorkflowEngine {
     this.blocks = blocks;
     this.startedTimestamp = Date.now();
 
-    workflowState.add(this.id, this.state).then(() => {
-      this._blockHandler(triggerBlock);
-    });
+    workflowState
+      .add(this.id, {
+        workflowId: this.workflow.id,
+        state: this.state,
+      })
+      .then(() => {
+        this._blockHandler(triggerBlock);
+      });
   }
 
   on(name, listener) {
@@ -130,7 +139,7 @@ class WorkflowEngine {
   pause(pause = true) {
     this.isPaused = pause;
 
-    workflowState.update(this.tabId, this.state);
+    workflowState.update(this.id, this.state);
   }
 
   stop(message) {
@@ -158,7 +167,7 @@ class WorkflowEngine {
       this.endedTimestamp = Date.now();
 
       if (!this.workflow.isTesting) {
-        const { logs } = browser.storage.local.get('logs');
+        const { logs } = await browser.storage.local.get('logs');
         const { name, icon, id } = this.workflow;
 
         logs.push({
@@ -219,10 +228,9 @@ class WorkflowEngine {
       return;
     }
 
-    this.workflowTimeout = setTimeout(
-      () => this.stop('Workflow stopped because of timeout'),
-      this.workflow.settings.timeout || 120000
-    );
+    this.workflowTimeout = setTimeout(() => {
+      if (!this.isDestroyed) this.stop('Workflow stopped because of timeout');
+    }, this.workflow.settings.timeout || 120000);
 
     workflowState.update(this.id, this.state);
     console.log(this.logs);
@@ -255,7 +263,7 @@ class WorkflowEngine {
               name: 'Finish',
             });
             this.dispatchEvent('finish');
-            this.destroy();
+            this.destroy('success');
             console.log('Done', this);
           }
 
@@ -278,7 +286,7 @@ class WorkflowEngine {
               error.data || ''
             );
           } else {
-            this.stop();
+            this.destroy('error');
           }
 
           clearTimeout(this.workflowTimeout);

+ 10 - 8
src/background/workflow-state.js

@@ -18,13 +18,15 @@ async function updater(callback, id) {
 }
 
 class WorkflowState {
-  static async get() {
+  static async get(filter) {
     try {
-      const { workflowState } = await browser.storage.local.get(
-        'workflowState'
-      );
+      let { workflowState } = await browser.storage.local.get('workflowState');
 
-      return workflowState || [];
+      if (filter) {
+        workflowState = workflowState.filter(filter);
+      }
+
+      return workflowState;
     } catch (error) {
       console.error(error);
 
@@ -32,9 +34,9 @@ class WorkflowState {
     }
   }
 
-  static add(id, state) {
+  static add(id, data) {
     return updater.call(this, (items) => {
-      items.push({ id, state });
+      items.push({ id, ...data });
 
       return items;
     });
@@ -58,7 +60,7 @@ class WorkflowState {
     return updater.call(
       this,
       (items, index) => {
-        if (index === -1) items.splice(index, 1);
+        if (index !== -1) items.splice(index, 1);
 
         return items;
       },

+ 2 - 2
src/components/newtab/workflow/WorkflowCard.vue

@@ -5,7 +5,7 @@
         <v-remixicon name="riGlobalLine" />
       </span>
       <div class="flex-grow"></div>
-      <button>
+      <button @click="$emit('execute', workflow)">
         <v-remixicon name="riPlayLine" />
       </button>
       <ui-popover v-if="showDetails" class="ml-2 h-6">
@@ -59,7 +59,7 @@ const props = defineProps({
     default: true,
   },
 });
-defineEmits(['delete', 'rename']);
+defineEmits(['delete', 'rename', 'execute']);
 
 const formatDate = () => dayjs(props.workflow.createdAt).fromNow();
 </script>

+ 4 - 0
src/lib/v-remixicon.js

@@ -1,6 +1,8 @@
 import vRemixicon from 'v-remixicon';
 import {
   riHome5Line,
+  riCalendarLine,
+  riFileTextLine,
   riArrowGoBackLine,
   riArrowGoForwardLine,
   riDatabase2Line,
@@ -56,6 +58,8 @@ import {
 
 export const icons = {
   riHome5Line,
+  riCalendarLine,
+  riFileTextLine,
   riArrowGoBackLine,
   riArrowGoForwardLine,
   riDatabase2Line,

+ 3 - 1
src/lib/vuex-orm.js

@@ -1,7 +1,9 @@
 import VuexORM, { Query } from '@vuex-orm/core';
 
 function callback(model, param, entity) {
-  this.store.dispatch('saveToStorage', entity);
+  if (this.baseModel.autoSave) {
+    this.store.dispatch('saveToStorage', entity);
+  }
 }
 
 Query.on('afterUpdate', callback);

+ 1 - 1
src/models/index.js

@@ -1,2 +1,2 @@
 export { default as Workflow } from './workflow';
-export { default as Task } from './task';
+export { default as Log } from './log';

+ 22 - 0
src/models/log.js

@@ -0,0 +1,22 @@
+import { Model } from '@vuex-orm/core';
+import { nanoid } from 'nanoid';
+
+class Log extends Model {
+  static entity = 'logs';
+
+  static fields() {
+    return {
+      id: this.uid(() => nanoid()),
+      data: this.attr({}),
+      name: this.string(''),
+      history: this.attr([]),
+      endedAt: this.number(0),
+      startedAt: this.number(0),
+      workflowId: this.attr(null),
+      status: this.string('success'),
+      icon: this.string('riGlobalLine'),
+    };
+  }
+}
+
+export default Log;

+ 0 - 26
src/models/task.js

@@ -1,26 +0,0 @@
-import { Model } from '@vuex-orm/core';
-import { nanoid } from 'nanoid';
-
-class Task extends Model {
-  static entity = 'tasks';
-
-  static fields() {
-    return {
-      id: this.uid(() => nanoid()),
-      description: this.string(''),
-      type: this.string(''),
-      data: this.attr(null),
-      workflowId: this.attr(null),
-    };
-  }
-
-  static async insert(payload) {
-    const res = await super.insert(payload);
-
-    await this.store().dispatch('saveToStorage', 'tasks');
-
-    return res;
-  }
-}
-
-export default Task;

+ 4 - 2
src/models/workflow.js

@@ -1,12 +1,14 @@
 import { Model } from '@vuex-orm/core';
 import { nanoid } from 'nanoid';
-import Task from './task';
+import Log from './log';
 
 class Workflow extends Model {
   static entity = 'workflows';
 
   static primaryKey = 'id';
 
+  static autoSave = true;
+
   static fields() {
     return {
       id: this.uid(() => nanoid()),
@@ -21,7 +23,7 @@ class Workflow extends Model {
         timeout: 120000,
         onError: 'stop-workflow',
       }),
-      tasks: this.hasMany(Task, 'workflowId'),
+      logs: this.hasMany(Log, 'workflowId'),
     };
   }
 

+ 19 - 1
src/newtab/App.vue

@@ -8,6 +8,7 @@
 <script setup>
 import { ref } from 'vue';
 import { useStore } from 'vuex';
+import browser from 'webextension-polyfill';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 
 const store = useStore();
@@ -15,10 +16,27 @@ const retrieved = ref(false);
 
 store
   .dispatch('retrieve', ['workflows', 'logs'])
-  .then(() => {
+  .then((res) => {
+    console.log(res);
     retrieved.value = true;
   })
   .catch(() => {
     retrieved.value = true;
   });
+
+function handleStorageChanged(change) {
+  console.log('testing', change);
+  if (change.logs) {
+    store.dispatch('entities/create', {
+      entity: 'logs',
+      data: change.logs.newValue,
+    });
+  }
+}
+
+browser.storage.local.onChanged.addListener(handleStorageChanged);
+
+window.addEventListener('beforeunload', () => {
+  browser.storage.local.onChanged.removeListener(handleStorageChanged);
+});
 </script>

+ 6 - 1
src/newtab/pages/Workflows.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="container pt-8 pb-4">
-    <h1 class="text-2xl font-semibold mb-8">Workflows</h1>
+    <h1 class="text-2xl font-semibold mb-6">Workflows</h1>
     <div class="flex items-center mb-6 space-x-4">
       <ui-input
         v-model="state.query"
@@ -46,6 +46,7 @@
         v-bind="{ workflow }"
         @delete="deleteWorkflow"
         @rename="renameWorkflow"
+        @execute="executeWorkflow"
       />
     </div>
   </div>
@@ -53,6 +54,7 @@
 <script setup>
 import { computed, shallowReactive } from 'vue';
 import { useDialog } from '@/composable/dialog';
+import { sendMessage } from '@/utils/message';
 import WorkflowCard from '@/components/newtab/workflow/WorkflowCard.vue';
 import Workflow from '@/models/workflow';
 
@@ -78,6 +80,9 @@ const workflows = computed(() =>
     .get()
 );
 
+function executeWorkflow(workflow) {
+  sendMessage('workflow:execute', workflow, 'background');
+}
 function newWorkflow() {
   dialog.prompt({
     title: 'New workflow',

+ 159 - 1
src/newtab/pages/logs.vue

@@ -1,5 +1,163 @@
 <template>
   <div class="container pt-8 pb-4">
-    <p>logs.......</p>
+    <h1 class="text-2xl font-semibold mb-6">Logs</h1>
+    <div class="flex items-center mb-6 space-x-4">
+      <ui-input
+        v-model="state.query"
+        prepend-icon="riSearch2Line"
+        placeholder="Search..."
+        class="flex-1"
+      />
+      <div class="flex items-center workflow-sort">
+        <ui-button
+          icon
+          class="rounded-r-none border-gray-300 border-r"
+          @click="state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc'"
+        >
+          <v-remixicon
+            :name="state.sortOrder === 'asc' ? 'riSortAsc' : 'riSortDesc'"
+          />
+        </ui-button>
+        <ui-select v-model="state.sortBy" placeholder="Sort by">
+          <option v-for="sort in sorts" :key="sort.id" :value="sort.id">
+            {{ sort.name }}
+          </option>
+        </ui-select>
+      </div>
+      <ui-select v-model="state.filterBy" placeholder="Filter by status">
+        <option v-for="filter in filters" :key="filter" :value="filter">
+          {{ filter }}
+        </option>
+      </ui-select>
+    </div>
+    <table class="w-full logs-table">
+      <tbody>
+        <tr v-for="log in logs" :key="log.id" class="hoverable border-b">
+          <td style="min-width: 150px">
+            {{ log.name }}
+          </td>
+          <td>
+            <span
+              :class="statusColors[log.status]"
+              class="p-1 text-sm rounded-lg w-16 inline-block text-center"
+            >
+              {{ log.status }}
+            </span>
+          </td>
+          <td class="log-time">
+            <v-remixicon
+              name="riCalendarLine"
+              title="Started date"
+            ></v-remixicon>
+            <span :title="formatDate(log.startedAt, 'DD MMM YYYY, hh:mm A')">
+              {{ formatDate(log.startedAt, 'relative') }}
+            </span>
+          </td>
+          <td class="log-time" title="Duration">
+            <v-remixicon name="riTimerLine"></v-remixicon>
+            <span>{{ getDuration(log.startedAt, log.endedAt) }}</span>
+          </td>
+          <td>
+            <div
+              v-if="log.data"
+              class="flex items-center justify-end space-x-4"
+            >
+              <v-remixicon
+                v-if="Object.keys(log.data).length !== 0"
+                name="riFileTextLine"
+                class="cursor-pointer"
+              />
+              <v-remixicon
+                name="riDeleteBin7Line"
+                class="text-red-500 cursor-pointer"
+                title="Delete log"
+                @click="deleteLog(log.id)"
+              />
+            </div>
+          </td>
+        </tr>
+      </tbody>
+    </table>
   </div>
 </template>
+<script setup>
+import { shallowReactive, computed } from 'vue';
+import { useStore } from 'vuex';
+import dayjs from '@/lib/dayjs';
+import Log from '@/models/log';
+
+const filters = ['all', 'success', 'stopped', 'error'];
+const sorts = [
+  { id: 'name', name: 'Alphabetical' },
+  { id: 'startedAt', name: 'Created date' },
+];
+const statusColors = {
+  error: 'bg-red-200',
+  success: 'bg-green-200',
+  stopped: 'bg-yellow-200',
+};
+
+const store = useStore();
+
+const state = shallowReactive({
+  query: '',
+  filterBy: 'all',
+  sortOrder: 'desc',
+  sortBy: 'startedAt',
+});
+
+const logs = computed(() =>
+  Log.query()
+    .where(({ name, status }) => {
+      const searchFilter = name
+        .toLocaleLowerCase()
+        .includes(state.query.toLocaleLowerCase());
+
+      if (state.filterBy !== 'all') {
+        return searchFilter && status === state.filterBy;
+      }
+
+      return searchFilter;
+    })
+    .orderBy(state.sortBy, state.sortOrder)
+    .get()
+);
+
+function formatDate(date, format) {
+  if (format === 'relative') return dayjs(date).fromNow();
+
+  return dayjs(date).format(format);
+}
+function getDuration(started, ended) {
+  const duration = dayjs(ended).diff(started, 'second');
+  const minutes = parseInt((duration / 60) % 60, 10);
+  const seconds = parseInt(duration % 60, 10);
+
+  const getText = (num, suffix) => (num > 0 ? `${num}${suffix}` : '');
+  console.log(duration, minutes, seconds);
+  console.log(started, ended);
+  return `${getText(minutes, 'm')} ${getText(seconds, 's')}`;
+}
+function deleteLog(id) {
+  Log.delete(id).then(() => {
+    store.dispatch('saveToStorage', 'logs');
+  });
+}
+</script>
+<style scoped>
+.logs-table td {
+  @apply p-2;
+}
+
+.log-time {
+  @apply text-gray-600 dark:text-gray-200;
+}
+.log-time svg {
+  @apply mr-2;
+}
+.log-time svg,
+.log-time span {
+  display: inline-block;
+  vertical-align: middle;
+}
+</style>

+ 3 - 1
src/store/index.js

@@ -15,10 +15,12 @@ const store = createStore({
             data: data[entity],
           })
         );
+        const result = await Promise.allSettled(promises);
 
-        await Promise.allSettled(promises);
+        return result;
       } catch (error) {
         console.error(error);
+        return [];
       }
     },
     saveToStorage({ getters }, key) {

+ 15 - 32
src/utils/message.js

@@ -22,50 +22,33 @@ export class MessageListener {
     return this.listen.bind(this);
   }
 
-  listen(message, sender, sendResponse) {
+  listen(message, sender) {
     try {
       const listener = this.listeners[message.name];
-
+      console.log(listener, this.listeners);
       const response =
         listener && listener.call({ message, sender }, message.data, sender);
 
       if (!response) {
-        // Do nothing
-      } else if (!(response instanceof Promise)) {
-        sendResponse(response);
-      } else {
-        response
-          .then((res) => {
-            sendResponse(res);
-          })
-          .catch((res) => {
-            sendResponse(res);
-          });
+        return Promise.resolve();
+      }
+      if (!(response instanceof Promise)) {
+        return Promise.resolve(response);
       }
+      return response;
     } catch (err) {
-      sendResponse({
-        error: true,
-        message: `Unhandled Background Error: ${String(err)}`,
-      });
+      return Promise.reject(
+        new Error(`Unhandled Background Error: ${String(err)}`)
+      );
     }
   }
 }
 
 export function sendMessage(name = '', data = {}, prefix = '') {
-  return new Promise((resolve, reject) => {
-    const payload = {
-      name: nameBuilder(prefix, name),
-      data,
-    };
+  const payload = {
+    name: nameBuilder(prefix, name),
+    data,
+  };
 
-    browser.runtime
-      .sendMessage(payload)
-      .then((response) => {
-        if (response.error) reject(new Error(response.message));
-        else resolve(response);
-      })
-      .catch((error) => {
-        reject(error);
-      });
-  });
+  return browser.runtime.sendMessage(payload);
 }