~arx10/procustodibus-app

7040334cedb21e02c8cc0d5387b86e4d9bc6cb0c — Justin Ludwig a month ago e43ef02
share common host-bulk-upload/download fns
M src/components/host/host-bulk-download-panel.vue => src/components/host/host-bulk-download-panel.vue +26 -104
@@ 39,7 39,7 @@
        <host-pick-field
          v-else
          ref="hostInput"
          v-model="hostId"
          v-model="hubHostId"
          label="Hub Host"
          required
        />


@@ 49,8 49,8 @@
        <interface-pick-field
          v-else
          ref="interfaceInput"
          v-model="interfaceId"
          :host-id="hostId"
          v-model="hubInterfaceId"
          :host-id="hubHostId"
          label="Hub Interface"
          required
        />


@@ 78,8 78,8 @@
    </app-form>
    <host-bulk-download-modal
      v-model="submitting"
      :hosts="hosts"
      :interface-model="iface"
      :hosts="hostList"
      :interface-model="hubInterface"
      :download-files="downloadFiles"
      :invalid-chars="invalidChars"
    />


@@ 87,7 87,8 @@
</template>

<script setup>
import { computed, inject, onMounted, ref, unref, watch } from 'vue'
import { computed, onMounted, ref, unref, watch } from 'vue'
import { useHubSelection } from '@/mixins/host/use-host-bulk-download'
import { normalizeModel } from '@/utils/model'

const props = defineProps({


@@ 100,33 101,22 @@ const props = defineProps({
})
const emit = defineEmits(['cancel'])

const $axiosApi = inject('$axiosApi')

/** Selected host ID. */
const hostId = ref('')
const interfaceId = ref('')
/** Selected interface model. */
const iface = ref(props.interfaceModel)

/** Raw CSV input text. */
const csv = ref('')
/** 'Download' radio button value. */
const downloadFiles = ref('agent')
/** 'Invalid name' radio button value. */
const invalidChars = ref('skip')
/** 'Source' radio button value. */
const source = ref('csv')
/** True if submitting form. */
const submitting = ref(false)

/** Placeholder text for CSV input. */
const csvPlaceholder = ref(
  `
Alice's Laptop
Bob's Phone
Carla's Workstation
`.trim(),
)
const {
  hubHostId,
  hubInterfaceId,
  hubInterface,
  hostList,
  source,
  csv,
  csvPlaceholder,
  downloadFiles,
  invalidChars,
  submitting,
  submit,
  selectDefaultHubHost,
  selectDefaultHubInterface,
  loadHubInterface,
} = useHubSelection(props)

const csvInput = ref()
const hostInput = ref()


@@ 135,71 125,9 @@ const inputRefs = computed(() => {
  return unref(source) === 'csv' ? [csvInput] : [hostInput, interfaceInput]
})

/** Parsed list of host names. */
const hosts = computed(() => {
  if (unref(source) !== 'csv') return []
  return unref(csv)
    .split(/\r?\n/)
    .map((x) => x.trim())
    .filter((x) => x)
})

watch(hostId, selectDefaultInterface)
watch(interfaceId, loadInterface)
onMounted(selectDefaultHost)

/**
 * Selects the default hub host on load.
 */
async function selectDefaultHost() {
  if (props.hostModel.id) return

  const { data } = await $axiosApi.get('/hosts', {
    params: { q: '"hub"', field: 'external' },
  })
  const host = normalizeModel(data)
  if (host.id) hostId.value = host.id
}

/**
 * Selects the default interface for the specified host.
 * @param {string} id Host pub ID.
 * @param {string} old Previous ID value.
 */
async function selectDefaultInterface(id, old) {
  if (id === old) return
  if (!id) {
    interfaceId.value = ''
    return
  }
  const { data } = await $axiosApi.get(`/hosts/${id}/interfaces/all`)
  const iface = normalizeModel(data)
  interfaceId.value = iface.id || ''
}

/**
 * Loads the specified interface model.
 * @param {string} id Interface pub ID.
 * @param {string} old Previous ID value.
 */
async function loadInterface(id, old) {
  if (id === old) return
  if (!id) {
    iface.value = normalizeModel()
    return
  }
  const { data } = await $axiosApi.get(`/interfaces/${id}`, {
    params: { included: 'peer' },
  })
  iface.value = normalizeModel(data)
}

/**
 * Submits the upload form, starting the upload process.
 */
function submit() {
  submitting.value = true
}
watch(hubHostId, selectDefaultHubInterface)
watch(hubInterfaceId, loadHubInterface)
onMounted(selectDefaultHubHost)

/**
 * Cancels the upload form.


@@ 208,9 136,3 @@ function cancel() {
  emit('cancel')
}
</script>

<style scoped lang="scss">
.preview-table {
  padding-top: 1rem;
}
</style>

M src/components/host/host-bulk-upload-panel.vue => src/components/host/host-bulk-upload-panel.vue +32 -155
@@ 5,7 5,7 @@
      :cancel="cancel"
      :submitting="submitting"
      :submit="submit"
      :submit-disabled="!hasPlan || !!interfaceErrors.length"
      :submit-disabled="!hasPlan || !!hubInterfaceErrors.length"
      submit-label="Add"
    >
      <app-message :active="!hasPlan" variant="warning">


@@ 25,14 25,14 @@
          autofocus
        />
      </o-field>
      <app-message :active="!!interfaceErrors.length" variant="warning">
      <app-message :active="!!hubInterfaceErrors.length" variant="warning">
        <div class="content">
          <div>
            The selected interface does not appear to be a hub. It should have
            the following defaults set:
          </div>
          <ul>
            <li v-for="item in interfaceErrors" :key="item">{{ item }}</li>
            <li v-for="item in hubInterfaceErrors" :key="item">{{ item }}</li>
          </ul>
        </div>
      </app-message>


@@ 42,7 42,7 @@
      <host-pick-field
        v-else
        ref="hostInput"
        v-model="hostId"
        v-model="hubHostId"
        label="Hub Host"
        required
      />


@@ 52,8 52,8 @@
      <interface-pick-field
        v-else
        ref="interfaceInput"
        v-model="interfaceId"
        :host-id="hostId"
        v-model="hubInterfaceId"
        :host-id="hubHostId"
        label="Hub Interface"
        required
      />


@@ 110,8 110,8 @@
    </app-form>
    <host-bulk-upload-modal
      v-model="submitting"
      :hosts="hosts"
      :interface-model="iface"
      :hosts="hostList"
      :interface-model="hubInterface"
      :download-files="downloadFiles"
      :invalid-chars="invalidChars"
      :existing-hosts="existingHosts"


@@ 120,11 120,11 @@
</template>

<script setup>
import { computed, inject, onMounted, ref, unref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useInputRefs } from '@/mixins/app/use-input-refs'
import { useHubSelection } from '@/mixins/host/use-host-bulk-download'
import { useFeaturesStore } from '@/stores/features'
import { env } from '@/utils/env'
import { splitAddress } from '@/utils/ip'
import { normalizeModel } from '@/utils/model'

const props = defineProps({


@@ 137,28 137,27 @@ const props = defineProps({
})
const emit = defineEmits(['cancel'])

const $axiosApi = inject('$axiosApi')
const { inputRefs } = useInputRefs(['csvInput', 'hostInput', 'interfaceInput'])
const features = useFeaturesStore()

/** Selected host ID. */
const hostId = ref('')
const interfaceId = ref('')
/** Selected interface model. */
const iface = ref(props.interfaceModel)
/** List of error messages to display about selected interface. */
const interfaceErrors = ref([])

/** Raw CSV input text. */
const csv = ref('')
/** 'Download' radio button value. */
const downloadFiles = ref('agent')
/** 'Invalid name' radio button value. */
const invalidChars = ref('skip')
/** 'Existing host or peer' radio button value. */
const existingHosts = ref('skip')
/** True if submitting form. */
const submitting = ref(false)
const {
  hubHostId,
  hubInterfaceId,
  hubInterface,
  hubInterfaceErrors,
  hostList,
  csv,
  csvPlaceholder,
  downloadFiles,
  invalidChars,
  existingHosts,
  submitting,
  submit,
  selectDefaultHubHost,
  selectDefaultHubInterface,
  loadHubInterface,
  validateHubInterface,
} = useHubSelection(props)

/** Help message for CSV input. */
const csvMessage = ref(`


@@ 166,132 165,16 @@ List of hosts to add (one host per line).
A new connection to the existing hub host will be created for each listed host,
using the hub interface's defaults.
`)
/** Placeholder text for CSV input. */
const csvPlaceholder = ref(
  `
Alice's Laptop
Bob's Phone
Carla's Workstation
`.trim(),
)

/** Parsed list of host names. */
const hosts = computed(() => {
  return unref(csv)
    .split(/\r?\n/)
    .map((x) => x.trim())
    .filter((x) => x)
})
/** True if user on plan with unlimited hosts. */
const hasPlan = computed(() => !features.hostLimit || features.hostLimit >= 100)
/** URL to pricing page. */
const pricingPage = computed(() => `${env.wwwUrl}/pricing/`)

watch(hostId, selectDefaultInterface)
watch(interfaceId, loadInterface)
watch(iface, validateInterface)
onMounted(selectDefaultHost)

/**
 * Selects the default hub host on load.
 */
async function selectDefaultHost() {
  if (props.hostModel.id) return

  const { data } = await $axiosApi.get('/hosts', {
    params: { q: '"hub"', field: 'external' },
  })
  const host = normalizeModel(data)
  if (host.id) hostId.value = host.id
}

/**
 * Selects the default interface for the specified host.
 * @param {string} id Host pub ID.
 * @param {string} old Previous ID value.
 */
async function selectDefaultInterface(id, old) {
  if (id === old) return
  if (!id) {
    interfaceId.value = ''
    return
  }
  const { data } = await $axiosApi.get(`/hosts/${id}/interfaces/all`)
  const iface = normalizeModel(data)
  interfaceId.value = iface.id || ''
}

/**
 * Loads the specified interface model.
 * @param {string} id Interface pub ID.
 * @param {string} old Previous ID value.
 */
async function loadInterface(id, old) {
  if (id === old) return
  if (!id) {
    iface.value = normalizeModel()
    return
  }
  const { data } = await $axiosApi.get(`/interfaces/${id}`, {
    params: { included: 'peer' },
  })
  iface.value = normalizeModel(data)
}

/**
 * Validates the currently selected interface, updating the errors list.
 * @param iface Interface model.
 */
async function validateInterface(iface) {
  const { id } = iface.rel('peer')
  if (!id) {
    interfaceErrors.value = []
    return
  }

  const { data } = await $axiosApi.get(`/peers/${id}`, {
    params: { included: 'defaults' },
  })
  const model = normalizeModel(data)
  const endpoint = model.rel('peer_endpoint_default').attr || {}
  const routing = model.rel('peer_endpoint_routing_default').attr || {}
  const errors = []

  const hubTypes = ['hub-and-spoke', 'internet-to-point', 'site-to-point']
  if (!hubTypes.includes(endpoint.wizard)) {
    errors.push(
      'Type: either Hub (in Hub-and-Spoke), Internet (in Point-to-Internet), or Site (in Point-to-Site)',
    )
  }

  if (!endpoint.allowed_ips || !endpoint.allowed_ips.length) {
    errors.push('Allowed IPs')
  }
  if (!(endpoint.hostname || endpoint.ip)) errors.push('Hostname')
  if (!endpoint.port) errors.push('Port')

  const bigSubnets =
    routing.subnet &&
    routing.subnet.length &&
    routing.subnet.every((x) => {
      const { mask, v4, v6 } = splitAddress(x)
      return (v4 && mask < 30) || (v6 && mask < 126)
    })
  if (!bigSubnets) {
    errors.push(
      'Network Addresses: each with a mask less than /30 (for IPv4, or /126 for IPv6)',
    )
  }

  interfaceErrors.value = errors
}

/**
 * Submits the upload form, starting the upload process.
 */
function submit() {
  submitting.value = true
}
watch(hubHostId, selectDefaultHubInterface)
watch(hubInterfaceId, loadHubInterface)
watch(hubInterface, validateHubInterface)
onMounted(selectDefaultHubHost)

/**
 * Cancels the upload form.


@@ 300,9 183,3 @@ function cancel() {
  emit('cancel')
}
</script>

<style scoped lang="scss">
.preview-table {
  padding-top: 1rem;
}
</style>

A src/mixins/host/use-host-bulk-download.js => src/mixins/host/use-host-bulk-download.js +191 -0
@@ 0,0 1,191 @@
import { computed, inject, ref, unref } from 'vue'
import { splitAddress } from '@/utils/ip'
import { normalizeModel } from '@/utils/model'

/**
 * Selects the default hub host on load.
 * @param hubHost Ref to host model.
 * @param hubHostId Ref to host pub ID.
 * @param $axiosApi Axios API instance.
 */
async function selectDefaultHubHost(hubHost, hubHostId, $axiosApi) {
  if (hubHost.value.id) return

  const { data } = await $axiosApi.get('/hosts', {
    params: { q: '"hub"', field: 'external' },
  })
  const host = normalizeModel(data)
  if (host.id) hubHostId.value = host.id
}

/**
 * Selects the default interface for the specified host.
 * @param {string} id Host pub ID.
 * @param {string} old Previous ID value.
 * @param hubInterfaceId Ref to interface pub ID.
 * @param $axiosApi Axios API instance.
 */
async function selectDefaultHubInterface(id, old, hubInterfaceId, $axiosApi) {
  if (id === old) return
  if (!id) {
    hubInterfaceId.value = ''
    return
  }
  const { data } = await $axiosApi.get(`/hosts/${id}/interfaces/all`)
  const iface = normalizeModel(data)
  hubInterfaceId.value = iface.id || ''
}

/**
 * Loads the specified interface model.
 * @param {string} id Interface pub ID.
 * @param {string} old Previous ID value.
 * @param hubInterface Ref to interface model.
 * @param $axiosApi Axios API instance.
 */
async function loadHubInterface(id, old, hubInterface, $axiosApi) {
  if (id === old) return
  if (!id) {
    hubInterface.value = normalizeModel()
    return
  }
  const { data } = await $axiosApi.get(`/interfaces/${id}`, {
    params: { included: 'peer' },
  })
  hubInterface.value = normalizeModel(data)
}

/**
 * Validates the currently selected interface, updating the errors list.
 * @param hubInterface Ref to interface model.
 * @param hubInterfaceErrors Ref to errors list.
 * @param $axiosApi Axios API instance.
 */
async function validateHubInterface(
  hubInterface,
  hubInterfaceErrors,
  $axiosApi,
) {
  const { id } = hubInterface.value.rel('peer')
  if (!id) {
    hubInterfaceErrors.value = []
    return
  }

  const { data } = await $axiosApi.get(`/peers/${id}`, {
    params: { included: 'defaults' },
  })
  const model = normalizeModel(data)
  const endpoint = model.rel('peer_endpoint_default').attr || {}
  const routing = model.rel('peer_endpoint_routing_default').attr || {}
  const errors = []

  const hubTypes = ['hub-and-spoke', 'internet-to-point', 'site-to-point']
  if (!hubTypes.includes(endpoint.wizard)) {
    errors.push(
      'Type: either Hub (in Hub-and-Spoke), Internet (in Point-to-Internet), or Site (in Point-to-Site)',
    )
  }

  if (!endpoint.allowed_ips || !endpoint.allowed_ips.length) {
    errors.push('Allowed IPs')
  }
  if (!(endpoint.hostname || endpoint.ip)) errors.push('Hostname')
  if (!endpoint.port) errors.push('Port')

  const bigSubnets =
    routing.subnet &&
    routing.subnet.length &&
    routing.subnet.every((x) => {
      const { mask, v4, v6 } = splitAddress(x)
      return (v4 && mask < 30) || (v6 && mask < 126)
    })
  if (!bigSubnets) {
    errors.push(
      'Network Addresses: each with a mask less than /30 (for IPv4, or /126 for IPv6)',
    )
  }

  hubInterfaceErrors.value = errors
}

/**
 * Refs for hub selection.
 *
 * @param props Props instance.
 * @return Refs.
 */
export function useHubSelection(props) {
  const $axiosApi = inject('$axiosApi')

  /** Selected host ID. */
  const hubHostId = ref('')
  /** Selected host model. */
  const hubHost = ref(props.hostModel)
  /** Selected interface ID. */
  const hubInterfaceId = ref('')
  /** Selected interface model. */
  const hubInterface = ref(props.interfaceModel)
  /** List of error messages to display about selected interface. */
  const hubInterfaceErrors = ref([])

  /** 'Source' radio button value. */
  const source = ref('csv')
  /** Raw CSV input text. */
  const csv = ref('')
  /** 'Download' radio button value. */
  const downloadFiles = ref('agent')
  /** 'Invalid name' radio button value. */
  const invalidChars = ref('skip')
  /** 'Existing host or peer' radio button value. */
  const existingHosts = ref('skip')
  /** True if submitting form. */
  const submitting = ref(false)

  /** Placeholder text for CSV input. */
  const csvPlaceholder = ref(
    `
Alice's Laptop
Bob's Phone
Carla's Workstation
  `.trim(),
  )

  /** Parsed list of host names. */
  const hostList = computed(() => {
    if (unref(source) !== 'csv') return []
    return unref(csv)
      .split(/\r?\n/)
      .map((x) => x.trim())
      .filter((x) => x)
  })

  return {
    hubHostId,
    hubHost,
    hubInterfaceId,
    hubInterface,
    hubInterfaceErrors,
    hostList,
    source,
    csv,
    csvPlaceholder,
    downloadFiles,
    invalidChars,
    existingHosts,
    submitting,
    submit: () => (submitting.value = true),
    selectDefaultHubHost: async () => {
      return selectDefaultHubHost(hubHost, hubHostId, $axiosApi)
    },
    selectDefaultHubInterface: async (id, old) => {
      return selectDefaultHubInterface(id, old, hubInterfaceId, $axiosApi)
    },
    loadHubInterface: async (id, old) => {
      return loadHubInterface(id, old, hubInterface, $axiosApi)
    },
    validateHubInterface: async () => {
      return validateHubInterface(hubInterface, hubInterfaceErrors, $axiosApi)
    },
  }
}