ostool-server 0.1.2

Server for managing development boards, serial sessions, and TFTP artifacts
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";

import StatusPill from "@/components/StatusPill.vue";
import { api } from "@/api/client";
import { useUiStore } from "@/stores/ui";
import type { BuiltinTftpConfig, SystemTftpdHpaConfig, TftpConfig, TftpStatus } from "@/types/api";
import { describeTftpStatus } from "@/utils/tftpStatus";

const ui = useUiStore();
const loading = ref(true);
const saving = ref(false);
const reconciling = ref(false);
const tftpConfig = ref<TftpConfig | null>(null);
const tftpStatus = ref<TftpStatus | null>(null);

const tone = computed(() =>
  tftpStatus.value
    ? describeTftpStatus(tftpStatus.value)
    : { tone: "neutral" as const, label: "未知" },
);

async function loadTftp() {
  loading.value = true;
  try {
    const [configResponse, statusResponse] = await Promise.all([api.getTftpConfig(), api.getTftpStatus()]);
    tftpConfig.value = configResponse.tftp;
    tftpStatus.value = statusResponse.status;
  } catch (error) {
    ui.setError((error as Error).message);
  } finally {
    loading.value = false;
  }
}

function switchProvider(provider: TftpConfig["provider"]) {
  if (provider === "builtin") {
    const current = tftpConfig.value;
    const next: BuiltinTftpConfig = {
      provider: "builtin",
      enabled: current?.enabled ?? true,
      root_dir: current?.root_dir ?? "/srv/tftp",
      bind_addr: current && current.provider === "builtin" ? current.bind_addr : "0.0.0.0:69",
    };
    tftpConfig.value = next;
    return;
  }

  const current = tftpConfig.value;
  const next: SystemTftpdHpaConfig = {
    provider: "system_tftpd_hpa",
    enabled: current?.enabled ?? true,
    root_dir: current?.root_dir ?? "/srv/tftp",
    config_path:
      current && current.provider === "system_tftpd_hpa"
        ? current.config_path
        : "/etc/default/tftpd-hpa",
    service_name:
      current && current.provider === "system_tftpd_hpa" ? current.service_name : "tftpd-hpa",
    username: current && current.provider === "system_tftpd_hpa" ? current.username : "tftp",
    address: current && current.provider === "system_tftpd_hpa" ? current.address : ":69",
    options: current && current.provider === "system_tftpd_hpa" ? current.options : "-l -s -c",
    manage_config:
      current && current.provider === "system_tftpd_hpa" ? current.manage_config : false,
    reconcile_on_start:
      current && current.provider === "system_tftpd_hpa" ? current.reconcile_on_start : false,
  };
  tftpConfig.value = next;
}

async function saveConfig() {
  if (!tftpConfig.value) {
    return;
  }

  saving.value = true;
  try {
    const response = await api.updateTftpConfig(tftpConfig.value);
    tftpConfig.value = response.tftp;
    ui.setSuccess("已保存 TFTP 配置");
    await loadTftp();
  } catch (error) {
    ui.setError((error as Error).message);
  } finally {
    saving.value = false;
  }
}

async function reconcile() {
  reconciling.value = true;
  try {
    const response = await api.reconcileTftp();
    tftpStatus.value = response.status;
    ui.setSuccess("已执行 TFTP reconcile");
  } catch (error) {
    ui.setError((error as Error).message);
  } finally {
    reconciling.value = false;
  }
}

onMounted(() => {
  ui.clearMessages();
  void loadTftp();
});
</script>

<template>
  <section class="page-grid">
    <div class="panel">
      <div class="panel-heading">
        <div>
          <p class="eyebrow">Provider 与运行状态</p>
          <h3>TFTP 配置管理</h3>
        </div>
        <div class="toolbar-actions">
          <button class="ghost-button" @click="loadTftp">刷新</button>
          <button class="ghost-button" :disabled="reconciling" @click="reconcile">
            {{ reconciling ? "执行中..." : "执行 Reconcile" }}
          </button>
          <button class="primary-button" :disabled="saving || !tftpConfig" @click="saveConfig">
            {{ saving ? "保存中..." : "保存配置" }}
          </button>
        </div>
      </div>

      <div v-if="loading" class="empty-state">正在加载 TFTP 配置...</div>
      <template v-else-if="tftpConfig && tftpStatus">
        <div class="split-grid">
          <section class="panel nested-panel">
            <div class="panel-heading compact">
              <h4>当前 Provider</h4>
              <StatusPill :tone="tone.tone" :label="tone.label" />
            </div>
            <div class="form-grid two-columns">
              <label class="field">
                <span>Provider</span>
                <select :value="tftpConfig.provider" @change="switchProvider(($event.target as HTMLSelectElement).value as TftpConfig['provider'])">
                  <option value="builtin">builtin</option>
                  <option value="system_tftpd_hpa">system_tftpd_hpa</option>
                </select>
              </label>
              <label class="checkbox-field">
                <input v-model="tftpConfig.enabled" type="checkbox" />
                <span>启用 TFTP</span>
              </label>
              <label class="field">
                <span>根目录</span>
                <input v-model="tftpConfig.root_dir" />
              </label>
              <template v-if="tftpConfig.provider === 'builtin'">
                <label class="field">
                  <span>绑定地址</span>
                  <input v-model="tftpConfig.bind_addr" placeholder="0.0.0.0:69" />
                </label>
              </template>
              <template v-else>
                <label class="field">
                  <span>配置文件</span>
                  <input v-model="tftpConfig.config_path" />
                </label>
                <label class="field">
                  <span>服务名</span>
                  <input v-model="tftpConfig.service_name" />
                </label>
                <label class="field">
                  <span>运行用户</span>
                  <input v-model="tftpConfig.username" />
                </label>
                <label class="field">
                  <span>监听地址</span>
                  <input v-model="tftpConfig.address" />
                </label>
                <label class="field">
                  <span>启动选项</span>
                  <input v-model="tftpConfig.options" />
                </label>
                <label class="checkbox-field">
                  <input v-model="tftpConfig.manage_config" type="checkbox" />
                  <span>允许服务端管理配置文件与重启 service</span>
                </label>
                <label class="checkbox-field">
                  <input v-model="tftpConfig.reconcile_on_start" type="checkbox" />
                  <span>启动时自动 reconcile</span>
                </label>
              </template>
            </div>
          </section>

          <section class="panel nested-panel">
            <div class="panel-heading compact">
              <h4>运行状态</h4>
            </div>
            <dl class="key-value-list">
              <div>
                <dt>健康状态</dt>
                <dd>{{ tftpStatus.healthy ? "正常" : "异常" }}</dd>
              </div>
              <div>
                <dt>目录可写</dt>
                <dd>{{ tftpStatus.writable ? "可写" : "不可写" }}</dd>
              </div>
              <div>
                <dt>根目录</dt>
                <dd>{{ tftpStatus.root_dir }}</dd>
              </div>
              <div>
                <dt>绑定/监听</dt>
                <dd>{{ tftpStatus.bind_addr_or_address || "-" }}</dd>
              </div>
              <div>
                <dt>服务状态</dt>
                <dd>{{ tftpStatus.service_state || "-" }}</dd>
              </div>
              <div>
                <dt>当前计算出的 server_ip</dt>
                <dd>{{ tftpStatus.resolved_server_ip || "-" }}</dd>
              </div>
              <div>
                <dt>当前计算出的 netmask</dt>
                <dd>{{ tftpStatus.resolved_netmask || "-" }}</dd>
              </div>
            </dl>
            <p v-if="tftpStatus.last_error" class="diagnostic-error">
              {{ tftpStatus.last_error }}
            </p>
          </section>
        </div>
      </template>
    </div>
  </section>
</template>