Sfoglia il codice sorgente

feat(webapp): RRset GUI, closes #358

Create/modify/delete RRsets.
Nils Wisiol 5 anni fa
parent
commit
115b147a22

+ 43 - 12
webapp/src/components/Field/RRSet.vue

@@ -1,34 +1,47 @@
 <template>
   <div>
     <component
-      :is="getRecordComponentName(type)"
-      v-for="(record, index) in value"
-      :key="index"
-      :content="value[index]"
-      :clearable="value.length > 1"
-      @update:content="$set(value, index, $event)"
+            :is="getRecordComponentName(type)"
+            v-for="(item, index) in valueMap"
+            :key="item.id"
+            :content="item.content"
+            :hide-label="index > 0"
+            :append-icon="value.length > 1 ? 'mdi-close' : ''"
+            ref="inputFields"
+            @update:content="$set(value, index, $event)"
+            @remove="(e) => removeHandler(index, e)"
+            @keyup="(e) => $emit('keyup', e)"
     />
-    <code style="white-space: normal">{{ value }}</code>
+    <v-btn
+            @click="addHandler"
+            class="px-0 text-none"
+            color="grey"
+            small
+            text
+    ><v-icon>mdi-plus</v-icon> add another value</v-btn>
+    <!--div><code style="white-space: normal">{{ value }}</code></div-->
   </div>
 </template>
 
 <script>
 import Record from './Record.vue';
 import RecordA from './RecordA.vue';
+import RecordAAAA from './RecordAAAA.vue';
 import RecordCNAME from './RecordCNAME.vue';
+import RecordNS from './RecordNS.vue';
 import RecordMX from './RecordMX.vue';
 import RecordSRV from './RecordSRV.vue';
-import RecordNS from './RecordNS.vue';
 
 export default {
   name: 'RRSet',
   components: {
     Record,
     RecordA,
+    RecordAAAA,
     RecordCNAME,
     RecordMX,
-    RecordSRV,
     RecordNS,
+    RecordSRV,
   },
   props: {
     value: {
@@ -40,9 +53,27 @@ export default {
       required: true,
     },
   },
-  data: () => ({
-    types: ['A', 'AAAA', 'MX', 'CNAME', 'TXT', 'SPF', 'CAA', 'TLSA', 'OPENPGPKEY', 'PTR', 'SRV', 'DS', 'DNSKEY'],
-  }),
+  data: function () {
+    const self = this;
+    return {
+      removals: 0,
+      types: ['A', 'AAAA', 'MX', 'NS', 'CNAME', 'TXT', 'SPF', 'CAA', 'TLSA', 'OPENPGPKEY', 'PTR', 'SRV', 'DS'],
+      addHandler: () => {
+        self.value.push('');
+        self.$nextTick(() => self.$refs.inputFields[self.$refs.inputFields.length - 1].focus());
+      },
+      removeHandler: (index) => {
+        self.value.splice(index, 1);
+        self.removals++;
+      },
+    }
+  },
+  computed: {
+    // This is necessary to allow rerendering the list after record deletion. Otherwise, VueJS confuses record indexes.
+    valueMap: function () {
+      return this.value.map((v, k) => ({content: v, id: `${k}_${this.removals}`}));
+    },
+  },
   methods: {
     getRecordComponentName(type) {
       const genericComponentName = 'Record';

+ 14 - 1
webapp/src/components/Field/RRSetType.vue

@@ -1,6 +1,8 @@
 <template>
   <v-combobox
     :label="label"
+    :disabled="disabled || readonly"
+    :error-messages="errorMessages"
     :value="value"
     :items="types"
     @input="input($event)"
@@ -11,10 +13,22 @@
 export default {
   name: 'RRSetType',
   props: {
+    disabled: {
+      type: Boolean,
+      required: false,
+    },
+    errorMessages: {
+      type: [String, Array],
+      default: () => [],
+    },
     label: {
       type: String,
       required: false,
     },
+    readonly: {
+      type: Boolean,
+      required: false,
+    },
     value: {
       type: String,
       required: true,
@@ -34,7 +48,6 @@ export default {
       'PTR',
       'SRV',
       'DS',
-      'DNSKEY',
     ],
   }),
   methods: {

+ 34 - 7
webapp/src/components/Field/Record.vue

@@ -1,20 +1,24 @@
 <template>
-  <v-layout>
+  <v-layout class="flex">
     <div
       v-for="(field, index) in fields"
       :key="index"
+      :class="index == fields.length - 1 ? 'flex-grow-1' : ''"
     >
       <v-text-field
         ref="input"
         v-model="field.value"
-        :clearable="clearable"
-        :label="field.label"
+        :label="hideLabel ? '' : field.label"
+        :class="hideLabel ? 'pt-0' : ''"
         placeholder=" "
-        :hide-details="!$v.fields.$each[index].$invalid && !$v.fields[index].$invalid"
+        :hide-details="!content.length || (!$v.fields.$each[index].$invalid && !$v.fields[index].$invalid)"
         :error="$v.fields.$each[index].$invalid || $v.fields[index].$invalid"
         :error-messages="fieldErrorMessages(index)"
         :style="{ width: fieldWidth(index) }"
+        :append-icon="index == fields.length-1 ? appendIcon : ''"
+        @click:append="() => $emit('remove')"
         @input="inputHandler()"
+        @paste.prevent="pasteHandler($event)"
         @keydown.8="backspaceHandler(index, $event)"
         @keydown.32="spaceHandler(index, $event)"
         @keydown.35="endHandler($event)"
@@ -22,6 +26,7 @@
         @keydown.37="leftHandler(index, $event)"
         @keydown.39="rightHandler(index, $event)"
         @keydown.46="deleteHandler(index, $event)"
+        @keyup="(e) => $emit('keyup', e)"
       />
       <span
         ref="mirror"
@@ -44,17 +49,21 @@ export default {
       type: String,
       required: true,
     },
-    clearable: {
+    hideLabel: {
       type: Boolean,
       default: false,
     },
+    appendIcon: {
+      type: String,
+      required: false,
+    },
   },
   data: () => ({
     errors: {
       required: ' ',
     },
     fields: [
-      { label: 'Content', validations: {} },
+      { label: 'Value', validations: {} },
     ],
     value: '',
   }),
@@ -137,6 +146,9 @@ export default {
       }
       return ret;
     },
+    focus() {
+      this.$refs.input[0].focus();
+    },
     async update(value, caretPosition) {
       await this.$nextTick();
 
@@ -161,7 +173,7 @@ export default {
       this.updateFields();
 
       if (caretPosition !== undefined) {
-        this.setPosition(caretPosition);
+        await this.setPosition(caretPosition);
       }
     },
     positionAfterDelimiter(index) {
@@ -232,6 +244,21 @@ export default {
       const value = this.fields.map(field => field.value).join(' ');
       this.update(value, pos);
     },
+    pasteHandler(e) {
+      let value = this.fields.map(field => field.value).join(' ');
+      const pos = this.getPosition();
+      const clipboardData = e.clipboardData.getData('text');
+
+      // number of fields covered by this paste, minus 1 (given by number of spaces in the clipboard text, bounded from
+      // above by the number of fields (minus 1) at or to the right of the caret position
+      const nBeforeCaret = (value.slice(0, pos).match(/ /g) || []).length
+      const n = Math.min((clipboardData.match(/ /g) || []).length, this.fields.length - 1 - nBeforeCaret);
+
+      // Insert clipboard text and remove up to n spaces form the right
+      value = value.slice(0, pos) + clipboardData + value.slice(pos);
+      value = value.replace(new RegExp(` {0,${n}}$`,'g'), '');
+      this.update(value, pos + clipboardData.length);
+    },
     async setPosition(pos) {
       await this.$nextTick();
       let i = 0;

+ 20 - 0
webapp/src/components/Field/RecordAAAA.vue

@@ -0,0 +1,20 @@
+<script>
+import { helpers } from 'vuelidate/lib/validators';
+import Record from './Record.vue';
+
+// from https://stackoverflow.com/a/17871737/6867099, without the '%' and '.' parts
+const ip6Address = helpers.regex('ip6Address', /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/);
+
+export default {
+  name: 'RecordAAAA',
+  extends: Record,
+  data: () => ({
+    errors: {
+      ip6Address: 'This field must contain an IPv6 address.',
+    },
+    fields: [
+      { label: 'IPv6 address', validations: { ip6Address } },
+    ],
+  }),
+};
+</script>

+ 1 - 1
webapp/src/components/Field/RecordMX.vue

@@ -2,7 +2,7 @@
 import { helpers, integer, between } from 'vuelidate/lib/validators';
 import Record from './Record.vue';
 
-const hostname = helpers.regex('hostname', /^((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))[.]?$/);
+const hostname = helpers.regex('hostname', /^(([a-zA-Z0-9-]+\.?)+)|\.$/);
 const trailingDot = helpers.regex('trailingDot', /[.]$/);
 
 const MAX16 = 65535;

+ 1 - 1
webapp/src/components/Field/RecordNS.vue

@@ -6,7 +6,7 @@ export default {
   extends: Record,
   data: () => ({
     fields: [
-      { label: 'Hostname', value: '', validations: {} },
+      { label: 'Hostname', validations: {} },
     ],
   }),
 };

+ 4 - 1
webapp/src/components/Field/TimeAgo.vue

@@ -1,5 +1,8 @@
 <template>
-  <span :title="value">{{ value ? timeAgo.format(new Date(value)) : 'never' }}</span>
+  <div
+          class="my-1 py-3"
+          :title="value"
+  >{{ value ? timeAgo.format(new Date(value)) : 'never' }}</div>
 </template>
 
 <script>

+ 7 - 1
webapp/src/router/index.js

@@ -115,7 +115,13 @@ const routes = [
     name: 'domains',
     component: () => import(/* webpackChunkName: "gui" */ '../views/DomainList.vue'),
     meta: {guest: false},
-  }
+  },
+  {
+    path: '/domains/:domain',
+    name: 'domain',
+    component: () => import(/* webpackChunkName: "gui" */ '../views/Domain/CrudDomain.vue'),
+    meta: {guest: false},
+  },
 ]
 
 const router = new VueRouter({

+ 90 - 0
webapp/src/views/Console/Confirmation.vue

@@ -0,0 +1,90 @@
+<template>
+  <v-dialog
+    v-model="value"
+    max-width="500px"
+    persistent
+    @keydown.esc="close"
+  >
+    <v-card>
+      <v-card-title>
+        <div class="title">
+          {{ title }}
+        </div>
+        <v-spacer />
+        <v-icon @click.stop="close">
+          mdi-close
+        </v-icon>
+      </v-card-title>
+      <v-divider />
+      <v-alert
+        :value="warning.length"
+        type="warning"
+      >
+        {{ warning }}
+      </v-alert>
+      <v-alert
+        :value="info.length"
+        type="info"
+      >
+        {{ info }}
+      </v-alert>
+      <v-card-text>
+        <slot />
+      </v-card-text>
+      <v-card-actions class="pa-3">
+        <v-btn
+          color="primary"
+          class="grow"
+          outline
+          @click.native="close"
+        >
+          Cancel
+        </v-btn>
+        <v-btn
+          color="primary"
+          class="grow"
+          dark
+          depressed
+          @click.native="run"
+        >
+          Yes, please
+        </v-btn>
+      </v-card-actions>
+    </v-card>
+  </v-dialog>
+</template>
+
+<script>
+export default {
+  name: 'Confirmation',
+  props: {
+    callback: undefined,
+    args: undefined,
+    value: Boolean,
+    title: {
+      type: String,
+      required: true,
+    },
+    info: {
+      type: String,
+      default: '',
+    },
+    warning: {
+      type: String,
+      default: '',
+    },
+  },
+  methods: {
+    close() {
+      this.$emit('input', false);
+    },
+    run() {
+      this.callback.apply(undefined, this.args);
+      this.close();
+    },
+  },
+};
+</script>
+
+<style>
+</style>

+ 15 - 3
webapp/src/views/CrudList.vue

@@ -1,7 +1,7 @@
 <template>
   <v-container class="fill-height" fluid>
     <v-row align="center" justify="center">
-      <v-col cols="12" sm="10">
+      <v-col cols="12" :sm="fullWidth ? '12' : '10'">
         <v-card>
           <!-- Error Snackbar -->
           <v-snackbar
@@ -38,6 +38,7 @@
                   :custom-filter="filterSearchableCols"
                   :loading="$store.getters.working || createDialogWorking || destroyDialogWorking"
                   class="elevation-1"
+                  @click:row="rowClick"
           >
             <template slot="top">
               <!-- Headline & Toolbar, Including New Form -->
@@ -175,7 +176,7 @@
             </template>
             <template v-slot:item.actions="itemFieldProps">
               <v-layout
-                      align-center
+                      class="my-1 py-3"
                       justify-end
               >
                 <v-btn
@@ -304,6 +305,7 @@ import RRSetType from '@/components/Field/RRSetType';
 import TimeAgo from '@/components/Field/TimeAgo';
 import Code from '@/components/Field/Code';
 import GenericText from '@/components/Field/GenericText';
+import Record from '@/components/Field/Record';
 import RRSet from '@/components/Field/RRSet';
 
 // safely access deeply nested objects
@@ -316,6 +318,7 @@ export default {
     TimeAgo,
     Code,
     GenericText,
+    Record,
     RRSet,
   },
   data() { return {
@@ -332,6 +335,7 @@ export default {
     errors: [],
     extraComponentName: '',
     extraComponentBind: {},
+    fullWidth: false,
     snackbar: false,
     snackbarInfoText: '',
     search: '',
@@ -377,6 +381,7 @@ export default {
         e.target.closest('tr').querySelector('.mdi-content-save-edit').closest('button').click();
       }
     },
+    handleRowClick: () => {},
   }},
   computed: {
     createInhibited: () => false,
@@ -387,6 +392,7 @@ export default {
         sortable: false,
         align: 'right',
         value: 'actions',
+        width: '130px',
       });
       return cols; // data table expects an array
     },
@@ -419,6 +425,9 @@ export default {
     clearErrors(c) {
       c.createErrors = [];
     },
+    rowClick(value) {
+      this.handleRowClick(value);
+    },
     /** *
      * Ask the user to delete the given item.
      * @param item
@@ -570,7 +579,7 @@ export default {
       // TODO only search searchable columns
       return value != null &&
               search != null &&
-              typeof value === 'string' &&
+              typeof value !== 'boolean' &&
               value.toString().toLocaleLowerCase().indexOf(search.toLocaleLowerCase()) !== -1
     },
   },
@@ -595,4 +604,7 @@ export default {
   >>> tr:focus-within :focus {
     background-color: #FFFFFF;
   }
+  >>> tbody tr > :hover {
+    cursor: pointer;
+  }
 </style>

+ 131 - 0
webapp/src/views/Domain/CrudDomain.vue

@@ -0,0 +1,131 @@
+<script>
+import CrudList from '@/views/CrudList';
+
+export default {
+  name: 'CrudDomain',
+  extends: CrudList,
+  data: function () {
+    const self = this;
+    return {
+      fullWidth: true,
+      createable: true,
+      updatable: true,
+      destroyable: true,
+      headlines: {
+        table: `Record Sets ${self.$route.params.domain}`,
+        create: `Create New Record Set (${self.$route.params.domain})`,
+        destroy: 'Delete Record Set',
+      },
+      texts: {
+        banner: () => 'You can edit your DNS records here. As this feature is new, we would like to gather your feedback. Feel free to post in <a href="https://talk.desec.io/" target="_blank">our forum</a>, or shoot us an email.',
+        create: () => ('Create a record set'),
+        destroy: rrset => (`Delete record set ${rrset.type} ${rrset.subname}?`),
+        destroyInfo: () => ('This operation will permanently remove this information from the DNS.'),
+      },
+      columns: {
+        type: {
+          name: 'item.type',
+          text: 'Type',
+          textCreate: 'Record Set Type',
+          align: 'left',
+          sortable: true,
+          value: 'type',
+          readonly: true,
+          datatype: 'RRSetType',
+          searchable: true,
+          writeOnCreate: true,
+          width: '130px',
+        },
+        subname: {
+          name: 'item.subname',
+          text: 'Subname',
+          align: 'left',
+          sortable: true,
+          value: 'subname',
+          readonly: true,
+          datatype: 'GenericText',
+          searchable: true,
+          writeOnCreate: true,
+          width: '200px',
+        },
+        records: {
+          name: 'item.records',
+          text: 'Content',
+          textCreate: 'Record Set Content',
+          align: 'left',
+          sortable: false,
+          value: 'records',
+          readonly: false,
+          datatype: 'RRSet',
+          fieldProps: rrSet => ({ type: rrSet.type || 'A' }),
+          searchable: true,
+        },
+        ttl: {
+          name: 'item.ttl',
+          text: 'TTL',
+          align: 'left',
+          sortable: true,
+          value: 'ttl',
+          readonly: false,
+          datatype: 'GenericText', // TODO TTL is not a String
+          fieldProps: () => ({ type: 'number' }),
+          searchable: true,
+          width: '130px',
+        },
+        touched: {
+          name: 'item.touched',
+          text: 'Last touched',
+          align: 'left',
+          sortable: true,
+          value: 'touched',
+          readonly: true,
+          datatype: 'TimeAgo',
+          searchable: false,
+          width: '130px',
+        },
+      },
+      actions: [
+      ],
+      paths: {
+        list: 'domains/::{domain}/rrsets/', // TODO dangerous?
+        create: 'domains/::{domain}/rrsets/',
+        delete: 'domains/::{domain}/rrsets/:{subname}.../:{type}/',
+        update: 'domains/::{domain}/rrsets/:{subname}.../:{type}/',
+      },
+      itemDefaults: () => ({
+        type: 'A', subname: '', records: [''], ttl: 3600,
+      }),
+    }
+  },
+};
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+    >>> td {
+        vertical-align: top;
+    }
+    >>> .v-data-table .v-data-table__mobile-row {
+        height: auto;
+        margin: -11px 0;
+    }
+    >>> .theme--light.v-data-table > .v-data-table__wrapper > table > tbody > tr:not(:last-child).v-data-table__mobile-table-row > td:last-child {
+        border-bottom-width: 4px;
+    }
+
+    >>> tr.successFade td {
+        animation: successFade 1s;
+    }
+    >>> tr.successFade:focus-within td {
+        animation: none;
+    }
+    @keyframes successFade {
+        from { background-color: forestgreen; }
+    }
+    >>> tr:focus-within .mdi-content-save-edit {
+        color: forestgreen;
+    }
+    >>> tr:focus-within :focus {
+        background-color: #FFFFFF;
+    }
+</style>

+ 4 - 1
webapp/src/views/DomainList.vue

@@ -22,7 +22,7 @@ export default {
           destroy: 'Domain Deletion',
         },
         texts: {
-          banner: () => ('You can create and delete domains here. We will soon extend our GUI to offer DNS record management. In the meantime, please <a href="https://desec.readthedocs.io/en/latest/dns/rrsets.html" target="_blank">use the API</a> to manage records.'),
+          banner: () => '<b>New:</b> You can now edit your DNS records using the GUI. To get started, click on one of your domains.',
           create: () => `You have ${self.availableCount} of ${self.limit_domains} domains left.`,
           createWarning: () => (self.availableCount <= 0 ? 'You have reached your maximum number of domains. Please contact support to apply for a higher limit.' : ''),
           destroy: d => (`Delete domain ${d.name}?`),
@@ -92,6 +92,9 @@ export default {
           }
           this.extraComponentName = 'DomainDetailsDialog';
         },
+        handleRowClick: (value) => {
+          this.$router.push({name: 'domain', params: {domain: value.name}});
+        },
     }
   },
   computed: {