Newer
Older
gnexus-book / ui / src / main.js
@Eugene Sukhodolskiy Eugene Sukhodolskiy 2 days ago 23 KB Apply UI kit layout refinements
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,
  GnInput,
  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 apiPost = async (path, payload = null) => {
  const response = await fetch(`/api${path}`, {
    method: "POST",
    headers: payload ? { "Content-Type": "application/json" } : undefined,
    body: payload ? JSON.stringify(payload) : undefined
  });
  if (!response.ok) {
    const text = await response.text();
    throw new Error(`${path} returned ${response.status}: ${text}`);
  }
  return response.json();
};

const AppScreen = {
  components: {
    GnBadge,
    GnButton,
    GnDescriptionList,
    GnEmptyState,
    GnInput,
    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 selectedChangeId = ref("");
    const selectedChange = ref(null);
    const changeLoading = ref(false);
    const changeApplying = ref(false);
    const changeError = ref("");
    const proposalSummary = ref("");
    const proposalTarget = ref("10-systems/new-document.md");
    const proposalReason = ref("");
    const proposalContent = ref(
      "---\nowner: gmikcon\nstatus: draft\nlast_reviewed: 2026-05-09\nreview_interval: 90d\nconfidence: medium\nsource_of_truth: manual\n---\n\n# New Document\n\n"
    );
    const proposalRunning = ref(false);
    const proposalError = ref("");
    const gitStatus = ref(null);
    const selectedCommitFiles = ref([]);
    const commitSummary = ref("");
    const commitDetails = ref("");
    const commitRunning = ref(false);
    const commitError = ref("");
    const commitResult = 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 selectedChangeJson = computed(() => {
      if (!selectedChange.value) {
        return "";
      }
      return JSON.stringify(selectedChange.value, null, 2);
    });

    const changeStatusVariant = (status) => {
      if (status === "applied") {
        return "success";
      }
      if (status === "rejected") {
        return "danger";
      }
      return "secondary";
    };

    const isCommittablePath = (path) => {
      if (!path) {
        return false;
      }
      const denied = [
        ".codex",
        ".git",
        ".pytest_cache",
        ".ruff_cache",
        ".venv",
        "__pycache__",
        "gnexus_book_server.egg-info",
        "node_modules"
      ];
      return !path.split("/").some((part) => denied.includes(part));
    };

    const committableEntries = computed(() =>
      (gitStatus.value?.entries || []).filter((entry) => isCommittablePath(entry.path))
    );

    const commitCanRun = computed(
      () => commitSummary.value.trim().length > 0 && selectedCommitFiles.value.length > 0 && !commitRunning.value
    );

    const syncCommitSelection = (gitData) => {
      const allowed = (gitData?.entries || [])
        .map((entry) => entry.path)
        .filter((path) => isCommittablePath(path));
      selectedCommitFiles.value = selectedCommitFiles.value.filter((path) => allowed.includes(path));
      if (!selectedCommitFiles.value.length) {
        selectedCommitFiles.value = allowed;
      }
    };

    const toggleCommitFile = (path) => {
      if (!isCommittablePath(path)) {
        return;
      }
      if (selectedCommitFiles.value.includes(path)) {
        selectedCommitFiles.value = selectedCommitFiles.value.filter((item) => item !== path);
      } else {
        selectedCommitFiles.value = [...selectedCommitFiles.value, path];
      }
    };

    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 loadChange = async (id) => {
      selectedChangeId.value = id;
      selectedChange.value = null;
      changeLoading.value = true;
      changeError.value = "";
      try {
        selectedChange.value = await apiGet(`/changes/${encodeURIComponent(id)}`);
      } catch (caught) {
        changeError.value = caught instanceof Error ? caught.message : `Failed to load change ${id}`;
      } finally {
        changeLoading.value = false;
      }
    };

    const applySelectedChange = async () => {
      if (!selectedChangeId.value) {
        return;
      }
      changeApplying.value = true;
      changeError.value = "";
      try {
        selectedChange.value = await apiPost(`/changes/${encodeURIComponent(selectedChangeId.value)}/apply`);
        const [changesData, validationData, freshnessData, gitData] = await Promise.all([
          apiGet("/changes"),
          apiGet("/validate"),
          apiGet("/health/freshness"),
          apiGet("/git/status")
        ]);
        changes.value = changesData;
        validation.value = validationData;
        freshness.value = freshnessData;
        gitStatus.value = gitData;
        syncCommitSelection(gitData);
      } catch (caught) {
        changeError.value = caught instanceof Error ? caught.message : "Failed to apply change";
      } finally {
        changeApplying.value = false;
      }
    };

    const proposeDocChange = async () => {
      if (!proposalSummary.value.trim() || !proposalTarget.value.trim() || !proposalContent.value.trim()) {
        proposalError.value = "Summary, target, and content are required";
        return;
      }
      proposalRunning.value = true;
      proposalError.value = "";
      try {
        const created = await apiPost("/changes", {
          kind: "doc",
          target: proposalTarget.value.trim(),
          summary: proposalSummary.value.trim(),
          reason: proposalReason.value.trim(),
          payload: {
            content: proposalContent.value
          }
        });
        changes.value = await apiGet("/changes");
        await loadChange(created.id);
        proposalSummary.value = "";
        proposalReason.value = "";
      } catch (caught) {
        proposalError.value = caught instanceof Error ? caught.message : "Failed to propose change";
      } finally {
        proposalRunning.value = false;
      }
    };

    const commitSelectedFiles = async () => {
      if (!commitCanRun.value) {
        return;
      }
      commitRunning.value = true;
      commitError.value = "";
      commitResult.value = null;
      try {
        commitResult.value = await apiPost("/commit", {
          summary: commitSummary.value.trim(),
          details: commitDetails.value.trim(),
          files: selectedCommitFiles.value
        });
        commitSummary.value = "";
        commitDetails.value = "";
        const [gitData, validationData] = await Promise.all([
          apiGet("/git/status"),
          apiGet("/validate")
        ]);
        gitStatus.value = gitData;
        validation.value = validationData;
        syncCommitSelection(gitData);
      } catch (caught) {
        commitError.value = caught instanceof Error ? caught.message : "Commit failed";
      } finally {
        commitRunning.value = false;
      }
    };

    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;
        syncCommitSelection(gitData);
        if (!selectedChangeId.value && changesData.length) {
          await loadChange(changesData[0].id);
        }
        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,
      applySelectedChange,
      changeApplying,
      changeError,
      changeLoading,
      changeStatusVariant,
      changes,
      commitCanRun,
      commitDetails,
      commitError,
      commitResult,
      commitRunning,
      commitSelectedFiles,
      commitSummary,
      committableEntries,
      docError,
      docLoading,
      docs,
      error,
      freshness,
      freshnessVariant,
      gitStatus,
      inventoryError,
      inventoryLoading,
      inventoryRecordTitle,
      inventoryRecords,
      inventoryTypes,
      load,
      loadDoc,
      loadInventoryType,
      loading,
      overview,
      proposalContent,
      proposalError,
      proposalReason,
      proposalRunning,
      proposalSummary,
      proposalTarget,
      proposeDocChange,
      selectedInventoryId,
      selectedInventoryRecord,
      selectedInventoryRecordJson,
      selectedInventoryType,
      selectedDoc,
      selectedDocFrontmatterJson,
      selectedDocPath,
      selectedChange,
      selectedChangeId,
      selectedChangeJson,
      selectedCommitFiles,
      selectInventoryRecord,
      loadChange,
      toggleCommitFile,
      isCommittablePath,
      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 ph-arrow-clockwise" @click="load">
            Refresh
          </GnButton>
        </template>
      </GnPageHeader>

      <section class="gnb-status">
        <GnMetricCard label="Documents" :value="String(docs.length)" icon="ph ph-files" meta="Markdown pages" />
        <GnMetricCard label="Inventory" :value="String(inventoryTypes.length)" icon="ph ph-database" meta="YAML types" />
        <GnMetricCard label="Changes" :value="String(changes.length)" icon="ph 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 class="gnb-changes-workspace">
              <div class="gnb-changes">
                <aside v-if="changes.length" class="gnb-list">
                  <button
                    v-for="change in changes"
                    :key="change.id"
                    type="button"
                    class="gnb-row gnb-row-button"
                    :class="{ 'is-active': change.id === selectedChangeId }"
                    @click="loadChange(change.id)"
                  >
                    <strong>{{ change.summary }}</strong>
                    <span>{{ change.status }} · {{ change.kind }} · {{ change.target }}</span>
                  </button>
                </aside>
                <GnEmptyState v-else title="No pending changes" text="Pending changes will appear here after agents propose updates." />

                <section class="gnb-detail">
                  <GnToolbar
                    :title="selectedChange?.summary || 'Pending change'"
                    :meta="changeLoading ? 'Loading' : selectedChange?.status || 'unknown'"
                  >
                    <template #actions>
                      <GnBadge v-if="selectedChange" :variant="changeStatusVariant(selectedChange.status)">
                        {{ selectedChange.status }}
                      </GnBadge>
                      <GnButton
                        v-if="selectedChange?.status === 'pending'"
                        variant="success"
                        icon="ph ph-check"
                        :disabled="changeApplying"
                        @click="applySelectedChange"
                      >
                        {{ changeApplying ? 'Applying' : 'Apply' }}
                      </GnButton>
                    </template>
                  </GnToolbar>
                  <div v-if="changeError" class="gnb-error">{{ changeError }}</div>
                  <pre v-if="selectedChange" class="gnb-json">{{ selectedChangeJson }}</pre>
                  <GnEmptyState v-else title="No change selected" text="Select a pending change to inspect its payload and status." />
                </section>
              </div>

              <section class="gnb-proposal">
                <GnToolbar title="Propose Document Change" meta="Creates a pending change" />
                <div class="gnb-form-grid">
                  <GnInput v-model="proposalSummary" label="Summary" icon="ph ph-note-pencil" />
                  <GnInput v-model="proposalTarget" label="Target path" icon="ph ph-file-md" />
                </div>
                <label class="gnb-field">
                  <span>Reason</span>
                  <textarea v-model="proposalReason" rows="3"></textarea>
                </label>
                <label class="gnb-field">
                  <span>Markdown content</span>
                  <textarea v-model="proposalContent" rows="12"></textarea>
                </label>
                <GnButton
                  variant="secondary"
                  icon="ph ph-plus"
                  :disabled="proposalRunning"
                  @click="proposeDocChange"
                >
                  {{ proposalRunning ? 'Creating' : 'Create pending change' }}
                </GnButton>
                <div v-if="proposalError" class="gnb-error">{{ proposalError }}</div>
              </section>
            </div>
          </template>

          <template #git>
            <div v-if="gitStatus?.entries?.length" class="gnb-git">
              <section class="gnb-list">
                <label
                  v-for="entry in gitStatus.entries"
                  :key="entry.path"
                  class="gnb-row gnb-checkbox-row"
                  :class="{ 'is-disabled': !isCommittablePath(entry.path) }"
                >
                  <input
                    type="checkbox"
                    :checked="selectedCommitFiles.includes(entry.path)"
                    :disabled="!isCommittablePath(entry.path)"
                    @change="toggleCommitFile(entry.path)"
                  />
                  <strong>{{ entry.status }}</strong>
                  <span>{{ entry.path }}</span>
                </label>
              </section>

              <section class="gnb-commit">
                <GnToolbar
                  title="Local Commit"
                  :meta="String(selectedCommitFiles.length) + ' selected'"
                />
                <GnInput v-model="commitSummary" label="Summary" icon="ph ph-git-commit" />
                <label class="gnb-field">
                  <span>Details</span>
                  <textarea v-model="commitDetails" rows="5"></textarea>
                </label>
                <GnButton
                  variant="success"
                  icon="ph ph-check"
                  :disabled="!commitCanRun"
                  @click="commitSelectedFiles"
                >
                  {{ commitRunning ? 'Committing' : 'Commit selected' }}
                </GnButton>
                <div v-if="commitError" class="gnb-error">{{ commitError }}</div>
                <pre v-if="commitResult" class="gnb-json gnb-json-small">{{ JSON.stringify(commitResult, null, 2) }}</pre>
              </section>
            </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");