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 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 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,
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-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-changes">
<aside 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>
<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-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>
<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-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-git-commit" />
<label class="gnb-field">
<span>Details</span>
<textarea v-model="commitDetails" rows="5"></textarea>
</label>
<GnButton
variant="success"
icon="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");