@ -0,0 +1,565 @@
<script setup lang="ts">
import { inject, computed, ref, watch } from "vue";
import { useStateStore } from "@/stores/StateStore";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import PvSelect from "@/components/common/pv-select.vue";
import PvDeleteModal from "@/components/common/pv-delete-modal.vue";
import MetricsChart from "./MetricsChart.vue";
import { useTheme } from "vuetify";
import { axiosPost, forceReloadPage } from "@/lib/PhotonUtils";
import TooltippedLabel from "@/components/common/pv-tooltipped-label.vue";
import { metricsHistorySnapshot } from "@/stores/settings/GeneralSettingsStore";

const theme = useTheme();

const restartProgram = async () => {
  if (await axiosPost("/utils/restartProgram", "restart PhotonVision")) {
    forceReloadPage();
  }
};
const restartDevice = async () => {
  if (await axiosPost("/utils/restartDevice", "restart the device")) {
    forceReloadPage();
  }
};

const address = inject<string>("backendHost");

const offlineUpdate = ref();
const openOfflineUpdatePrompt = () => {
  offlineUpdate.value.click();
};

const offlineUpdateRegex = new RegExp("photonvision-((?:dev-)?v[\\w.-]+)-((?:linux|win|mac)\\w+)\\.jar");
const majorVersionRegex = new RegExp("(?:dev-)?(\\d+)\\.\\d+\\.\\d+");

const offlineUpdateDialog = ref({ show: false, confirmString: "" });

const handleOfflineUpdateRequest = async () => {
  const files = offlineUpdate.value.files;
  if (files.length === 0) return;

  const match = files[0].name.match(offlineUpdateRegex);
  if (!match) {
    useStateStore().showSnackbarMessage({
      message: "Selected file does not match expected naming convention.",
      color: "error"
    });
    return;
  }

  const version = match[1] as string;
  const arch = match[2] as string;

  const currentVersion = useSettingsStore().general.imageVersion;
  const currentArch = useSettingsStore().general.wpilibArch;

  const versionMajor = version.match(majorVersionRegex)?.[1];
  const currentVersionMajor = currentVersion?.match(majorVersionRegex)?.[1];

  const versionMatch = currentVersion ? versionMajor === currentVersionMajor : false;
  const dev = version.includes("dev");

  if (currentArch && arch !== currentArch) {
    useStateStore().showSnackbarMessage({
      message: `Selected file architecture (${arch}) does not match device architecture (${currentArch}).`,
      color: "error"
    });
    return;
  } else if (versionMatch && !dev) {
    handleOfflineUpdate(files[0]);
  } else if (!versionMatch && !dev) {
    offlineUpdateDialog.value = {
      show: true,
      confirmString: `You are attempting to update from PhotonVision ${currentVersion} on image ${useSettingsStore().general.imageVersion} to ${version} from a different FRC year. These versions may be incompatible. Are you sure you want to proceed?`
    };
  } else if (versionMatch && dev) {
    offlineUpdateDialog.value = {
      show: true,
      confirmString:
        "You are attempting to update to a dev version. This could result in instability. Are you sure you want to proceed?"
    };
  } else if (!versionMatch && dev) {
    offlineUpdateDialog.value = {
      show: true,
      confirmString: `You are attempting to update to a dev version, from PhotonVision ${currentVersion} on image ${useSettingsStore().general.imageVersion} to ${version} from a different FRC year. These versions may be incompatible, and you may experience instability. Are you sure you want to proceed?`
    };
  }
};

const handleOfflineUpdate = async (file: File) => {
  const formData = new FormData();
  formData.append("jarData", file);
  useStateStore().showSnackbarMessage({
    message: "New Software Upload in Progress...",
    color: "secondary",
    timeout: -1
  });
  if (
    await axiosPost("/utils/offlineUpdate", "upload new software", formData, {
      headers: { "Content-Type": "multipart/form-data" },
      onUploadProgress: ({ progress }) => {
        const uploadPercentage = (progress || 0) * 100.0;
        if (uploadPercentage < 99.5) {
          useStateStore().showSnackbarMessage({
            message: "New Software Upload in Progress",
            color: "secondary",
            timeout: -1,
            progressBar: uploadPercentage,
            progressBarColor: "primary"
          });
        }
      }
    })
  ) {
    useStateStore().showSnackbarMessage({
      message: "Installing uploaded software...",
      color: "secondary",
      timeout: -1
    });
    forceReloadPage();
  }
};

const exportLogFile = ref();
const openExportLogsPrompt = () => {
  exportLogFile.value.click();
};

const exportSettings = ref();
const openExportSettingsPrompt = () => {
  exportSettings.value.click();
};

enum ImportType {
  AllSettings,
  HardwareConfig,
  HardwareSettings,
  NetworkConfig,
  ApriltagFieldLayout
}

const showImportDialog = ref(false);
const importType = ref<ImportType | undefined>(undefined);
const importFile = ref<File | null>(null);

const handleSettingsImport = () => {
  if (importType.value === undefined || importFile.value === null) return;
  const formData = new FormData();
  formData.append("data", importFile.value);
  let settingsEndpoint: string;
  switch (importType.value) {
    case ImportType.HardwareConfig:
      settingsEndpoint = "/hardwareConfig";
      break;
    case ImportType.HardwareSettings:
      settingsEndpoint = "/hardwareSettings";
      break;
    case ImportType.NetworkConfig:
      settingsEndpoint = "/networkConfig";
      break;
    case ImportType.ApriltagFieldLayout:
      settingsEndpoint = "/aprilTagFieldLayout";
      break;
    default:
    case ImportType.AllSettings:
      settingsEndpoint = "";
      break;
  }
  axiosPost(`/settings${settingsEndpoint}`, "import settings", formData, {
    headers: { "Content-Type": "multipart/form-data" }
  });
  showImportDialog.value = false;
  importType.value = undefined;
  importFile.value = null;
};

const showFactoryReset = ref(false);
const nukePhotonConfigDirectory = async () => {
  if (await axiosPost("/utils/nukeConfigDirectory", "delete the config directory")) {
    forceReloadPage();
  }
};

interface MetricItem {
  header: string;
  value?: string;
}

const generalMetrics = computed<MetricItem[]>(() => {
  const stats = [
    { header: "Version", value: useSettingsStore().general.version || "Unknown" },
    { header: "Image Version", value: useSettingsStore().general.imageVersion || "Unknown" },
    { header: "Hardware Model", value: useSettingsStore().general.hardwareModel || "Unknown" },
    { header: "Platform", value: useSettingsStore().general.hardwarePlatform || "Unknown" },
    { header: "GPU Acceleration", value: useSettingsStore().general.gpuAcceleration || "None detected" }
  ];

  if (!useSettingsStore().network.networkingDisabled) {
    stats.push({ header: "IP Address", value: useSettingsStore().metrics.ipAddress || "Unknown" });
  }

  return stats;
});

// @ts-expect-error This uses Intl.DurationFormat which is newly implemented and not available in TS.
const durationFormatter = new Intl.DurationFormat("en", { style: "narrow" });
const platformMetrics = computed<MetricItem[]>(() => {
  const metrics = useSettingsStore().metrics;
  const stats = [
    {
      header: "Uptime",
      value: (() => {
        const seconds = metrics.uptime;
        if (seconds === undefined) return "Unknown";

        const days = Math.floor(seconds / 86400);
        const hours = Math.floor((seconds % 86400) / 3600);
        const minutes = Math.floor((seconds % 3600) / 60);
        const secs = Math.floor(seconds % 60);

        return durationFormatter.format({
          days: days,
          hours: hours,
          minutes: minutes,
          seconds: secs
        });
      })()
    }
  ];

  if (metrics.npuUsage && metrics.npuUsage.length > 0) {
    stats.push({
      header: "NPU Usage",
      value: metrics.npuUsage?.map((usage, index) => `Core${index} ${usage}%`).join(", ") || "Unknown"
    });
  }

  if (metrics.gpuMem && metrics.gpuMemUtil && metrics.gpuMem > 0 && metrics.gpuMemUtil > 0) {
    stats.push({
      header: "GPU Memory Usage",
      value: `${metrics.gpuMemUtil}MB of ${metrics.gpuMem}MB`
    });
  }

  if (metrics.cpuThr) {
    stats.push({
      header: "CPU Throttling",
      value: metrics.cpuThr.toString()
    });
  }

  if (metrics.recvBitRate && metrics.recvBitRate !== -1) {
    stats.push({
      header: "Received Bit Rate",
      value: `${(metrics.recvBitRate / 1e6).toFixed(5)} Mb/s`
    });
  }

  return stats;
});

const cpuUsageData = ref<{ time: number; value: number }[]>([]);
const cpuMemoryUsageData = ref<{ time: number; value: number }[]>([]);
const cpuTempData = ref<{ time: number; value: number }[]>([]);
const networkUsageData = ref<{ time: number; value: number }[]>([]);

watch(metricsHistorySnapshot, () => {
  cpuUsageData.value = metricsHistorySnapshot.value.map((entry) => ({
    time: entry.time,
    value: entry.metrics.cpuUtil ?? 0
  }));
  cpuMemoryUsageData.value = metricsHistorySnapshot.value.map((entry) => ({
    time: entry.time,
    value: entry.metrics.ramUtil === -1 ? -1 : ((entry.metrics.ramUtil ?? 0) / (entry.metrics.ramMem ?? -1.0)) * 100
  }));
  cpuTempData.value = metricsHistorySnapshot.value.map((entry) => ({
    time: entry.time,
    value: entry.metrics.cpuTemp ?? 0
  }));
  networkUsageData.value = metricsHistorySnapshot.value.map((entry) => ({
    time: entry.time,
    value: entry.metrics.sentBitRate === -1 ? -1 : (entry.metrics.sentBitRate ?? 0) / 1e6
  }));
});
</script>

<template>
  <v-row no-gutters>
    <!-- Device control card -->
    <v-col class="pr-3">
      <v-card class="mb-3 rounded-12 fill-height d-flex flex-column justify-space-between" color="surface">
        <v-card-title class="d-flex justify-space-between">
          <span>Device Control</span>
        </v-card-title>
        <v-card-text class="flex-0-0">
          <v-table>
            <tbody>
              <tr v-for="(item, itemIndex) in generalMetrics.concat(platformMetrics)" :key="itemIndex">
                <td :key="itemIndex">
                  {{ item.header }}
                </td>
                <td :key="itemIndex">
                  {{ item.value }}
                </td>
              </tr>
            </tbody>
          </v-table>
        </v-card-text>
        <v-card-text class="pt-0 flex-0-0">
          <v-row>
            <v-col>
              <v-btn
                color="buttonPassive"
                :variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
                @click="useStateStore().showLogModal = true"
              >
                <v-icon start class="open-icon" size="large"> mdi-eye </v-icon>
                <span class="open-label">View Logs</span>
              </v-btn>
            </v-col>
            <v-col>
              <v-btn
                color="buttonPassive"
                :variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
                @click="openExportLogsPrompt"
              >
                <v-icon start class="open-icon" size="large"> mdi-download </v-icon>
                <span class="open-label">Download Logs</span>

                <!-- Special hidden link that gets 'clicked' when the user exports journalctl logs -->
                <a
                  ref="exportLogFile"
                  style="color: black; text-decoration: none; display: none"
                  :href="`http://${address}/api/utils/photonvision-journalctl.txt`"
                  download="photonvision-journalctl.txt"
                  target="_blank"
                />
              </v-btn>
            </v-col>
          </v-row>
        </v-card-text>
        <v-card-text class="pt-0 flex-0-0">
          <v-row>
            <v-col>
              <v-btn
                color="buttonPassive"
                :variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
                @click="() => (showImportDialog = true)"
              >
                <v-icon start class="open-icon" size="large"> mdi-import </v-icon>
                <span class="open-label">Import Settings</span>
              </v-btn>
            </v-col>
            <v-col>
              <v-btn
                color="buttonPassive"
                :variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
                @click="openExportSettingsPrompt"
              >
                <v-icon start class="open-icon" size="large"> mdi-export </v-icon>
                <span class="open-label">Export Settings</span>
              </v-btn>
            </v-col>
          </v-row>
        </v-card-text>
        <v-card-text class="pt-0 flex-0-0">
          <v-row>
            <v-col cols="12" sm="6"
              ><v-btn
                color="buttonActive"
                :variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
                @click="restartProgram"
              >
                <v-icon start class="open-icon" size="large"> mdi-restart </v-icon>
                <span class="open-label">Restart Software</span>
              </v-btn>
            </v-col>
            <v-col cols="12" sm="6">
              <v-btn
                color="buttonPassive"
                :variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
                @click="openOfflineUpdatePrompt"
              >
                <v-icon start class="open-icon" size="large"> mdi-upload </v-icon>
                <span class="open-label">Offline Update</span>
              </v-btn>
              <input
                ref="offlineUpdate"
                type="file"
                accept=".jar"
                style="display: none"
                @change="handleOfflineUpdateRequest"
              />
            </v-col>
          </v-row>
        </v-card-text>
        <v-card-text class="pt-0 flex-0-0">
          <v-row>
            <v-col cols="12" sm="6">
              <v-btn
                color="buttonActive"
                :variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
                @click="restartDevice"
              >
                <v-icon start class="open-icon" size="large"> mdi-restart-alert </v-icon>
                <span class="open-label">Reboot Device</span>
              </v-btn>
            </v-col>
            <v-col cols="12" sm="6">
              <v-btn
                color="error"
                :variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
                @click="() => (showFactoryReset = true)"
              >
                <v-icon start class="open-icon" size="large"> mdi-trash-can-outline </v-icon>
                <span class="open-icon"> Factory Reset </span>
              </v-btn>
            </v-col>
          </v-row>
        </v-card-text>
      </v-card>
    </v-col>

    <!-- Device metrics card -->
    <v-col>
      <v-card class="mb-3 rounded-12 fill-height d-flex flex-column justify-space-between" color="surface">
        <v-card-title class="d-flex justify-space-between">
          <span>Device Metrics</span>
        </v-card-title>
        <v-card-text class="pt-0 flex-0-0 pb-2">
          <div class="d-flex justify-space-between pb-3">
            <span>CPU Usage</span>
            <span>{{ Math.round(cpuUsageData.at(-1)?.value ?? 0) }}%</span>
          </div>
          <Suspense>
            <!-- Allows us to import echarts when it's actually needed  -->
            <MetricsChart id="chart" :data="cpuUsageData" type="percentage" :min="0" :max="100" color="blue" />
            <template #fallback> Loading... </template>
          </Suspense>
        </v-card-text>
        <v-card-text class="pt-0 flex-0-0 pb-2">
          <div class="d-flex justify-space-between pb-3 pt-3">
            <span>CPU Memory Usage</span>
            <span>{{ Math.round(cpuMemoryUsageData.at(-1)?.value ?? 0) }}%</span>
          </div>
          <Suspense>
            <!-- Allows us to import echarts when it's actually needed  -->
            <MetricsChart id="chart" :data="cpuMemoryUsageData" type="percentage" :min="0" :max="100" color="purple" />
            <template #fallback> Loading... </template>
          </Suspense>
        </v-card-text>
        <v-card-text class="pt-0 flex-0-0 pb-2">
          <div class="d-flex justify-space-between pb-3 pt-3">
            <span>CPU Temperature</span>
            <span>{{ cpuTempData.at(-1)?.value == -1 ? "--- " : Math.round(cpuTempData.at(-1)?.value ?? 0) }}°C</span>
          </div>
          <Suspense>
            <!-- Allows us to import echarts when it's actually needed  -->
            <MetricsChart id="chart" :data="cpuTempData" type="temperature" color="red" />
            <template #fallback> Loading... </template>
          </Suspense>
        </v-card-text>
        <v-card-text class="pt-0 flex-0-0">
          <div class="d-flex justify-space-between pb-3 pt-3">
            <tooltipped-label
              label="Network Usage"
              icon="mdi-information"
              location="top"
              tooltip="Measured rate for this coprocessor ONLY. This FMS limit is for ALL robot communication. If you are experiencing bandwidth issues while under this limit, check other sources."
            />
            <span
              >{{ networkUsageData.at(-1)?.value == -1 ? "---" : networkUsageData.at(-1)?.value.toFixed(3) }} Mb/s</span
            >
          </div>
          <Suspense>
            <!-- Allows us to import echarts when it's actually needed  -->
            <MetricsChart id="chart" :data="networkUsageData" type="mb" :min="0" :max="10" color="green" />
            <template #fallback> Loading... </template>
          </Suspense>
        </v-card-text>
      </v-card>
    </v-col>
  </v-row>

  <!-- Factory reset modal -->
  <pv-delete-modal
    v-model="showFactoryReset"
    title="Factory Reset PhotonVision"
    description="This will delete all settings and configurations stored on this device, including network settings. This action cannot be undone."
    expected-confirmation-text="Delete Everything"
    :on-confirm="nukePhotonConfigDirectory"
    :on-backup="openExportSettingsPrompt"
    delete-text="Factory reset"
  />

  <!-- Import settings modal -->
  <v-dialog
    v-model="showImportDialog"
    width="600"
    @update:modelValue="
      () => {
        importType = undefined;
        importFile = null;
      }
    "
  >
    <v-card color="surface" dark>
      <v-card-title class="pb-0">Import Settings</v-card-title>
      <v-card-text>
        Upload and apply previously saved or exported PhotonVision settings to this device
        <div class="pa-5 pb-0">
          <pv-select
            v-model="importType"
            label="Type"
            tooltip="Select the type of settings file you are trying to upload"
            :items="['All Settings', 'Hardware Config', 'Hardware Settings', 'Network Config', 'Apriltag Layout']"
            :select-cols="10"
            style="width: 100%"
          />
          <v-file-input
            v-model="importFile"
            class="pb-5"
            variant="underlined"
            :disabled="importType === undefined"
            :error-messages="importType === undefined ? 'Settings type not selected' : ''"
            :accept="importType === ImportType.AllSettings ? '.zip' : '.json'"
          />
          <v-btn
            color="primary"
            :disabled="importFile === null"
            :variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
            @click="handleSettingsImport"
          >
            <v-icon start class="open-icon"> mdi-import </v-icon>
            <span class="open-label">Import Settings</span>
          </v-btn>
        </div>
      </v-card-text>
    </v-card>
  </v-dialog>

  <v-dialog v-model="offlineUpdateDialog.show" :width="700" dark>
    <v-card color="surface" flat>
      <v-card-title style="display: flex; justify-content: center"> Offline Update </v-card-title>
      <v-card-text class="pt-0 pb-10px">
        <span> {{ offlineUpdateDialog.confirmString }} </span>
      </v-card-text>
      <v-card-text class="pt-10px">
        <v-row class="align-center text-white">
          <v-col cols="12">
            <v-btn
              color="buttonActive"
              width="100%"
              :variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
              @click="
                offlineUpdateDialog.show = false;
                handleOfflineUpdate(offlineUpdate.value.files[0]);
              "
            >
              <v-icon start class="open-icon" size="large"> mdi-upload </v-icon>
              <span class="open-label"> Confirm Update </span>
            </v-btn>
          </v-col>
        </v-row>
      </v-card-text>
    </v-card>
  </v-dialog>

  <a
    ref="exportSettings"
    style="color: black; text-decoration: none; display: none"
    :href="`http://${address}/api/settings/photonvision_config.zip`"
    download="photonvision-settings.zip"
    target="_blank"
  />
</template>

<style scoped lang="scss">
.v-btn:not(.refresh) {
  width: 100%;
}
.fill-height {
  height: calc(100% - 12px) !important;
}
@media only screen and (max-width: 351px) {
  .open-icon {
    margin: 0 !important;
  }
  .open-label {
    display: none;
  }
}
</style>
