~arx10/procustodibus-app

54577060eb02f6aa5f8c0d5009ea563e9881ed0a — Justin Ludwig 6 months ago 6773565
host bulk upload page
A src/components/host/host-bulk-upload-modal.vue => src/components/host/host-bulk-upload-modal.vue +730 -0
@@ 0,0 1,730 @@
<template>
  <o-modal
    v-model:active="active"
    aria-role="dialog"
    aria-label="Adding Hosts by Bulk"
    aria-modal
    has-modal-card
    trap-focus
  >
    <app-modal-form
      :cancel="cancelOrCloseModal"
      :cancel-label="cancelOrCloseLabel"
      title="Adding Hosts by Bulk"
    >
      <div class="content">
        <ul class="progress-details">
          <li v-for="result in data" :key="result.id">
            <span>{{ result.attr.name }}</span>
            <ul>
              <li
                v-for="item in result.meta.log"
                :key="item"
                :class="`has-text-${item.variant || 'grey-slate'}`"
              >
                {{ item.message }}
              </li>
            </ul>
          </li>
        </ul>
        <app-progress
          :value="loadingProgress"
          size="large"
          variant="primary"
          show-value
        >
          {{ done }} of {{ total }}
        </app-progress>
      </div>
    </app-modal-form>
  </o-modal>
</template>

<script setup>
import { computed, inject, nextTick, ref, unref, watch } from 'vue'
import {
  generateNextAddress,
  joinAddress,
  parseAddress,
  splitAddress,
} from '@/utils/ip'
import { toast } from '@/utils/message'
import { normalizeModel, normalizeModels, simulateModel } from '@/utils/model'
import { randomBase64 } from '@/utils/text'

const props = defineProps({
  /** True if open (default false). */
  modelValue: { type: Boolean, default: false },
  /** List of host names. */
  hosts: { type: Array, default: () => [] },
  /** Hub interface model. */
  interfaceModel: { type: Object, default: normalizeModel },
  /** Download type. */
  downloadFiles: { type: String, default: 'agent' },
  /** How to handle invalid characters in host names. */
  invalidChars: { type: String, default: 'skip' },
  /** How to handle existing hosts. */
  existingHosts: { type: String, default: 'skip' },
})
const emit = defineEmits([
  'complete',
  'cancel',
  'progress',
  'update:modelValue',
])

const $axiosApi = inject('$axiosApi')

/**
 * List of processed host models.
 * Each host model has an added `log` meta attr for list of log messages.
 */
const data = ref([])
/** Hub peer model. */
const hubPeer = ref(null)
/** True if processing. */
const loading = ref(false)
/** Last interface address selected from each of the hub's subnets. */
const lastAddresses = ref([])
/** List of interface IP addresses already used by organization. */
const unavailableAddresses = ref([])

/** True if dialog opened. */
const active = computed({
  get: () => props.modelValue,
  set: (v, old) => (v !== old ? emit('update:modelValue', v) : undefined),
})
/** Label of close/cancel button. */
const cancelOrCloseLabel = computed(() => (unref(loading) ? 'Cancel' : 'Close'))
/** Progress percentage (0.0-1.0). */
const loadingProgress = computed(() => {
  const t = unref(total)
  return t ? (unref(done) * 100) / t : 0
})
/** Count of processed host names. */
const done = computed(() => data.value.length)
/** Total host names to process. */
const total = computed(() => props.hosts.length)

watch(active, (v) => (v ? load() : undefined))

/**
 * Cancels the processing; or if finished processing, closes the dialog.
 */
function cancelOrCloseModal() {
  if (loading.value) {
    loading.value = false
    emit('cancel')
  } else {
    active.value = false
  }
}

/**
 * Starts the processing.
 */
async function load() {
  loading.value = true
  data.value = []
  await loadHub()
  await loadAddresses()
  nextTick(loadNext)
}

/**
 * Loads the hub peer model.
 */
async function loadHub() {
  const { id } = props.interfaceModel.rel('peer')
  const { data } = await $axiosApi.get(`/peers/${id}`, {
    params: { included: 'defaults' },
  })
  const model = normalizeModel(data)
  hubPeer.value = model
  const net = model.rel('peer_endpoint_routing_default').attr.subnet || []
  lastAddresses.value = net.map(parseAddress)
}

/**
 * Loads the unavailable addresses list.
 */
async function loadAddresses() {
  const { data: addr } = await $axiosApi.get('/interfaces/addresses')
  unavailableAddresses.value = addr.data.map((x) => parseAddress(x.id))
}

/**
 * Processes the next host name.
 */
async function loadNext() {
  const currentTotal = unref(total)
  const currentDone = unref(done)
  if (!unref(loading) || !currentTotal) return
  if (currentDone >= currentTotal) return downloadZip()

  const name = unref(props.hosts)[currentDone]
  const host = await findOrCreateHost(name)
  if (abortHost(host)) return nextHost(host)

  const iface = await findOrCreateInterface(host)
  if (abortHost(host)) return nextHost(host)

  const endpoint = await findOrCreateEndpoint(host, iface)
  if (abortHost(host)) return nextHost(host)

  await findOrCreateHubEndpoint(host, iface, endpoint)
  if (abortHost(host)) return nextHost(host)

  addToZip(host, iface)
  nextHost(host)
}

/**
 * Returns true if processing has been canceled or a warning message logged.
 * @param host Host model to check for warning message.
 * @return {boolean} True if aborting.
 */
function abortHost(host) {
  return (
    !unref(loading) ||
    host.meta.log.some((x) => ['danger', 'warning'].includes(x.variant))
  )
}

/**
 * Adds the specified host model to the list of processed models,
 * and queues the next set of work.
 * @param host Host model.
 */
function nextHost(host) {
  data.value.push(host)
  emit('progress', data.value)
  nextTick(loadNext)
}

/**
 * Ensures the specified host has an appropriate model.
 * @param host Host model.
 * @param {string} name Host name if host is null.
 * @return Annotated host model.
 */
function modelHost(host, name) {
  host = host || simulateModel({ attr: { name } })
  host.meta = { log: [] }
  return host
}

/**
 * Ensures the specified model has a "new" flag.
 * @param o Model.
 * @return Annotated host model.
 */
function modelNu(o) {
  o.nu = true
  return o
}

/**
 * Addes a log message to the specified host model.
 * @param host Host model.
 * @param {string} message Message to log.
 * @param {string} variant Log level (eg 'warning').
 */
function log(host, message, variant) {
  host.meta.log.push({ message, variant })
}

/**
 * Finds or creates the specified host by name.
 * @param {string} name Host name.
 * @return Host model.
 */
async function findOrCreateHost(name) {
  if (props.invalidChars === 'skip' && name !== makeSafeName(name)) {
    const host = modelHost(null, name)
    log(host, 'Name contains invalid filename characters', 'warning')
    log(host, 'Skipped', 'warning')
    return host
  }
  const hosts = await findHosts(name)
  let host = modelHost(hosts[0], name)
  if (!unref(loading)) return host

  if (hosts.length > 1) {
    log(host, 'Found more than one host', 'warning')
    log(host, 'Skipped', 'warning')
  } else if (hosts.length < 1) {
    await findAndWarnAboutPeer(host)
    if (abortHost(host)) return host

    host = await createHost(name)
    log(host, 'Created new host', 'success')
  } else if (props.existingHosts === 'delete') {
    await findAndWarnAboutPeer(host)
    if (abortHost(host)) return host

    await deleteHost(host)
    log(host, 'Deleted existing host', 'success')
    host = await createHost(name)
    log(host, 'Created new host', 'success')
  } else {
    log(host, 'Found existing host')
  }

  return host
}

/**
 * Finds the peer matching the specified host's name,
 * and warns appropriately if found.
 * @param host Host model.
 * @return Peer model or null.
 */
async function findAndWarnAboutPeer(host) {
  const peers = await findPeers(host.attr.name)
  const peer = peers[0]
  if (!unref(loading) || !peer) return peer

  if (peers.length > 1) {
    log(host, 'Found more than one peer', 'warning')
    log(host, 'Skipped', 'warning')
  } else if (props.existingHosts === 'skip') {
    log(host, 'Found existing peer', 'warning')
    log(host, 'Skipped', 'warning')
  } else if (props.existingHosts === 'download' && !host.id) {
    log(host, 'Found existing peer without host', 'warning')
    log(host, 'Skipped', 'warning')
  } else if (props.existingHosts !== 'delete') {
    log(host, 'Found existing peer')
  }

  return peer
}

/**
 * Finds or creates the peer matching the specified host's name.
 * @param host Host model.
 * @return Peer model or null.
 */
async function findOrCreatePeer(host) {
  const peerName = host.attr.name
  let peer = await findAndWarnAboutPeer(host)
  if (abortHost(host)) return peer

  if (peer && props.existingHosts === 'delete') {
    await deletePeer(peer)
    log(host, 'Deleted existing peer', 'success')
    peer = null
  }
  if (!peer) {
    peer = await createPeer(peerName)
    log(host, 'Created new peer', 'success')
  }

  return peer
}

/**
 * Finds or creates an interface for the specified host.
 * @param host Host model.
 * @return Interface model or null.
 */
async function findOrCreateInterface(host) {
  let iface = await findInterface(host)
  if (!unref(loading)) return iface

  if (!iface) {
    const peer = await findOrCreatePeer(host)
    if (!peer || !unref(loading)) return null

    iface = await createInterface(host, peer)
    log(host, 'Created new interface', 'success')
  } else if (props.existingHosts === 'skip') {
    log(host, 'Found existing interface', 'warning')
    log(host, 'Skipped', 'warning')
  } else {
    log(host, 'Found existing interface')
  }

  return iface
}

/**
 * Finds or creates an endpoint for the specified host and interface.
 * @param host Host model.
 * @param iface Interface model.
 * @return Endpoint model or null.
 */
async function findOrCreateEndpoint(host, iface) {
  if (!iface.nu && !['add', 'replace'].includes(props.existingHosts)) return

  let endpoint = await findExpectedEndpoint(iface)
  if (!unref(loading)) return endpoint

  if (!endpoint) {
    endpoint = await createEndpoint(iface)
    log(host, 'Created new endpoint', 'success')
    return endpoint
  } else if (props.existingHosts === 'replace') {
    endpoint = await updateEndpoint(endpoint)
    log(host, 'Updated existing endpoint', 'success')
  }

  return endpoint
}

/**
 * Finds or creates a corresponding endpoint to the specified host, interface,
 * and endpoint model (for the hub host).
 * @param host Host model.
 * @param iface Interface model.
 * @param endpoint Endpoint model.
 * @return Endpoint model or null.
 */
async function findOrCreateHubEndpoint(host, iface, endpoint) {
  if (!endpoint) return
  const peer = iface.rel('peer')
  const hubEndpoint = await findEndpoint(props.interfaceModel, peer)
  if (!unref(loading)) return

  if (!hubEndpoint) {
    const params = await buildHubEndpointParams(peer, iface.rel('routing'))
    if (!unref(loading)) return
    const url = `/interfaces/${props.interfaceModel.id}/endpoints`
    await $axiosApi.post(url, params)
    log(host, 'Created corresponding endpoint', 'success')
  } else if (props.existingHosts === 'skip') {
    log(host, 'Found existing corresponding endpoint', 'warning')
    log(host, 'Skipped', 'warning')
  } else if (props.existingHosts === 'replace') {
    const params = await buildHubEndpointParams(peer, iface.rel('routing'))
    if (!unref(loading)) return
    const url = `/endpoints/${hubEndpoint.id}`
    await $axiosApi.post(url, params)
    log(host, 'Updated corresponding endpoint', 'success')
  } else {
    log(host, 'Found existing corresponding endpoint')
  }
}

/**
 * Builds the params to create an endpoint for the specified peer,
 * corresponding to the specified remote interface routing.
 * @param peer Peer model.
 * @param routing Routing model.
 * @return Params map.
 */
async function buildHubEndpointParams(peer, routing) {
  const { data } = await $axiosApi.get(`/peers/${peer.id}`, {
    params: { included: 'defaults' },
  })
  const { allowed_ips: ips } = normalizeModel(data).rel(
    'peer_endpoint_default',
  ).attr
  const { address } = routing.meta.expected || routing.attr
  return {
    available: true,
    peer_id: peer.id,
    preshared_key_copy: true,
    allowed_ips:
      ips && ips.length
        ? ips
        : address
          ? address.map((x) => splitAddress(x).ip)
          : null,
  }
}

/**
 * Finds the list of hosts with the specified name.
 * @param {string} name Host name.
 * @return List of host models.
 */
async function findHosts(name) {
  const { data } = await $axiosApi.get('/hosts', {
    params: { q: `"${name}"`, field: 'name' },
  })
  return normalizeModels(data)
}

/**
 * Finds the list of peers with the specified name.
 * @param {string} name Peer name.
 * @return List of peer models.
 */
async function findPeers(name) {
  const { data } = await $axiosApi.get('/peers', {
    params: { q: `"${name}"`, field: 'name' },
  })
  return normalizeModels(data)
}

/**
 * Finds the default interface for the specified host.
 * @param host Host model.
 * @return Interface model or null.
 */
async function findInterface(host) {
  if (host.nu) return null
  const { data } = await $axiosApi.get(`/hosts/${host.id}/interfaces/all`, {
    params: { included: 'peer,routing,expected' },
  })
  // reverse to newest first
  const ifaces = normalizeModels(data).reverse()
  if (ifaces.length <= 1) return ifaces[0]
  return ifaces.find(findExpectedEndpoint) || ifaces[0]
}

/**
 * Finds the endpoint for the specified interface and peer,
 * with the 'expected' included set.
 * @param iface Interface model.
 * @param [peer] Peer model (defaults to hub peer).
 * @return Endpoint model or null.
 */
async function findExpectedEndpoint(iface, peer) {
  return findEndpoint(iface, peer, 'expected')
}

/**
 * Finds the endpoint for the specified interface and peer.
 * @param iface Interface model.
 * @param [peer] Peer model (defaults to hub peer).
 * @param {string} [included] Included parameter value (eg 'expected').
 * @return Endpoint model or null.
 */
async function findEndpoint(iface, peer, included) {
  if (iface.nu) return null
  if (!peer) peer = unref(hubPeer)
  try {
    const { data } = await $axiosApi.get(
      `/interfaces/${iface.id}/endpoints/peers/${peer.id}`,
      { params: included ? { included } : {}, suppressErrors: true },
    )
    return normalizeModel(data)
  } catch (e) {
    if (!e.response) throw e
    return null
  }
}

/**
 * Creates a new host with the specified name.
 * @param {string} name Host name.
 * @return Host model.
 */
async function createHost(name) {
  const { data } = await $axiosApi.post('/hosts', { name })
  return modelNu(modelHost(normalizeModel(data)))
}

/**
 * Deletes the specified host.
 * @param host Host model.
 */
async function deleteHost(host) {
  await $axiosApi.delete(`/hosts/${host.id}/connections`)
  await $axiosApi.delete(`/hosts/${host.id}`)
}

/**
 * Creates a new peer with the specified name.
 * @param {string} name Peer name.
 * @return Peer model.
 */
async function createPeer(name) {
  const keyPair = await generateKeyPair()
  const params = Object.assign({ name }, keyPair.attr)
  const { data } = await $axiosApi.post('/peers', params)
  return modelNu(normalizeModel(data))
}

/**
 * Deletes the specified peer.
 * @param peer Peer model.
 */
async function deletePeer(peer) {
  await $axiosApi.delete(`/peers/${peer.id}/connections`)
  await $axiosApi.delete(`/peers/${peer.id}`)
}

/**
 * Creates a new interface for the specified host and peer.
 * @param host Host model.
 * @param peer Peer model.
 * @return Interface model.
 */
async function createInterface(host, peer) {
  const defaults = unref(hubPeer).rel('peer_endpoint_routing_default').attr

  const unavailable = unref(unavailableAddresses)
  const nextAddresses = unref(lastAddresses)
    .map((last) => {
      const a = generateNextAddress(last, null, unavailable, 'up')
      if (a) {
        a.subnet = last.subnet
        a.subnetMask = last.subnetMask
      }
      return a
    })
    .filter((x) => x)

  unavailable.push(...nextAddresses)
  lastAddresses.value = nextAddresses
  const address = nextAddresses.map((x) => {
    return joinAddress({ ip: x.correctForm(), mask: x.subnetMask })
  })

  const params = Object.assign({}, defaults, {
    name: 'wg0',
    peer_id: peer.id,
    address,
  })
  const { data } = await $axiosApi.post(`/hosts/${host.id}/interfaces`, params)
  const m = normalizeModel(data)

  const rr = { peer: [peer], routing: [simulateModel({ attr: { address } })] }
  return simulateModel({ id: m.id, attr: m.attr, rr, nu: true })
}

/**
 * Creates a new endpoint for the specified interface.
 * @param iface Interface model.
 * @return Endpoint model.
 */
async function createEndpoint(iface) {
  let psk = 'generate'
  if (props.existingHost === 'add') {
    const peer = iface.rel('peer')
    const hubEndpoint = await findEndpoint(props.interfaceModel, peer)
    if (hubEndpoint) psk = 'copy'
  }

  const params = await buildEndpointParams(psk)
  const url = `/interfaces/${iface.id}/endpoints`
  const { data } = await $axiosApi.post(url, params)
  return simulateModel({ id: data.data.id, attr: params, nu: true })
}

/**
 * Updates the specified endpoint with the hub endpoint defaults.
 * @param endpoint Endpoint model.
 * @return Updated endpoint model.
 */
async function updateEndpoint(endpoint) {
  const params = await buildEndpointParams()
  await $axiosApi.post(`/endpoints/${endpoint.id}`, params)
  return simulateModel({ id: endpoint.id, attr: params })
}

/**
 * Builds the params to create an endpoint for the hub peer.
 * @param {string} [psk] Preshared key behavior: 'copy' or 'generate' (default).
 * @return Params map.
 */
async function buildEndpointParams(psk = 'generate') {
  const peer = unref(hubPeer)
  const defaults = peer.rel('peer_endpoint_default').attr
  const params = Object.assign({ available: true, peer_id: peer.id }, defaults)
  if (psk === 'copy') {
    params.preshared_key_copy = true
  } else {
    params.preshared_key = defaults.preshared_key_generate
      ? await generatePresharedKey()
      : ''
  }
  return params
}

/**
 * Generates a new X25519 public key pair.
 * @return Model with `public_key` and `private_key` attributes.
 */
async function generateKeyPair() {
  const url = '/peers/private-key/generate'
  const secret = randomBase64(32)
  const { data } = await $axiosApi.post(url, { secret })
  return normalizeModel(data)
}

/**
 * Generates a new random 256-bit secret key.
 * @return {string} Base64-encoded key.
 */
async function generatePresharedKey() {
  const url = '/endpoints/preshared-key/generate'
  const secret = randomBase64(32)
  const { data } = await $axiosApi.post(url, { secret })
  return normalizeModel(data).id
}

/**
 * Replaces filename-unsafe characters with dashes.
 * @param {string} name Filename.
 * @return {string} Safe filename.
 */
function makeSafeName(name) {
  // eslint-disable-next-line no-control-regex
  return name ? name.replace(/[\x00–\x1f\x7f"*/:<>?\\|]+/g, '') : ''
}

/**
 * Adds the configuration files for specified host and interface to the zip.
 * @param host Host model.
 * @param iface Interface model.
 */
function addToZip(host, _iface) {
  if (props.downloadFiles === 'nothing') return
  log(host, 'Zipped', 'success')
}

/**
 * Completes the processing and downloads the zip file.
 */
function downloadZip() {
  const message = buildDoneMessage()
  toast(message, { variant: / 0 /.test(message) ? 'warning' : 'success' })
  loading.value = false
  emit('complete')
}

/**
 * Builds the completion message.
 * @return {string} Completion message.
 */
function buildDoneMessage() {
  if (props.downloadFiles === 'nothing') {
    const added = data.value.filter((host) => {
      return host.meta.log.some((x) => x.message === 'Created new host')
    })
    if (added.length) return `Added ${added.length} hosts`

    const updated = data.value.filter((host) => {
      return host.meta.log.some((x) => /^Created|^Updated/.test(x.message))
    })
    return `Updated ${updated.length} hosts`
  }

  const zipped = data.value.filter((host) => {
    return host.meta.log.some((x) => x.message === 'Zipped')
  })
  return `Zipped ${zipped.length} hosts`
}
</script>

<style scoped lang="scss">
.progress-details {
  margin: 0;
  height: 10rem;
  overflow: scroll;
  list-style: disc inside;

  ul {
    margin: 0 1rem 0.5rem;

    li {
      margin: 0 1rem;
    }
  }
}
</style>

A src/components/host/host-bulk-upload-panel.vue => src/components/host/host-bulk-upload-panel.vue +308 -0
@@ 0,0 1,308 @@
<template>
  <app-panel label="Bulk Upload" :loading="loading">
    <app-form
      :input-refs="inputRefs"
      :cancel="cancel"
      :submitting="submitting"
      :submit="submit"
      :submit-disabled="!hasPlan || !!interfaceErrors.length"
      submit-label="Add"
    >
      <app-message :active="!hasPlan" variant="warning">
        <router-link to="/admin/billing/setup">Upgrade</router-link> to a paid
        plan to add hosts by bulk (see
        <a :href="pricingPage" target="www" rel="noopener">Plans & Pricing</a>
        page).
      </app-message>
      <o-field :message="csvMessage" label="Hosts to Add">
        <o-input
          ref="csvInput"
          v-model="csv"
          :placeholder="csvPlaceholder"
          type="textarea"
          validation-message="required"
          required
          autofocus
        />
      </o-field>
      <app-message :active="!!interfaceErrors.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>
          </ul>
        </div>
      </app-message>
      <o-field v-if="hostModel.id" label="Hub Host">
        <span>{{ hostModel.attr.name }}</span>
      </o-field>
      <host-pick-field
        v-else
        ref="hostInput"
        v-model="hostId"
        label="Hub Host"
        required
      />
      <o-field v-if="interfaceModel.id" label="Hub Interface">
        <span>{{ interfaceModel.attr.name }}</span>
      </o-field>
      <interface-pick-field
        v-else
        ref="interfaceInput"
        v-model="interfaceId"
        :host-id="hostId"
        label="Hub Interface"
        required
      />
      <o-field label="Download" class="radio-stack">
        <o-radio v-model="downloadFiles" native-value="agent">
          Agent config files
        </o-radio>
      </o-field>
      <o-field class="radio-stack">
        <o-radio v-model="downloadFiles" native-value="wireguard">
          WireGuard config files
        </o-radio>
      </o-field>
      <o-field>
        <o-radio v-model="downloadFiles" native-value="nothing">
          Nothing
        </o-radio>
      </o-field>
      <o-field label="Invalid Names" class="radio-stack">
        <o-radio v-model="invalidChars" native-value="skip">
          Skip and warn
        </o-radio>
      </o-field>
      <o-field>
        <o-radio v-model="invalidChars" native-value="remove">
          Strip invalid filename characters from download
        </o-radio>
      </o-field>
      <o-field label="Existing Hosts or Peers" class="radio-stack">
        <o-radio v-model="existingHosts" native-value="skip">
          Skip and warn
        </o-radio>
      </o-field>
      <o-field class="radio-stack">
        <o-radio v-model="existingHosts" native-value="download">
          Don't modify, but include in download
        </o-radio>
      </o-field>
      <o-field class="radio-stack">
        <o-radio v-model="existingHosts" native-value="add">
          Add connection to hub if missing
        </o-radio>
      </o-field>
      <o-field class="radio-stack">
        <o-radio v-model="existingHosts" native-value="replace">
          Replace connection to hub with latest defaults
        </o-radio>
      </o-field>
      <o-field>
        <o-radio v-model="existingHosts" native-value="delete">
          Delete it and all connections and re-create from scratch
        </o-radio>
      </o-field>
    </app-form>
    <host-bulk-upload-modal
      v-model="submitting"
      :hosts="hosts"
      :interface-model="iface"
      :download-files="downloadFiles"
      :invalid-chars="invalidChars"
      :existing-hosts="existingHosts"
    />
  </app-panel>
</template>

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

const props = defineProps({
  /** True if model is loading. */
  loading: { type: Boolean, default: false },
  /** Host model. */
  hostModel: { type: Object, default: normalizeModel },
  /** Interface model. */
  interfaceModel: { type: Object, default: normalizeModel },
})
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)

/** Help message for CSV input. */
const csvMessage = ref(`
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
}

/**
 * Cancels the upload form.
 */
function cancel() {
  emit('cancel')
}
</script>

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

M src/components/host/host-list-panel.vue => src/components/host/host-list-panel.vue +12 -0
@@ 7,6 7,18 @@
        tooltip="Add"
        to="/hosts/add"
      />
      <app-tool-icon
        v-if="amAdmin"
        icon="upload"
        tooltip="Bulk Upload"
        to="/hosts/upload"
      />
      <app-tool-icon
        v-if="amAdmin"
        icon="download"
        tooltip="Download Config"
        to="/hosts/download"
      />
    </template>
    <app-table
      v-model="pageantry"

A src/pages/hosts/upload.vue => src/pages/hosts/upload.vue +19 -0
@@ 0,0 1,19 @@
<template>
  <app-page-container :crumbs="pageCrumbs" :title="pageTitle">
    <host-bulk-upload-panel @cancel="cancel" />
  </app-page-container>
</template>

<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'

const $router = useRouter()

const pageCrumbs = ref(['Bulk Upload', { label: 'Hosts', to: '/hosts' }])
const pageTitle = ref('Add Hosts by Bulk')

function cancel() {
  $router.back()
}
</script>