~arx10/procustodibus-app

e43ef02d6edf6471a5307a7466a350f75b1f781d — Justin Ludwig a month ago a086800
host bulk download page
A src/components/host/host-bulk-download-modal.vue => src/components/host/host-bulk-download-modal.vue +468 -0
@@ 0,0 1,468 @@
<template>
  <o-modal
    v-model:active="active"
    aria-role="dialog"
    aria-label="Downloading Configuration"
    aria-modal
    has-modal-card
    trap-focus
  >
    <app-modal-form
      :cancel="cancelOrCloseModal"
      :cancel-label="cancelOrCloseLabel"
      title="Downloading Configuration"
    >
      <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 { ZipWriter } from '@zip.js/zip.js'
import { compareDates } from '@/utils/date'
import {
  download,
  generateAgentConf,
  generateAgentSetupConf,
  generateWgQuickConf,
} from '@/utils/download'
import { env } from '@/utils/env'
import { toast } from '@/utils/message'
import { normalizeModel, normalizeModels, simulateModel } from '@/utils/model'
import { getPageantryParams, emptyPageantryState } from '@/utils/pageantry'

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' },
})
const emit = defineEmits([
  'complete',
  'cancel',
  'progress',
  'update:modelValue',
])

const $axiosApi = inject('$axiosApi')

/** Promise to generate zip file content. */
let zipPromise = null
/** Active ZipWriter object. */
let zipWriter = null

/**
 * List of processed host models.
 * Each host model has an added `log` meta attr for list of log messages.
 */
const data = ref([])
/** List of endpoints paged thus far. */
const endpoints = ref([])
/** Total count of endpoints. */
const endpointsTotal = ref(0)
/** Paging for endpoints. */
const endpointsPageantry = ref(emptyPageantryState())
/** True if processing. */
const loading = ref(false)

/** 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 || unref(endpointsTotal))

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 = []
  endpoints.value = []
  endpointsTotal.value = 0
  initZip()
  await loadMoreEndpoints()
  nextTick(loadNext)
}

/**
 * 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()

  let host = null
  let ifaces = null

  if (props.hosts.length) {
    host = await findHost(props.hosts[currentDone])
    if (abortHost(host)) return nextHost(host)
  } else {
    const endpoint = await loadNextEndpoint()
    host = modelHost(endpoint.rel('host'))
    ifaces = [endpoint.rel('interface')]
    if (abortHost(host)) return nextHost(host)
  }

  if (props.downloadFiles === 'wireguard') {
    if (!ifaces) ifaces = await findInterfaces(host)
    await Promise.all(ifaces.map((iface) => addToZip(host, iface)))
  } else {
    await addToZip(host)
  }

  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 } })
  if (!host.meta.log) host.meta.log = []
  return host
}

/**
 * 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 the specified host by name.
 * @param {string} name Host name.
 * @return Host model.
 */
async function findHost(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)
  const 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) {
    log(host, 'Missing host', 'danger')
  }

  return host
}

/**
 * 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 all interfaces for the specified host.
 * @param host Host model.
 * @return List of interface models.
 */
async function findInterfaces(host) {
  const { data } = await $axiosApi.get(`/hosts/${host.id}/interfaces/all`)
  const ifaces = normalizeModels(data)
  if (ifaces.length < 1) log(host, 'Missing interface', 'danger')
  return ifaces
}

/**
 * Loads the next endpoint for the selected interface model.
 * @return Endpoint model.
 */
async function loadNextEndpoint() {
  const currentDone = unref(done)
  if (currentDone >= unref(endpoints).length) await loadMoreEndpoints()

  const endpoint = unref(endpoints)[currentDone]
  if (!endpoint) {
    const host = modelHost(null, `Missing host #${currentDone + 1}`)
    log(host, 'Missing host', 'danger')
    return simulateModel({ rr: { host: [host] } })
  }

  const host = modelHost(endpoint.rel('host'))
  const { name } = host.attr
  if (props.invalidChars === 'skip' && name !== makeSafeName(name)) {
    log(host, 'Name contains invalid filename characters', 'warning')
    log(host, 'Skipped', 'warning')
    return simulateModel({ attr: host.attr, rr: { host: [host] } })
  }

  return endpoint
}

/**
 * Loads the next page of endpoints for the selected interface model.
 */
async function loadMoreEndpoints() {
  const { id } = props.interfaceModel.rel('peer')
  if (!id || props.hosts.length) return

  endpointsPageantry.value.off = endpoints.value.length
  const { data } = await $axiosApi.get(`/peers/${id}/endpoints`, {
    params: getPageantryParams(endpointsPageantry.value),
  })
  endpointsTotal.value = data.meta.total
  endpoints.value.push(...normalizeModels(data))
}

/**
 * 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, '') : ''
}

/**
 * Initializes the zip file structures.
 */
function initZip() {
  if (zipWriter) zipWriter.close()
  if (props.downloadFiles === 'nothing') return

  const { readable, writable } = new TransformStream()
  zipPromise = new Response(readable).blob()
  zipWriter = new ZipWriter(writable)
}

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

  const directory = `hosts/${makeSafeName(host.attr.name)}`
  let result = null

  if (props.downloadFiles === 'wireguard') {
    iface = await loadInterfacePrivateKey(iface.id)
    result = await addFileToZip(
      `${directory}/${iface.attr.name}.conf`,
      generateWgQuickConf(iface),
    )
  } else {
    const agent = await loadAgent(host.id)
    result = await addFileToZip(
      `${directory}/${env.confName}.conf`,
      generateAgentConf(agent, host),
    )
    if (result.variant === 'success') {
      const code = await loadCode(agent.id)
      result = await addFileToZip(
        `${directory}/${env.confName}-setup.conf`,
        generateAgentSetupConf(agent, code),
      )
    }
  }

  log(host, result.message, result.variant)
}

/**
 * Adds the specified content to the zip under the specified path.
 * Returns a log entry indicating success or error.
 * @param {string} path Path in zip (eg 'hosts/Foo Bar/agent.conf').
 * @param {string} content File content.
 * @return Log entry with `message` and `variant` properties.
 */
async function addFileToZip(path, content) {
  try {
    await zipWriter.add(path, new Blob([content]).stream())
    return { message: 'Zipped', variant: 'success' }
  } catch (e) {
    console.error(e) // eslint-disable-line no-console
    return { message: `Zip error: ${e}`, variant: 'danger' }
  }
}

/**
 * Completes the processing and downloads the zip file.
 */
async function downloadZip() {
  if (zipWriter) {
    await zipWriter.close()
    zipWriter = null
    const zip = await zipPromise
    if (zip.size > 100) download(zip, `${env.confName}-hosts.zip`, 'blob')
  }

  const message = buildDoneMessage()
  toast(message, { variant: / 0 /.test(message) ? 'warning' : 'success' })
  loading.value = false
  emit('complete')
}

/**
 * Retreives the full key material for the specified interface, as well as its
 * enpoints (from which to generate the full wg-quick conf).
 * @param {string} interfaceId Interface pub ID.
 * @return Model for interface.
 */
async function loadInterfacePrivateKey(interfaceId) {
  const url = `/interfaces/${interfaceId}/expected-conf-with-private-key`
  const { data } = await $axiosApi.get(url)
  return normalizeModel(data)
}

/**
 * Retrieves the active agent from the api.
 * @param {string} hostId Host pub ID.
 * @return Model for agent user.
 */
async function loadAgent(hostId) {
  const { data } = await $axiosApi.get(`/hosts/${hostId}/members`)
  const agent = normalizeModels(data)
    .concat()
    .sort((a, b) => compareDates(a, b, (x) => x.attr.created))
    .find((x) => ['logger', 'supervisor'].includes(x.attr.membership))
  if (agent) return agent

  const rs = await $axiosApi.post(`/hosts/${hostId}/members/agent`)
  return normalizeModel(rs.data)
}

/**
 * Retrieves the active setup code from the api.
 * @param {string} agentId Agent pub ID.
 * @return Model for agent_setup user-code.
 */
async function loadCode(agentId) {
  const url = `/users/${agentId}/credentials/signature/setup`
  const { data } = await $axiosApi.post(url)
  return normalizeModel(data)
}

/**
 * Builds the completion message.
 * @return {string} Completion message.
 */
function buildDoneMessage() {
  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-download-panel.vue => src/components/host/host-bulk-download-panel.vue +216 -0
@@ 0,0 1,216 @@
<template>
  <app-panel label="Bulk Download" :loading="loading">
    <app-form
      :input-refs="inputRefs"
      :cancel="cancel"
      :submitting="submitting"
      :submit="submit"
      submit-label="Download"
    >
      <o-field label="From" class="radio-stack">
        <o-radio v-model="source" native-value="csv">
          List of host names
        </o-radio>
      </o-field>
      <o-field>
        <o-radio v-model="source" native-value="connections">
          All connections to a hub host
        </o-radio>
      </o-field>
      <o-field
        v-if="source === 'csv'"
        message="List of hosts to download (one host per line)."
        label="Hosts to Download"
      >
        <o-input
          ref="csvInput"
          v-model="csv"
          :placeholder="csvPlaceholder"
          type="textarea"
          validation-message="required"
          required
          autofocus
        />
      </o-field>
      <template v-if="source === 'connections'">
        <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
        />
      </template>
      <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 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>
    </app-form>
    <host-bulk-download-modal
      v-model="submitting"
      :hosts="hosts"
      :interface-model="iface"
      :download-files="downloadFiles"
      :invalid-chars="invalidChars"
    />
  </app-panel>
</template>

<script setup>
import { computed, inject, onMounted, ref, unref, watch } from 'vue'
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')

/** 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 csvInput = ref()
const hostInput = ref()
const interfaceInput = ref()
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
}

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

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

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

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

const $router = useRouter()

const pageCrumbs = ref(['Download', { label: 'Hosts', to: '/hosts' }])
const pageTitle = ref('Download Configuration')

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