Newer
Older
gnexus-book / ui / src / main.js
@Eugene Sukhodolskiy Eugene Sukhodolskiy 2 days ago 12 KB Add document drill-down to UI
import { createApp, computed, onMounted, ref } from "vue";
import "gnexus-ui-kit/dist/css/kit.css";
import "gnexus-ui-kit/dist/assets/fonts/phosphor-icons/src/css/icons.css";
import {
  GnBadge,
  GnButton,
  GnDescriptionList,
  GnEmptyState,
  GnMetricCard,
  GnPageHeader,
  GnTabs,
  GnToolbar,
  GnToastProvider
} from "gnexus-ui-kit/vue";
import "./styles.css";

const apiGet = async (path) => {
  const response = await fetch(`/api${path}`);
  if (!response.ok) {
    throw new Error(`${path} returned ${response.status}`);
  }
  return response.json();
};

const AppScreen = {
  components: {
    GnBadge,
    GnButton,
    GnDescriptionList,
    GnEmptyState,
    GnMetricCard,
    GnPageHeader,
    GnTabs,
    GnToolbar
  },
  setup() {
    const activeTab = ref("docs");
    const loading = ref(true);
    const error = ref("");
    const health = ref(null);
    const validation = ref(null);
    const freshness = ref(null);
    const docs = ref([]);
    const selectedDocPath = ref("");
    const selectedDoc = ref(null);
    const docLoading = ref(false);
    const docError = ref("");
    const inventoryTypes = ref([]);
    const selectedInventoryType = ref("");
    const inventoryLoading = ref(false);
    const inventoryError = ref("");
    const inventoryRecords = ref([]);
    const selectedInventoryId = ref("");
    const changes = ref([]);
    const gitStatus = ref(null);

    const tabs = [
      { id: "docs", label: "Docs", icon: "ph-files" },
      { id: "inventory", label: "Inventory", icon: "ph-database" },
      { id: "changes", label: "Changes", icon: "ph-git-pull-request" },
      { id: "git", label: "Git", icon: "ph-git-branch" }
    ];

    const validationVariant = computed(() =>
      validation.value?.status === "ok" ? "success" : "danger"
    );

    const freshnessVariant = computed(() =>
      freshness.value?.status === "ok" ? "success" : "danger"
    );

    const overview = computed(() => [
      { key: "repo", term: "Repository", value: health.value?.repo_root || "unknown" },
      { key: "validation", term: "Validation", value: validation.value?.status || "unknown" },
      { key: "freshness", term: "Freshness", value: freshness.value?.status || "unknown" }
    ]);

    const selectedDocFrontmatterJson = computed(() => {
      if (!selectedDoc.value?.frontmatter) {
        return "";
      }
      return JSON.stringify(selectedDoc.value.frontmatter, null, 2);
    });

    const loadDoc = async (path) => {
      selectedDocPath.value = path;
      selectedDoc.value = null;
      docLoading.value = true;
      docError.value = "";
      try {
        selectedDoc.value = await apiGet(`/docs/read?path=${encodeURIComponent(path)}`);
      } catch (caught) {
        docError.value = caught instanceof Error ? caught.message : `Failed to load document ${path}`;
      } finally {
        docLoading.value = false;
      }
    };

    const selectedInventoryRecord = computed(() => {
      if (!selectedInventoryId.value) {
        return null;
      }
      return inventoryRecords.value.find((item) => item?.id === selectedInventoryId.value) || null;
    });

    const selectedInventoryRecordJson = computed(() => {
      if (!selectedInventoryRecord.value) {
        return "";
      }
      return JSON.stringify(selectedInventoryRecord.value, null, 2);
    });

    const inventoryRecordTitle = (item) => item?.name || item?.fqdn || item?.id || "unknown";

    const loadInventoryType = async (type) => {
      selectedInventoryType.value = type;
      selectedInventoryId.value = "";
      inventoryLoading.value = true;
      inventoryError.value = "";
      try {
        const records = await apiGet(`/inventory/${type}`);
        inventoryRecords.value = Array.isArray(records) ? records : [];
        selectedInventoryId.value = inventoryRecords.value[0]?.id || "";
      } catch (caught) {
        inventoryRecords.value = [];
        inventoryError.value =
          caught instanceof Error ? caught.message : `Failed to load inventory ${type}`;
      } finally {
        inventoryLoading.value = false;
      }
    };

    const selectInventoryRecord = (item) => {
      selectedInventoryId.value = item?.id || "";
    };

    const load = async () => {
      loading.value = true;
      error.value = "";
      try {
        const [
          healthData,
          validationData,
          freshnessData,
          docsData,
          inventoryData,
          changesData,
          gitData
        ] = await Promise.all([
          apiGet("/health"),
          apiGet("/validate"),
          apiGet("/health/freshness"),
          apiGet("/docs"),
          apiGet("/inventory"),
          apiGet("/changes"),
          apiGet("/git/status")
        ]);
        health.value = healthData;
        validation.value = validationData;
        freshness.value = freshnessData;
        docs.value = docsData;
        inventoryTypes.value = inventoryData;
        changes.value = changesData;
        gitStatus.value = gitData;
        if (!selectedDocPath.value && docsData.length) {
          await loadDoc(docsData[0].path);
        }
        if (!selectedInventoryType.value && inventoryData.length) {
          await loadInventoryType(inventoryData[0]);
        }
      } catch (caught) {
        error.value = caught instanceof Error ? caught.message : "Failed to load API data";
      } finally {
        loading.value = false;
      }
    };

    onMounted(load);

    return {
      activeTab,
      changes,
      docError,
      docLoading,
      docs,
      error,
      freshness,
      freshnessVariant,
      gitStatus,
      inventoryError,
      inventoryLoading,
      inventoryRecordTitle,
      inventoryRecords,
      inventoryTypes,
      load,
      loadDoc,
      loadInventoryType,
      loading,
      overview,
      selectedInventoryId,
      selectedInventoryRecord,
      selectedInventoryRecordJson,
      selectedInventoryType,
      selectedDoc,
      selectedDocFrontmatterJson,
      selectedDocPath,
      selectInventoryRecord,
      tabs,
      validation,
      validationVariant
    };
  },
  template: `
    <main class="gnb-shell">
      <GnPageHeader
        title="Gnexus Book"
        subtitle="Infrastructure documentation maintenance console"
        kicker="Knowledge Base"
        accent
      >
        <template #actions>
          <GnButton variant="secondary" icon="ph-arrow-clockwise" @click="load">
            Refresh
          </GnButton>
        </template>
      </GnPageHeader>

      <section class="gnb-status">
        <GnMetricCard label="Documents" :value="String(docs.length)" icon="ph-files" meta="Markdown pages" />
        <GnMetricCard label="Inventory" :value="String(inventoryTypes.length)" icon="ph-database" meta="YAML types" />
        <GnMetricCard label="Changes" :value="String(changes.length)" icon="ph-git-pull-request" meta="Pending records" />
      </section>

      <section class="section">
        <GnToolbar title="System Status" :meta="loading ? 'Loading' : 'Live API'">
          <template #actions>
            <GnBadge :variant="validationVariant">validation {{ validation?.status || 'unknown' }}</GnBadge>
            <GnBadge :variant="freshnessVariant">freshness {{ freshness?.status || 'unknown' }}</GnBadge>
          </template>
        </GnToolbar>
        <GnDescriptionList :items="overview" />
        <div v-if="error" class="gnb-error">{{ error }}</div>
      </section>

      <section class="section">
        <GnTabs v-model="activeTab" :items="tabs">
          <template #docs>
            <div v-if="docs.length" class="gnb-docs">
              <aside class="gnb-list">
                <button
                  v-for="doc in docs"
                  :key="doc.path"
                  type="button"
                  class="gnb-row gnb-row-button"
                  :class="{ 'is-active': doc.path === selectedDocPath }"
                  @click="loadDoc(doc.path)"
                >
                  <strong>{{ doc.title }}</strong>
                  <span>{{ doc.path }}</span>
                </button>
              </aside>

              <section class="gnb-detail">
                <GnToolbar
                  :title="selectedDoc?.path || 'Document'"
                  :meta="docLoading ? 'Loading' : 'Markdown'"
                />
                <div v-if="docError" class="gnb-error">{{ docError }}</div>
                <div v-else-if="selectedDoc" class="gnb-doc-detail">
                  <section>
                    <h3>Frontmatter</h3>
                    <pre class="gnb-json gnb-json-small">{{ selectedDocFrontmatterJson }}</pre>
                  </section>
                  <section>
                    <h3>Body</h3>
                    <pre class="gnb-markdown">{{ selectedDoc.body }}</pre>
                  </section>
                </div>
                <GnEmptyState v-else title="No document selected" text="Select a document to inspect its metadata and body." />
              </section>
            </div>
            <GnEmptyState v-else title="No documents loaded" text="The backend did not return document records." />
          </template>

          <template #inventory>
            <div v-if="inventoryTypes.length" class="gnb-inventory">
              <aside class="gnb-list">
                <button
                  v-for="type in inventoryTypes"
                  :key="type"
                  type="button"
                  class="gnb-row gnb-row-button"
                  :class="{ 'is-active': type === selectedInventoryType }"
                  @click="loadInventoryType(type)"
                >
                  <strong>{{ type }}</strong>
                  <span>/inventory/{{ type }}</span>
                </button>
              </aside>

              <section class="gnb-detail">
                <GnToolbar
                  :title="selectedInventoryType || 'Inventory'"
                  :meta="inventoryLoading ? 'Loading' : String(inventoryRecords.length) + ' records'"
                />
                <div v-if="inventoryError" class="gnb-error">{{ inventoryError }}</div>
                <div v-else-if="inventoryRecords.length" class="gnb-detail-grid">
                  <div class="gnb-list">
                    <button
                      v-for="item in inventoryRecords"
                      :key="item.id || inventoryRecordTitle(item)"
                      type="button"
                      class="gnb-row gnb-row-button"
                      :class="{ 'is-active': item.id === selectedInventoryId }"
                      @click="selectInventoryRecord(item)"
                    >
                      <strong>{{ inventoryRecordTitle(item) }}</strong>
                      <span>{{ item.id || 'no-id' }}</span>
                    </button>
                  </div>
                  <pre class="gnb-json">{{ selectedInventoryRecordJson }}</pre>
                </div>
                <GnEmptyState v-else title="No records" text="This inventory type is currently empty." />
              </section>
            </div>
            <GnEmptyState v-else title="No inventory loaded" text="The backend did not return inventory types." />
          </template>

          <template #changes>
            <div v-if="changes.length" class="gnb-list">
              <article v-for="change in changes" :key="change.id" class="gnb-row">
                <strong>{{ change.summary }}</strong>
                <span>{{ change.status }} · {{ change.kind }} · {{ change.target }}</span>
              </article>
            </div>
            <GnEmptyState v-else title="No pending changes" text="Pending changes will appear here after agents propose updates." />
          </template>

          <template #git>
            <div v-if="gitStatus?.entries?.length" class="gnb-list">
              <article v-for="entry in gitStatus.entries" :key="entry.path" class="gnb-row">
                <strong>{{ entry.status }}</strong>
                <span>{{ entry.path }}</span>
              </article>
            </div>
            <GnEmptyState v-else title="Working tree clean" text="No tracked or untracked changes are currently reported." />
          </template>
        </GnTabs>
      </section>
    </main>
  `
};

const App = {
  components: {
    AppScreen,
    GnToastProvider
  },
  template: `
    <GnToastProvider>
      <AppScreen />
    </GnToastProvider>
  `
};

createApp(App).mount("#app");