feat: browser-style panel nav, repo file routing, migration 0007
graph, search, api_catalog pages: back/forward history stack (PanelNav/dpNav). File artifact paths open in external tabs via card.repo (Gitea source URL) or card.docs (cargo docs for .rs) — openFile/openFileInPanel removed from all pages. Tera | safe required for URL values inside <script> blocks (auto-escape of slashes). card.ncl: repo field added. insert_brand_ctx: injects card_repo/card_docs into Tera context. #[onto_api] proc-macro: source_file = file!() emitted; ApiRouteEntry.source_file populated in primary catalog handler. migration 0007-card-repo-field: check card.ncl for repo field; skip if absent.
This commit is contained in:
parent
da083fb9ec
commit
75892a8eea
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,6 +14,7 @@ nushell-*
|
|||||||
github-com
|
github-com
|
||||||
.coder
|
.coder
|
||||||
target
|
target
|
||||||
|
artifacts/
|
||||||
distribution
|
distribution
|
||||||
.qodo
|
.qodo
|
||||||
# enviroment to load on bin/build
|
# enviroment to load on bin/build
|
||||||
|
|||||||
@ -212,7 +212,7 @@ let d = import "../ontology/defaults/core.ncl" in
|
|||||||
name = "Ontoref Daemon",
|
name = "Ontoref Daemon",
|
||||||
pole = 'Yang,
|
pole = 'Yang,
|
||||||
level = 'Practice,
|
level = 'Practice,
|
||||||
description = "Runtime support daemon for the ontoref protocol. Provides NCL export caching, file watching, actor registry, notification barrier, HTTP API (11 pages), MCP server (29 tools, stdio + streamable-HTTP), Q&A NCL persistence, quick-actions catalog, passive drift observation, unified auth/session management, per-file ontology version counters (GET /projects/{slug}/ontology/versions), and annotated API catalog (GET /api/catalog). API catalog populated at link time via #[onto_api] proc-macro + inventory — zero runtime overhead. Launched via ADR-004 NCL pipe bootstrap: nickel export config.ncl | ontoref-daemon.bin --config-stdin.",
|
description = "Runtime support daemon for the ontoref protocol. Provides NCL export caching, file watching, actor registry, notification barrier, HTTP API (11 pages), MCP server (29 tools, stdio + streamable-HTTP), Q&A NCL persistence, quick-actions catalog, passive drift observation, unified auth/session management, per-file ontology version counters (GET /projects/{slug}/ontology/versions), and annotated API catalog (GET /api/catalog). API catalog populated at link time via #[onto_api] proc-macro + inventory — zero runtime overhead. Launched via ADR-004 NCL pipe bootstrap: nickel export config.ncl | ontoref-daemon.bin --config-stdin. Graph, search, and api_catalog UI pages carry browser-style panel navigation (back/forward history stack). File artifact paths open in external tabs: card.repo (Gitea source URL) for most files, card.docs (cargo docs) for .rs files — no inline file loading. card_repo/card_docs injected into Tera context from insert_brand_ctx; | safe filter required for URL values inside <script> blocks.",
|
||||||
invariant = false,
|
invariant = false,
|
||||||
artifact_paths = [
|
artifact_paths = [
|
||||||
"crates/ontoref-daemon/",
|
"crates/ontoref-daemon/",
|
||||||
|
|||||||
@ -52,7 +52,7 @@ let d = import "../ontology/defaults/state.ncl" in
|
|||||||
from = "modes-and-web-present",
|
from = "modes-and-web-present",
|
||||||
to = "fully-self-described",
|
to = "fully-self-described",
|
||||||
condition = "At least 3 ADRs accepted, reflection/backlog.ncl present, describe project returns complete picture.",
|
condition = "At least 3 ADRs accepted, reflection/backlog.ncl present, describe project returns complete picture.",
|
||||||
catalyst = "ADR-001–ADR-006 authored (6 ADRs present). Auth model, project onboarding, and session management nodes added in 2026-03-13. Personal/career/project-card schemas, 5 content modes, search bookmarks, and ADR-006 (Nu 0.111 compat) added in session 2026-03-15. Session 2026-03-23: api-catalog-surface node added (#[onto_api] proc-macro + inventory catalog), describe-query-layer updated (diff + api subcommands), adopt-ontoref-tooling updated (update_ontoref mode + manifest/connections templates + enrichment prompt), ontoref-daemon updated (11 pages, 29 MCP tools, per-file versioning, API catalog endpoint). Session 2026-03-26: config-surface node added — typed DaemonNclConfig (parse-at-boundary pattern), #[derive(ConfigFields)] coherence registry, override-layer mutation API (PUT /config/{section}), NCL contracts (.ontoref/contracts.ncl: LogConfig + DaemonConfig), manifest config_surface with multi-consumer sections. ADR-007 (inventory/onto_api) extended to ConfigFields; ADR-008 (NCL-first config validation + override-layer mutation). Session 2026-03-26 (2nd): manifest-self-description node added — capability_type (id/name/summary/rationale/how/artifacts/adrs/nodes), requirement_type (env_target: Production/Development/Both; kind: Tool/Service/EnvVar/Infrastructure; impact/provision), critical_dep_type (failure_impact required; mitigation). describe requirements new subcommand. describe guides extended with capabilities/requirements/critical_deps. Bug fix: collect-identity read manifest.kind? (never existed) instead of manifest.repo_kind?; description field added to manifest_type. Ontoref self-described with 3 capabilities, 5 requirements, 3 critical deps. ADR-009.",
|
catalyst = "ADR-001–ADR-006 authored (6 ADRs present). Auth model, project onboarding, and session management nodes added in 2026-03-13. Personal/career/project-card schemas, 5 content modes, search bookmarks, and ADR-006 (Nu 0.111 compat) added in session 2026-03-15. Session 2026-03-23: api-catalog-surface node added (#[onto_api] proc-macro + inventory catalog), describe-query-layer updated (diff + api subcommands), adopt-ontoref-tooling updated (update_ontoref mode + manifest/connections templates + enrichment prompt), ontoref-daemon updated (11 pages, 29 MCP tools, per-file versioning, API catalog endpoint). Session 2026-03-26: config-surface node added — typed DaemonNclConfig (parse-at-boundary pattern), #[derive(ConfigFields)] coherence registry, override-layer mutation API (PUT /config/{section}), NCL contracts (.ontoref/contracts.ncl: LogConfig + DaemonConfig), manifest config_surface with multi-consumer sections. ADR-007 (inventory/onto_api) extended to ConfigFields; ADR-008 (NCL-first config validation + override-layer mutation). Session 2026-03-26 (2nd): manifest-self-description node added — capability_type (id/name/summary/rationale/how/artifacts/adrs/nodes), requirement_type (env_target: Production/Development/Both; kind: Tool/Service/EnvVar/Infrastructure; impact/provision), critical_dep_type (failure_impact required; mitigation). describe requirements new subcommand. describe guides extended with capabilities/requirements/critical_deps. Bug fix: collect-identity read manifest.kind? (never existed) instead of manifest.repo_kind?; description field added to manifest_type. Ontoref self-described with 3 capabilities, 5 requirements, 3 critical deps. ADR-009. Session 2026-03-29: graph/search/api_catalog UI pages gain browser-style panel navigation (PanelNav/dpNav back/forward history stack, cursor-into-array model). File artifact paths route to external tabs via card.repo (Gitea source URL format {repo}/src/branch/main/{path}) or card.docs (cargo docs URL for .rs files when configured) — inline file loading removed from all three pages. card.ncl gains repo field. insert_brand_ctx injects card_repo/card_docs into Tera context. Tera | safe filter applied to URL values in <script> blocks to prevent HTML entity escaping of slashes.",
|
||||||
blocker = "none",
|
blocker = "none",
|
||||||
horizon = 'Weeks,
|
horizon = 'Weeks,
|
||||||
},
|
},
|
||||||
|
|||||||
52
CHANGELOG.md
52
CHANGELOG.md
@ -7,6 +7,58 @@ ADRs referenced below live in `adrs/` as typed Nickel records.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Browser-style panel navigation + repo file routing
|
||||||
|
|
||||||
|
Graph, search, and api_catalog pages now share a uniform browser-style navigation model:
|
||||||
|
back/forward history stack with cursor-into-array semantics. File artifact paths open in
|
||||||
|
external browser tabs rather than being loaded inline.
|
||||||
|
|
||||||
|
#### `crates/ontoref-daemon/templates/pages/graph.html`
|
||||||
|
|
||||||
|
- `.artifact-link` click handler changed from removed `openFile()` to `srcOpen()`.
|
||||||
|
- `panelNav._replay` `type: "file"` case changed to `srcOpen(e.id)`.
|
||||||
|
|
||||||
|
#### `crates/ontoref-daemon/templates/pages/search.html`
|
||||||
|
|
||||||
|
- `openFileInPanel` async function removed entirely (was loading file content inline via `/api/file`).
|
||||||
|
- `srcOpen(path)` function added: opens `{card_repo}/src/branch/main/{path}` for most files;
|
||||||
|
opens `card_docs` for `.rs` files when configured.
|
||||||
|
- `CARD_REPO` / `CARD_DOCS` JS constants injected via Tera (`| safe` filter required — Tera
|
||||||
|
auto-escapes all `{{ }}` interpolations regardless of `<script>` context).
|
||||||
|
- `.s-file-link` click delegation updated to call `srcOpen`.
|
||||||
|
- `dpNav._replay` `type: "file"` case calls `srcOpen`.
|
||||||
|
|
||||||
|
#### `crates/ontoref-daemon/templates/pages/api_catalog.html`
|
||||||
|
|
||||||
|
- `ensureFileModal` and `openFile` removed.
|
||||||
|
- `srcOpen` added with same logic as graph and search pages.
|
||||||
|
- `CARD_REPO` / `CARD_DOCS` constants injected.
|
||||||
|
- `#detail-source-btn` click delegation calls `srcOpen`.
|
||||||
|
|
||||||
|
#### `crates/ontoref-daemon/src/ui/handlers.rs`
|
||||||
|
|
||||||
|
- `insert_brand_ctx` reads `card.repo` and `card.docs` from config NCL and injects
|
||||||
|
`card_repo` / `card_docs` into the Tera context for all pages.
|
||||||
|
|
||||||
|
#### `card.ncl`
|
||||||
|
|
||||||
|
- `repo = "https://repo.jesusperez.pro/jesus/ontoref"` added.
|
||||||
|
|
||||||
|
#### `reflection/migrations/0007-card-repo-field.ncl` — new migration
|
||||||
|
|
||||||
|
- Check: `card.ncl` absent → pass (not applicable); present + `repo =` found → pass; present
|
||||||
|
without `repo =` → pending.
|
||||||
|
- Instructions: add `repo` and optionally `docs` fields; explains how `srcOpen` uses both.
|
||||||
|
|
||||||
|
#### on+re update
|
||||||
|
|
||||||
|
| Artifact | Change |
|
||||||
|
|----------|--------|
|
||||||
|
| `.ontology/core.ncl` | `ontoref-daemon` description updated — browser-style panel nav, file routing via `card.repo`/`card.docs` |
|
||||||
|
| `.ontology/state.ncl` | `self-description-coverage` catalyst updated with session 2026-03-29 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Protocol Migration System — progressive NCL checks for consumer project upgrades (ADR-010)
|
### Protocol Migration System — progressive NCL checks for consumer project upgrades (ADR-010)
|
||||||
|
|
||||||
Replaces the template-prompt approach with an ordered, idempotent migration system. Applied state
|
Replaces the template-prompt approach with an ordered, idempotent migration system. Applied state
|
||||||
|
|||||||
13
README.md
13
README.md
@ -98,8 +98,14 @@ Counter increments on every watcher-triggered reload. `GET /projects/{slug}/onto
|
|||||||
|
|
||||||
**ADR–Node Linkage** — nodes declare which ADRs validate them via `adrs: Array String`.
|
**ADR–Node Linkage** — nodes declare which ADRs validate them via `adrs: Array String`.
|
||||||
`describe` surfaces a **Validated by** section per node (CLI and `--fmt md`). The graph UI
|
`describe` surfaces a **Validated by** section per node (CLI and `--fmt md`). The graph UI
|
||||||
renders each ADR as a clickable link that opens the full ADR content in a modal via
|
renders each ADR as a clickable link that opens the full ADR content via `GET /api/adr/{id}`.
|
||||||
`GET /api/adr/{id}`.
|
|
||||||
|
**Browser-Style Panel Navigation** — graph, search, and api_catalog UI pages carry a
|
||||||
|
back/forward history stack (cursor-into-array model). Clicking nodes, ADRs, or search results
|
||||||
|
pushes to history; clicking artifacts opens the source file in the configured repository or
|
||||||
|
docs. `card.repo` in `card.ncl` resolves to `{repo}/src/branch/main/{path}` (Gitea format).
|
||||||
|
For `.rs` files, `card.docs` redirects to the cargo docs URL instead. `insert_brand_ctx`
|
||||||
|
injects both as `card_repo`/`card_docs` into every Tera template.
|
||||||
|
|
||||||
**Passive Drift Observation** — background file watcher that detects divergence between Yang
|
**Passive Drift Observation** — background file watcher that detects divergence between Yang
|
||||||
code artifacts and Yin ontology. Watches `crates/`, `.ontology/`, `adrs/`, `reflection/modes/`.
|
code artifacts and Yin ontology. Watches `crates/`, `.ontology/`, `adrs/`, `reflection/modes/`.
|
||||||
@ -137,7 +143,8 @@ NuCmd`) whose result IS the applied state — no state file, fully idempotent. `
|
|||||||
migrations with applied/pending status; `migrate pending` lists only what is missing; `migrate show <id>`
|
migrations with applied/pending status; `migrate pending` lists only what is missing; `migrate show <id>`
|
||||||
renders runtime-interpolated instructions (project_root and project_name auto-detected). NuCmd checks are
|
renders runtime-interpolated instructions (project_root and project_name auto-detected). NuCmd checks are
|
||||||
valid Nushell (no bash `&&`, `$env.VAR` not `$VAR`). Grep checks targeting ADR files scope to
|
valid Nushell (no bash `&&`, `$env.VAR` not `$VAR`). Grep checks targeting ADR files scope to
|
||||||
`adr-[0-9][0-9][0-9]-*.ncl` to exclude schema/template infrastructure files. ([ADR-010](adrs/adr-010-protocol-migration-system.ncl))
|
`adr-[0-9][0-9][0-9]-*.ncl` to exclude schema/template infrastructure files. 7 migrations shipped;
|
||||||
|
`0007-card-repo-field` checks for `repo =` in `card.ncl`. ([ADR-010](adrs/adr-010-protocol-migration-system.ncl))
|
||||||
|
|
||||||
**Manifest Self-Interrogation** — `manifest_type` gains three typed arrays that answer self-knowledge
|
**Manifest Self-Interrogation** — `manifest_type` gains three typed arrays that answer self-knowledge
|
||||||
queries agents and operators need on cold start: `capabilities[]` (what the project does, why it was
|
queries agents and operators need on cold start: `capabilities[]` (what the project does, why it was
|
||||||
|
|||||||
1
card.ncl
1
card.ncl
@ -9,6 +9,7 @@ d.ProjectCard & {
|
|||||||
status = 'Active,
|
status = 'Active,
|
||||||
source = 'Local,
|
source = 'Local,
|
||||||
url = "https://ontoref.jesusperez.pro",
|
url = "https://ontoref.jesusperez.pro",
|
||||||
|
repo = "https://repo.jesusperez.pro/jesus/ontoref",
|
||||||
started_at = "2025",
|
started_at = "2025",
|
||||||
tags = ["nickel", "ontology", "governance", "protocol", "architecture"],
|
tags = ["nickel", "ontology", "governance", "protocol", "architecture"],
|
||||||
tools = ["Nickel", "Nushell"],
|
tools = ["Nickel", "Nushell"],
|
||||||
|
|||||||
1
crates/ontoref-daemon/public/vendor/htmx.min.js
vendored
Normal file
1
crates/ontoref-daemon/public/vendor/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -312,14 +312,17 @@ pub fn router(state: AppState) -> axum::Router {
|
|||||||
// ADR read + validation endpoints
|
// ADR read + validation endpoints
|
||||||
.route("/validate/adrs", get(validate_adrs))
|
.route("/validate/adrs", get(validate_adrs))
|
||||||
.route("/adr/{id}", get(get_adr))
|
.route("/adr/{id}", get(get_adr))
|
||||||
|
// File content (for graph UI artifact links)
|
||||||
|
.route("/file", get(get_file_content))
|
||||||
// Ontology extension endpoints
|
// Ontology extension endpoints
|
||||||
.route("/ontology", get(list_ontology_extensions))
|
.route("/ontology", get(list_ontology_extensions))
|
||||||
.route("/ontology/{file}", get(get_ontology_extension))
|
.route("/ontology/{file}", get(get_ontology_extension))
|
||||||
// Graph endpoints (impact analysis + federation)
|
// Graph endpoints (impact analysis + federation)
|
||||||
.route("/graph/impact", get(graph_impact))
|
.route("/graph/impact", get(graph_impact))
|
||||||
.route("/graph/node/{id}", get(graph_node))
|
.route("/graph/node/{id}", get(graph_node))
|
||||||
// Backlog JSON endpoint
|
// Backlog endpoints
|
||||||
.route("/backlog-json", get(backlog_json))
|
.route("/backlog-json", get(backlog_json))
|
||||||
|
.route("/backlog/propose-status", post(backlog_propose_status))
|
||||||
// Q&A read endpoint
|
// Q&A read endpoint
|
||||||
.route("/qa-json", get(qa_json))
|
.route("/qa-json", get(qa_json))
|
||||||
// Push-based sync endpoint: projects export their NCL and POST here.
|
// Push-based sync endpoint: projects export their NCL and POST here.
|
||||||
@ -777,12 +780,21 @@ async fn actor_deregister(State(state): State<AppState>, Path(token): Path<Strin
|
|||||||
actors = "agent, developer, ci",
|
actors = "agent, developer, ci",
|
||||||
tags = "actors"
|
tags = "actors"
|
||||||
)]
|
)]
|
||||||
async fn actor_touch(State(state): State<AppState>, Path(token): Path<String>) -> StatusCode {
|
async fn actor_touch(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
state.touch_activity();
|
state.touch_activity();
|
||||||
if state.actors.touch(&token) {
|
if state.actors.touch(&token) {
|
||||||
StatusCode::NO_CONTENT
|
(StatusCode::NO_CONTENT, axum::Json(serde_json::Value::Null)).into_response()
|
||||||
} else {
|
} else {
|
||||||
StatusCode::NOT_FOUND
|
// Return 200 instead of 404 so browsers don't log a console error.
|
||||||
|
// The `expired` flag tells the client to re-register silently.
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
axum::Json(serde_json::json!({ "expired": true })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1789,6 +1801,80 @@ async fn get_adr(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── File content endpoint
|
||||||
|
// ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct FileQuery {
|
||||||
|
path: String,
|
||||||
|
slug: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ontoref_derive::onto_api(
|
||||||
|
method = "GET",
|
||||||
|
path = "/file",
|
||||||
|
description = "Read a project-relative file and return its content as text (for UI artifact \
|
||||||
|
links)",
|
||||||
|
auth = "none",
|
||||||
|
actors = "developer",
|
||||||
|
params = "path:string:required:Project-relative file path, slug:string:optional:Project slug",
|
||||||
|
tags = "graph"
|
||||||
|
)]
|
||||||
|
async fn get_file_content(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<FileQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
state.touch_activity();
|
||||||
|
let (root, _, _) = resolve_project_ctx(&state, q.slug.as_deref());
|
||||||
|
|
||||||
|
// Prevent path traversal — canonicalize both root and requested path.
|
||||||
|
let Ok(canonical_root) = root.canonicalize() else {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "cannot resolve project root" })),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let requested = canonical_root.join(q.path.trim_start_matches('/'));
|
||||||
|
let Ok(canonical_req) = requested.canonicalize() else {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({ "error": "file not found" })),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if !canonical_req.starts_with(&canonical_root) {
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(serde_json::json!({ "error": "path outside project root" })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = canonical_req
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let lang = match ext {
|
||||||
|
"rs" => "rust",
|
||||||
|
"toml" => "toml",
|
||||||
|
"ncl" => "nickel",
|
||||||
|
"nu" => "nushell",
|
||||||
|
"md" => "markdown",
|
||||||
|
"json" => "json",
|
||||||
|
"html" => "html",
|
||||||
|
_ => "text",
|
||||||
|
};
|
||||||
|
|
||||||
|
match std::fs::read_to_string(&canonical_req) {
|
||||||
|
Ok(content) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({ "content": content, "lang": lang, "path": q.path })),
|
||||||
|
),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": e.to_string() })),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Ontology extension endpoints ─────────────────────────────────────────────
|
// ── Ontology extension endpoints ─────────────────────────────────────────────
|
||||||
|
|
||||||
const CORE_FILES: &[&str] = &["core.ncl", "state.ncl", "gate.ncl"];
|
const CORE_FILES: &[&str] = &["core.ncl", "state.ncl", "gate.ncl"];
|
||||||
@ -1938,6 +2024,113 @@ async fn backlog_json(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Backlog propose-status ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct BacklogProposeRequest {
|
||||||
|
pub id: String,
|
||||||
|
pub proposed_status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub proposed_by: String,
|
||||||
|
/// Target project slug (defaults to primary).
|
||||||
|
#[serde(default)]
|
||||||
|
pub slug: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ontoref_derive::onto_api(
|
||||||
|
method = "POST",
|
||||||
|
path = "/backlog/propose-status",
|
||||||
|
description = "Propose a status change for a backlog item. Creates a backlog_review \
|
||||||
|
notification that requires admin approval via the UI or CLI.",
|
||||||
|
auth = "viewer",
|
||||||
|
actors = "agent, developer",
|
||||||
|
params = "id:string:required:Backlog item id (e.g. bl-001) | \
|
||||||
|
proposed_status:string:required:Target status (Open/InProgress/Done/Cancelled) | \
|
||||||
|
proposed_by:string:optional:Actor label shown in the notification | \
|
||||||
|
slug:string:optional:Project slug (defaults to primary)",
|
||||||
|
tags = "backlog"
|
||||||
|
)]
|
||||||
|
async fn backlog_propose_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<BacklogProposeRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
state.touch_activity();
|
||||||
|
let (root, cache, import_path) = resolve_project_ctx(&state, body.slug.as_deref());
|
||||||
|
let slug = body
|
||||||
|
.slug
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| state.registry.primary_slug().to_string());
|
||||||
|
|
||||||
|
let backlog_path = root.join("reflection").join("backlog.ncl");
|
||||||
|
let items = if backlog_path.exists() {
|
||||||
|
match cache.export(&backlog_path, import_path.as_deref()).await {
|
||||||
|
Ok((json, _)) => json
|
||||||
|
.get("items")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Err(_) => vec![],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = items
|
||||||
|
.iter()
|
||||||
|
.find(|it| it.get("id").and_then(|v| v.as_str()) == Some(body.id.as_str()));
|
||||||
|
|
||||||
|
let item_title = item
|
||||||
|
.and_then(|it| it.get("title").and_then(|v| v.as_str()))
|
||||||
|
.unwrap_or(body.id.as_str())
|
||||||
|
.to_string();
|
||||||
|
let current_status = item
|
||||||
|
.and_then(|it| it.get("status").and_then(|v| v.as_str()))
|
||||||
|
.unwrap_or("Unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let by = if body.proposed_by.is_empty() {
|
||||||
|
"agent".to_string()
|
||||||
|
} else {
|
||||||
|
body.proposed_by.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"item_id": body.id,
|
||||||
|
"item_title": item_title,
|
||||||
|
"current_status": current_status,
|
||||||
|
"proposed_status": body.proposed_status,
|
||||||
|
"proposed_by": by,
|
||||||
|
"actions": [
|
||||||
|
{ "id": "approve", "label": format!("Approve → {}", body.proposed_status), "mode": "backlog_approve" },
|
||||||
|
{ "id": "reject", "label": "Reject", "mode": "backlog_reject" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let proj = state.registry.get(&slug);
|
||||||
|
let notifications = proj
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| &p.notifications)
|
||||||
|
.unwrap_or(&state.notifications);
|
||||||
|
|
||||||
|
notifications.push_custom(
|
||||||
|
&slug,
|
||||||
|
"backlog_review",
|
||||||
|
format!("{}: {} → {}", body.id, item_title, body.proposed_status),
|
||||||
|
Some(payload),
|
||||||
|
Some(body.proposed_by),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::ACCEPTED,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "proposed",
|
||||||
|
"item_id": body.id,
|
||||||
|
"proposed_status": body.proposed_status,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Q&A endpoints ───────────────────────────────────────────────────────
|
// ── Q&A endpoints ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[ontoref_derive::onto_api(
|
#[ontoref_derive::onto_api(
|
||||||
@ -3563,7 +3756,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(touch_miss.status(), StatusCode::NOT_FOUND);
|
assert_eq!(touch_miss.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -430,6 +430,34 @@ fn apply_keys_overlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
fn init_tera(
|
||||||
|
templates_dir: Option<&std::path::Path>,
|
||||||
|
) -> Option<Arc<tokio::sync::RwLock<tera::Tera>>> {
|
||||||
|
let tdir = templates_dir?;
|
||||||
|
let glob = format!("{}/**/*.html", tdir.display());
|
||||||
|
match tera::Tera::new(&glob) {
|
||||||
|
Ok(mut t) => {
|
||||||
|
let css_ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
t.register_function(
|
||||||
|
"css_version",
|
||||||
|
move |_: &std::collections::HashMap<String, tera::Value>| {
|
||||||
|
Ok(tera::Value::String(css_ts.to_string()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
info!(templates_dir = %tdir.display(), "Tera templates loaded");
|
||||||
|
Some(Arc::new(tokio::sync::RwLock::new(t)))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, templates_dir = %tdir.display(), "Tera init failed — UI disabled");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
// Parse CLI first so we can redirect logs to stderr in stdio MCP mode.
|
// Parse CLI first so we can redirect logs to stderr in stdio MCP mode.
|
||||||
@ -455,7 +483,10 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cli.dump_api_catalog {
|
if cli.dump_api_catalog {
|
||||||
println!("{}", ontoref_ontology::api::dump_catalog_json());
|
println!(
|
||||||
|
"{}",
|
||||||
|
ontoref_ontology::api::dump_catalog_ncl("ontoref-daemon")
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -638,24 +669,8 @@ async fn main() {
|
|||||||
|
|
||||||
// Initialize Tera template engine from the configured templates directory.
|
// Initialize Tera template engine from the configured templates directory.
|
||||||
#[cfg(feature = "ui")]
|
#[cfg(feature = "ui")]
|
||||||
let tera_instance: Option<Arc<tokio::sync::RwLock<tera::Tera>>> = {
|
let tera_instance: Option<Arc<tokio::sync::RwLock<tera::Tera>>> =
|
||||||
if let Some(ref tdir) = cli.templates_dir {
|
init_tera(cli.templates_dir.as_deref());
|
||||||
let glob = format!("{}/**/*.html", tdir.display());
|
|
||||||
match tera::Tera::new(&glob) {
|
|
||||||
Ok(t) => {
|
|
||||||
info!(templates_dir = %tdir.display(), "Tera templates loaded");
|
|
||||||
Some(Arc::new(tokio::sync::RwLock::new(t)))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(error = %e, templates_dir = %tdir.display(), "Tera init failed — UI disabled");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
info!("--templates-dir not set — web UI disabled");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional DB connection with health check
|
// Optional DB connection with health check
|
||||||
#[cfg(feature = "db")]
|
#[cfg(feature = "db")]
|
||||||
|
|||||||
@ -197,6 +197,10 @@ async fn search_modes(
|
|||||||
|
|
||||||
// ── Detail HTML builders ─────────────────────────────────────────────────────
|
// ── Detail HTML builders ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const VIEWABLE_EXTS: &[&str] = &[
|
||||||
|
"ncl", "toml", "rs", "nu", "md", "json", "html", "yaml", "yml",
|
||||||
|
];
|
||||||
|
|
||||||
fn node_html(n: &serde_json::Value) -> String {
|
fn node_html(n: &serde_json::Value) -> String {
|
||||||
let desc = str_field(n, "description");
|
let desc = str_field(n, "description");
|
||||||
let level = str_field(n, "level");
|
let level = str_field(n, "level");
|
||||||
@ -210,6 +214,11 @@ fn node_html(n: &serde_json::Value) -> String {
|
|||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
|
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let adrs: Vec<&str> = n
|
||||||
|
.get("adrs")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let mut h = String::new();
|
let mut h = String::new();
|
||||||
h.push_str(¶(desc));
|
h.push_str(¶(desc));
|
||||||
@ -220,15 +229,41 @@ fn node_html(n: &serde_json::Value) -> String {
|
|||||||
h.push_str(&badge("invariant", "badge-warning"));
|
h.push_str(&badge("invariant", "badge-warning"));
|
||||||
}
|
}
|
||||||
h.push_str("</div>");
|
h.push_str("</div>");
|
||||||
|
|
||||||
|
if !adrs.is_empty() {
|
||||||
|
h.push_str(§ion_header("Validated by"));
|
||||||
|
h.push_str("<ul class=\"text-xs font-mono space-y-0.5\">");
|
||||||
|
for adr in adrs {
|
||||||
|
h.push_str(&format!(
|
||||||
|
"<li><span class=\"text-success mr-1\">◆</span><button class=\"s-adr-link \
|
||||||
|
text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 \
|
||||||
|
cursor-pointer text-left\" data-adr=\"{id}\">{id}</button></li>",
|
||||||
|
id = esc(adr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
h.push_str("</ul>");
|
||||||
|
}
|
||||||
|
|
||||||
if !artifacts.is_empty() {
|
if !artifacts.is_empty() {
|
||||||
h.push_str(§ion_header("Artifacts"));
|
h.push_str(§ion_header("Artifacts"));
|
||||||
h.push_str("<ul class=\"text-xs font-mono space-y-0.5\">");
|
h.push_str("<ul class=\"text-xs font-mono space-y-1\">");
|
||||||
for a in artifacts {
|
for a in artifacts {
|
||||||
|
let ext = a.rsplit('.').next().unwrap_or("");
|
||||||
|
if VIEWABLE_EXTS.contains(&ext) {
|
||||||
|
h.push_str(&format!(
|
||||||
|
"<li><button class=\"s-file-link text-primary hover:underline \
|
||||||
|
underline-offset-2 bg-transparent border-none p-0 cursor-pointer text-left \
|
||||||
|
break-all\" data-path=\"{path}\">{label}</button></li>",
|
||||||
|
path = esc(a),
|
||||||
|
label = esc(a)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
h.push_str(&format!(
|
h.push_str(&format!(
|
||||||
"<li class=\"text-base-content/60\">{}</li>",
|
"<li class=\"text-base-content/60\">{}</li>",
|
||||||
esc(a)
|
esc(a)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
h.push_str("</ul>");
|
h.push_str("</ul>");
|
||||||
}
|
}
|
||||||
h
|
h
|
||||||
|
|||||||
@ -62,6 +62,73 @@ pub fn add_item(
|
|||||||
Ok(next_id)
|
Ok(next_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update editable fields (title, kind, priority, detail) for an item.
|
||||||
|
pub struct ItemUpdate<'a> {
|
||||||
|
pub title: &'a str,
|
||||||
|
pub kind: &'a str,
|
||||||
|
pub priority: &'a str,
|
||||||
|
pub status: &'a str,
|
||||||
|
pub detail: &'a str,
|
||||||
|
/// Comma-separated ADR ids; empty string = leave unchanged.
|
||||||
|
pub related_adrs: &'a str,
|
||||||
|
/// Comma-separated mode ids; empty string = leave unchanged.
|
||||||
|
pub related_modes: &'a str,
|
||||||
|
/// Enum value without tick; empty string = leave unchanged.
|
||||||
|
pub graduates_to: &'a str,
|
||||||
|
pub today: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_item(path: &Path, id: &str, upd: &ItemUpdate<'_>) -> anyhow::Result<()> {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
|
||||||
|
let adrs_val = csv_to_ncl_array(upd.related_adrs);
|
||||||
|
let modes_val = csv_to_ncl_array(upd.related_modes);
|
||||||
|
|
||||||
|
let mut fields: Vec<(&str, String)> = vec![
|
||||||
|
("title", format!("\"{}\"", escape_ncl(upd.title))),
|
||||||
|
("kind", format!("'{}", upd.kind)),
|
||||||
|
("priority", format!("'{}", upd.priority)),
|
||||||
|
("status", format!("'{}", upd.status)),
|
||||||
|
("detail", format!("\"{}\"", escape_ncl(upd.detail))),
|
||||||
|
("updated", format!("\"{}\"", upd.today)),
|
||||||
|
];
|
||||||
|
if !upd.related_adrs.is_empty() {
|
||||||
|
fields.push(("related_adrs", adrs_val));
|
||||||
|
}
|
||||||
|
if !upd.related_modes.is_empty() {
|
||||||
|
fields.push(("related_modes", modes_val));
|
||||||
|
}
|
||||||
|
if !upd.graduates_to.is_empty() {
|
||||||
|
fields.push(("graduates_to", format!("'{}", upd.graduates_to)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pairs: Vec<(&str, &str)> = fields.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||||
|
let updated = mutate_item_fields(&content, id, &pairs);
|
||||||
|
super::ncl_write::atomic_write(path, &updated)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert `"adr-001,adr-002"` → `["adr-001", "adr-002"]` (Nickel array
|
||||||
|
/// literal).
|
||||||
|
fn csv_to_ncl_array(csv: &str) -> String {
|
||||||
|
if csv.trim().is_empty() {
|
||||||
|
return "[]".to_string();
|
||||||
|
}
|
||||||
|
let items: Vec<String> = csv
|
||||||
|
.split(',')
|
||||||
|
.map(|s| format!("\"{}\"", escape_ncl(s.trim())))
|
||||||
|
.collect();
|
||||||
|
format!("[{}]", items.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the item block with the given id from the items array.
|
||||||
|
pub fn delete_item(path: &Path, id: &str) -> anyhow::Result<()> {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let updated = remove_item_block(&content, id)?;
|
||||||
|
super::ncl_write::atomic_write(path, &updated)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Replace the value of one or more fields within the item block identified by
|
/// Replace the value of one or more fields within the item block identified by
|
||||||
@ -152,6 +219,56 @@ fn insert_before_array_close(content: &str, block: &str) -> anyhow::Result<Strin
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance `i` past the `},` closing line and an optional trailing blank.
|
||||||
|
/// Returns the new index after skipping.
|
||||||
|
fn skip_past_block_close(lines: &[&str], mut i: usize) -> usize {
|
||||||
|
while i < lines.len() {
|
||||||
|
let closing = lines[i].trim();
|
||||||
|
i += 1;
|
||||||
|
if closing == "}," {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lines.get(i).is_some_and(|l| l.trim().is_empty()) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the entire `{ ... },` block that contains `id = "<id>"`.
|
||||||
|
///
|
||||||
|
/// Scans for the opening `{` on the line before the id field (or the same
|
||||||
|
/// block boundary), collects lines until the closing `},`, and drops them.
|
||||||
|
fn remove_item_block(content: &str, id: &str) -> anyhow::Result<String> {
|
||||||
|
let id_needle = format!("\"{}\"", id);
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
let mut result: Vec<&str> = Vec::with_capacity(lines.len());
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < lines.len() {
|
||||||
|
let trimmed = lines[i].trim();
|
||||||
|
let is_block_start = trimmed == "{"
|
||||||
|
&& (1..=4).any(|d| {
|
||||||
|
lines
|
||||||
|
.get(i + d)
|
||||||
|
.is_some_and(|l| l.contains(&id_needle) && l.contains('='))
|
||||||
|
});
|
||||||
|
if is_block_start {
|
||||||
|
i = skip_past_block_close(&lines, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(lines[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = result.join("\n");
|
||||||
|
if content.ends_with('\n') && !out.ends_with('\n') {
|
||||||
|
Ok(out + "\n")
|
||||||
|
} else {
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Minimal escaping for string values embedded in Nickel double-quoted strings.
|
/// Minimal escaping for string values embedded in Nickel double-quoted strings.
|
||||||
fn escape_ncl(s: &str) -> String {
|
fn escape_ncl(s: &str) -> String {
|
||||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -63,6 +63,7 @@ fn multi_router(state: AppState) -> axum::Router {
|
|||||||
.route("/manage/logout", get(handlers::manage_logout))
|
.route("/manage/logout", get(handlers::manage_logout))
|
||||||
// Per-project routes — AuthUser extractor enforces auth per project
|
// Per-project routes — AuthUser extractor enforces auth per project
|
||||||
.route("/{slug}/", get(handlers::dashboard_mp))
|
.route("/{slug}/", get(handlers::dashboard_mp))
|
||||||
|
.route("/{slug}/stats", get(handlers::dashboard_stats_mp))
|
||||||
.route("/{slug}/graph", get(handlers::graph_mp))
|
.route("/{slug}/graph", get(handlers::graph_mp))
|
||||||
.route("/{slug}/sessions", get(handlers::sessions_mp))
|
.route("/{slug}/sessions", get(handlers::sessions_mp))
|
||||||
.route("/{slug}/notifications", get(handlers::notifications_mp))
|
.route("/{slug}/notifications", get(handlers::notifications_mp))
|
||||||
@ -77,6 +78,12 @@ fn multi_router(state: AppState) -> axum::Router {
|
|||||||
post(handlers::backlog_update_status_mp),
|
post(handlers::backlog_update_status_mp),
|
||||||
)
|
)
|
||||||
.route("/{slug}/backlog/add", post(handlers::backlog_add_mp))
|
.route("/{slug}/backlog/add", post(handlers::backlog_add_mp))
|
||||||
|
.route("/{slug}/backlog/edit", post(handlers::backlog_edit_mp))
|
||||||
|
.route("/{slug}/backlog/delete", post(handlers::backlog_delete_mp))
|
||||||
|
.route(
|
||||||
|
"/{slug}/backlog/propose-status",
|
||||||
|
post(handlers::backlog_propose_status_mp),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/{slug}/notifications/{id}/action",
|
"/{slug}/notifications/{id}/action",
|
||||||
post(handlers::notification_action_mp),
|
post(handlers::notification_action_mp),
|
||||||
@ -94,6 +101,10 @@ fn multi_router(state: AppState) -> axum::Router {
|
|||||||
.route("/{slug}/api", get(handlers::api_catalog_page_mp))
|
.route("/{slug}/api", get(handlers::api_catalog_page_mp))
|
||||||
.route("/{slug}/config", get(handlers::config_page_mp))
|
.route("/{slug}/config", get(handlers::config_page_mp))
|
||||||
.route("/{slug}/adrs", get(handlers::adrs_page_mp))
|
.route("/{slug}/adrs", get(handlers::adrs_page_mp))
|
||||||
|
.route(
|
||||||
|
"/{slug}/migrations/pending",
|
||||||
|
get(handlers::pending_migrations_fragment_mp),
|
||||||
|
)
|
||||||
.route("/{slug}/actions", get(handlers::actions_page_mp))
|
.route("/{slug}/actions", get(handlers::actions_page_mp))
|
||||||
.route("/{slug}/actions/run", post(handlers::actions_run_mp))
|
.route("/{slug}/actions/run", post(handlers::actions_run_mp))
|
||||||
.route("/{slug}/qa", get(handlers::qa_page_mp))
|
.route("/{slug}/qa", get(handlers::qa_page_mp))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -66,7 +66,9 @@
|
|||||||
|
|
||||||
{% if not current_role or current_role == "admin" %}
|
{% if not current_role or current_role == "admin" %}
|
||||||
<div class="card-actions justify-end pt-1">
|
<div class="card-actions justify-end pt-1">
|
||||||
<form method="post" action="{{ base_url }}/actions/run">
|
<form hx-post="{{ base_url }}/actions/run"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML">
|
||||||
<input type="hidden" name="action_id" value="{{ action.id }}">
|
<input type="hidden" name="action_id" value="{{ action.id }}">
|
||||||
<button type="submit" class="btn btn-xs btn-primary gap-1">
|
<button type="submit" class="btn btn-xs btn-primary gap-1">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@ -28,10 +28,10 @@
|
|||||||
<span class="badge badge-lg badge-neutral">{{ route_count }} routes</span>
|
<span class="badge badge-lg badge-neutral">{{ route_count }} routes</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not is_primary and route_count == 0 %}
|
{% if route_count == 0 %}
|
||||||
<div class="flex flex-col items-center justify-center py-16 text-base-content/40 text-sm">
|
<div class="flex flex-col items-center justify-center py-16 text-base-content/40 text-sm">
|
||||||
<p>No API surface declared for <code class="font-mono">{{ slug }}</code>.</p>
|
<p>No API surface declared for <code class="font-mono">{{ slug }}</code>.</p>
|
||||||
<p class="mt-2">Add route declarations to the project's ontology to surface them here.</p>
|
<p class="mt-2">Export an <code class="font-mono">api-catalog*.json</code> file to the project root to surface routes here.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
@ -52,38 +52,29 @@
|
|||||||
<option value="POST">POST</option>
|
<option value="POST">POST</option>
|
||||||
<option value="PUT">PUT</option>
|
<option value="PUT">PUT</option>
|
||||||
<option value="DELETE">DELETE</option>
|
<option value="DELETE">DELETE</option>
|
||||||
|
<option value="PATCH">PATCH</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span id="filter-count" class="text-xs text-base-content/40 self-center hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Routes table -->
|
<!-- Catalog accordions -->
|
||||||
<div class="overflow-x-auto" id="routes-container">
|
<div id="catalogs-container"></div>
|
||||||
<table class="table table-sm w-full bg-base-200 rounded-lg" id="routes-table">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-base-content/50 text-xs uppercase tracking-wider">
|
|
||||||
<th class="w-16">Method</th>
|
|
||||||
<th>Path</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th class="w-20">Auth</th>
|
|
||||||
<th>Actors</th>
|
|
||||||
<th>Tags</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="routes-body">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Route detail modal -->
|
<!-- Route detail modal -->
|
||||||
<dialog id="route-modal" class="modal">
|
<dialog id="route-modal" class="modal">
|
||||||
<div class="modal-box w-full max-w-2xl">
|
<div class="modal-box w-full max-w-2xl">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3">✕</button>
|
<button class="btn btn-sm btn-ghost absolute right-3 top-3">✕</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="flex items-baseline gap-2 mb-3 pr-8">
|
<div class="flex items-baseline gap-2 mb-3 pr-8">
|
||||||
<span id="detail-method" class="font-mono font-bold text-lg"></span>
|
<span id="detail-method" class="font-mono font-bold text-lg"></span>
|
||||||
<span id="detail-path" class="font-mono text-base-content/60 text-sm break-all"></span>
|
<span id="detail-path" class="font-mono text-base-content/60 text-sm break-all"></span>
|
||||||
</div>
|
</div>
|
||||||
<p id="detail-desc" class="text-sm text-base-content/80 mb-4"></p>
|
<p id="detail-desc" class="text-sm text-base-content/80 mb-4"></p>
|
||||||
|
<div id="detail-catalog-badge" class="mb-3 hidden">
|
||||||
|
<span class="text-xs text-base-content/40">Catalog: </span>
|
||||||
|
<span id="detail-catalog" class="badge badge-xs badge-ghost font-mono"></span>
|
||||||
|
</div>
|
||||||
<div id="detail-params" class="hidden mb-4">
|
<div id="detail-params" class="hidden mb-4">
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Parameters</h3>
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Parameters</h3>
|
||||||
<table class="table table-xs w-full">
|
<table class="table table-xs w-full">
|
||||||
@ -91,6 +82,11 @@
|
|||||||
<tbody id="detail-params-body"></tbody>
|
<tbody id="detail-params-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="detail-source" class="hidden mb-3">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-1">Source</h3>
|
||||||
|
<button id="detail-source-btn"
|
||||||
|
class="font-mono text-xs text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 cursor-pointer text-left break-all"></button>
|
||||||
|
</div>
|
||||||
<div class="flex gap-4 text-xs text-base-content/40 border-t border-base-content/10 pt-3">
|
<div class="flex gap-4 text-xs text-base-content/40 border-t border-base-content/10 pt-3">
|
||||||
<span>Feature: <code id="detail-feature" class="font-mono"></code></span>
|
<span>Feature: <code id="detail-feature" class="font-mono"></code></span>
|
||||||
<span id="detail-auth-wrap">Auth: <span id="detail-auth"></span></span>
|
<span id="detail-auth-wrap">Auth: <span id="detail-auth"></span></span>
|
||||||
@ -102,10 +98,36 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const ROUTES = {{ catalog_json | safe }};
|
const CATALOGS = {{ catalogs_json | safe }};
|
||||||
|
const MULTI = CATALOGS.length > 1;
|
||||||
|
const CARD_REPO = "{{ card_repo | default(value='') | safe }}";
|
||||||
|
const CARD_DOCS = "{{ card_docs | default(value='') | safe }}";
|
||||||
|
|
||||||
|
function srcOpen(path) {
|
||||||
|
if (!path) return;
|
||||||
|
var ext = path.split('.').pop().toLowerCase();
|
||||||
|
if (ext === 'rs' && CARD_DOCS) {
|
||||||
|
window.open(CARD_DOCS.replace(/\/$/, ''), '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CARD_REPO) {
|
||||||
|
window.open(CARD_REPO.replace(/\/$/, '') + '/src/branch/main/' + path, '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat array: each route carries _catalog and _flatIdx for showDetail lookup.
|
||||||
|
const ALL_ROUTES = [];
|
||||||
|
CATALOGS.forEach(function(cat) {
|
||||||
|
(cat.routes || []).forEach(function(r) {
|
||||||
|
ALL_ROUTES.push(Object.assign({}, r, { _catalog: cat.name }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentRoutes = ALL_ROUTES.slice();
|
||||||
|
|
||||||
function methodClass(m) {
|
function methodClass(m) {
|
||||||
return `method-${m.toLowerCase()}`;
|
return 'method-' + m.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function authBadge(auth) {
|
function authBadge(auth) {
|
||||||
@ -113,79 +135,163 @@ function authBadge(auth) {
|
|||||||
none: 'badge badge-ghost badge-xs font-mono',
|
none: 'badge badge-ghost badge-xs font-mono',
|
||||||
viewer: 'badge badge-info badge-xs font-mono',
|
viewer: 'badge badge-info badge-xs font-mono',
|
||||||
admin: 'badge badge-error badge-xs font-mono',
|
admin: 'badge badge-error badge-xs font-mono',
|
||||||
}[auth] ?? 'badge badge-ghost badge-xs font-mono';
|
}[auth] || 'badge badge-ghost badge-xs font-mono';
|
||||||
return `<span class="${cls}">${auth}</span>`;
|
return '<span class="' + cls + '">' + auth + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function actorBadges(actors) {
|
function actorBadges(actors) {
|
||||||
if (!actors || actors.length === 0) return '<span class="text-base-content/30">—</span>';
|
if (!actors || actors.length === 0) return '<span class="text-base-content/30">—</span>';
|
||||||
return actors.map(a => `<span class="badge badge-xs badge-ghost font-mono">${a}</span>`).join(' ');
|
return actors.map(function(a) {
|
||||||
|
return '<span class="badge badge-xs badge-ghost font-mono">' + a + '</span>';
|
||||||
|
}).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function tagBadges(tags) {
|
function tagBadges(tags) {
|
||||||
if (!tags || tags.length === 0) return '';
|
if (!tags || tags.length === 0) return '';
|
||||||
return tags.map(t => `<span class="badge badge-xs badge-outline">${t}</span>`).join(' ');
|
return tags.map(function(t) {
|
||||||
|
return '<span class="badge badge-xs badge-outline">' + t + '</span>';
|
||||||
|
}).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
let visibleRoutes = ROUTES;
|
function routeRow(r, idx) {
|
||||||
|
return '<tr class="hover cursor-pointer route-row" onclick="showDetail(' + idx + ')">' +
|
||||||
|
'<td class="font-mono font-bold ' + methodClass(r.method) + '">' + r.method + '</td>' +
|
||||||
|
'<td class="font-mono text-sm">' + r.path + '</td>' +
|
||||||
|
'<td class="text-sm text-base-content/70">' + r.description + '</td>' +
|
||||||
|
'<td>' + authBadge(r.auth) + '</td>' +
|
||||||
|
'<td class="flex flex-wrap gap-1">' + actorBadges(r.actors) + '</td>' +
|
||||||
|
'<td>' + tagBadges(r.tags) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableFor(routes, startIdx) {
|
||||||
|
if (routes.length === 0) return '';
|
||||||
|
return '<div class="overflow-x-auto">' +
|
||||||
|
'<table class="table table-sm w-full bg-base-200 rounded-lg">' +
|
||||||
|
'<thead><tr class="text-base-content/50 text-xs uppercase tracking-wider">' +
|
||||||
|
'<th class="w-16">Method</th><th>Path</th><th>Description</th>' +
|
||||||
|
'<th class="w-20">Auth</th><th>Actors</th><th>Tags</th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody>' +
|
||||||
|
routes.map(function(r, i) { return routeRow(r, startIdx + i); }).join('') +
|
||||||
|
'</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
function renderRoutes(routes) {
|
function renderRoutes(routes) {
|
||||||
visibleRoutes = routes;
|
currentRoutes = routes;
|
||||||
const tbody = document.getElementById('routes-body');
|
const container = document.getElementById('catalogs-container');
|
||||||
tbody.innerHTML = routes.map((r, i) => `
|
const filterCount = document.getElementById('filter-count');
|
||||||
<tr class="hover cursor-pointer route-row" onclick="showDetail(${i})">
|
const isFiltered = routes.length < ALL_ROUTES.length;
|
||||||
<td class="font-mono font-bold ${methodClass(r.method)}">${r.method}</td>
|
|
||||||
<td class="font-mono text-sm">${r.path}</td>
|
if (isFiltered) {
|
||||||
<td class="text-sm text-base-content/70">${r.description}</td>
|
filterCount.textContent = routes.length + ' match' + (routes.length !== 1 ? 'es' : '');
|
||||||
<td>${authBadge(r.auth)}</td>
|
filterCount.classList.remove('hidden');
|
||||||
<td class="flex flex-wrap gap-1">${actorBadges(r.actors)}</td>
|
} else {
|
||||||
<td>${tagBadges(r.tags)}</td>
|
filterCount.classList.add('hidden');
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDetail(index) {
|
if (!MULTI) {
|
||||||
const r = visibleRoutes[index];
|
// Single catalog: plain table, no accordion.
|
||||||
|
container.innerHTML = tableFor(routes, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by catalog, preserving flat index for showDetail.
|
||||||
|
var groups = {};
|
||||||
|
var order = [];
|
||||||
|
routes.forEach(function(r, i) {
|
||||||
|
if (!groups[r._catalog]) { groups[r._catalog] = []; order.push(r._catalog); }
|
||||||
|
groups[r._catalog].push({ route: r, idx: i });
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = order.map(function(name) {
|
||||||
|
var entries = groups[name];
|
||||||
|
var rows = entries.map(function(e) { return routeRow(e.route, e.idx); }).join('');
|
||||||
|
var count = entries.length;
|
||||||
|
return '<details class="collapse collapse-arrow bg-base-200 mb-2" open>' +
|
||||||
|
'<summary class="collapse-title font-mono font-semibold text-sm min-h-0 py-3">' +
|
||||||
|
name +
|
||||||
|
' <span class="badge badge-xs badge-ghost ml-2">' + count + '</span>' +
|
||||||
|
'</summary>' +
|
||||||
|
'<div class="collapse-content px-0">' +
|
||||||
|
'<div class="overflow-x-auto">' +
|
||||||
|
'<table class="table table-sm w-full">' +
|
||||||
|
'<thead><tr class="text-base-content/50 text-xs uppercase tracking-wider">' +
|
||||||
|
'<th class="w-16">Method</th><th>Path</th><th>Description</th>' +
|
||||||
|
'<th class="w-20">Auth</th><th>Actors</th><th>Tags</th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</details>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(idx) {
|
||||||
|
var r = currentRoutes[idx];
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
document.getElementById('detail-method').textContent = r.method;
|
document.getElementById('detail-method').textContent = r.method;
|
||||||
document.getElementById('detail-method').className = `font-mono font-bold text-lg ${methodClass(r.method)}`;
|
document.getElementById('detail-method').className = 'font-mono font-bold text-lg ' + methodClass(r.method);
|
||||||
document.getElementById('detail-path').textContent = r.path;
|
document.getElementById('detail-path').textContent = r.path;
|
||||||
document.getElementById('detail-desc').textContent = r.description;
|
document.getElementById('detail-desc').textContent = r.description;
|
||||||
document.getElementById('detail-feature').textContent = r.feature || 'default';
|
document.getElementById('detail-feature').textContent = r.feature || 'default';
|
||||||
document.getElementById('detail-auth').innerHTML = authBadge(r.auth);
|
document.getElementById('detail-auth').innerHTML = authBadge(r.auth);
|
||||||
|
|
||||||
const paramsDiv = document.getElementById('detail-params');
|
var catBadgeWrap = document.getElementById('detail-catalog-badge');
|
||||||
const tbody = document.getElementById('detail-params-body');
|
if (MULTI) {
|
||||||
|
document.getElementById('detail-catalog').textContent = r._catalog;
|
||||||
|
catBadgeWrap.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
catBadgeWrap.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
var paramsDiv = document.getElementById('detail-params');
|
||||||
|
var tbody = document.getElementById('detail-params-body');
|
||||||
if (r.params && r.params.length > 0) {
|
if (r.params && r.params.length > 0) {
|
||||||
tbody.innerHTML = r.params.map(p => `
|
tbody.innerHTML = r.params.map(function(p) {
|
||||||
<tr>
|
return '<tr>' +
|
||||||
<td class="font-mono text-xs">${p.name}</td>
|
'<td class="font-mono text-xs">' + p.name + '</td>' +
|
||||||
<td class="text-xs text-base-content/60">${p.type || ''}</td>
|
'<td class="text-xs text-base-content/60">' + (p.type || '') + '</td>' +
|
||||||
<td class="text-xs text-base-content/50">${p.constraint || ''}</td>
|
'<td class="text-xs text-base-content/50">' + (p.constraint || '') + '</td>' +
|
||||||
<td class="text-xs">${p.description || ''}</td>
|
'<td class="text-xs">' + (p.description || '') + '</td>' +
|
||||||
</tr>
|
'</tr>';
|
||||||
`).join('');
|
}).join('');
|
||||||
paramsDiv.classList.remove('hidden');
|
paramsDiv.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
paramsDiv.classList.add('hidden');
|
paramsDiv.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
var sourceDiv = document.getElementById('detail-source');
|
||||||
|
var sourceBtn = document.getElementById('detail-source-btn');
|
||||||
|
if (r.source_file) {
|
||||||
|
sourceBtn.textContent = r.source_file;
|
||||||
|
sourceBtn.dataset.path = r.source_file;
|
||||||
|
sourceDiv.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
sourceDiv.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('route-modal').showModal();
|
document.getElementById('route-modal').showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('#detail-source-btn');
|
||||||
|
if (btn && btn.dataset.path) { srcOpen(btn.dataset.path); }
|
||||||
|
});
|
||||||
|
|
||||||
function filterRoutes() {
|
function filterRoutes() {
|
||||||
const text = document.getElementById('filter-input').value.toLowerCase();
|
var text = document.getElementById('filter-input').value.toLowerCase();
|
||||||
const auth = document.getElementById('filter-auth').value;
|
var auth = document.getElementById('filter-auth').value;
|
||||||
const method = document.getElementById('filter-method').value;
|
var method = document.getElementById('filter-method').value;
|
||||||
const filtered = ROUTES.filter(r => {
|
var filtered = ALL_ROUTES.filter(function(r) {
|
||||||
const textMatch = !text || r.path.toLowerCase().includes(text) || r.description.toLowerCase().includes(text);
|
var textMatch = !text || r.path.toLowerCase().includes(text) || r.description.toLowerCase().includes(text);
|
||||||
const authMatch = !auth || r.auth === auth;
|
var authMatch = !auth || r.auth === auth;
|
||||||
const methodMatch = !method || r.method === method;
|
var methodMatch = !method || r.method === method;
|
||||||
return textMatch && authMatch && methodMatch;
|
return textMatch && authMatch && methodMatch;
|
||||||
});
|
});
|
||||||
renderRoutes(filtered);
|
renderRoutes(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderRoutes(ROUTES);
|
renderRoutes(ALL_ROUTES);
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@ -54,7 +54,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Items table -->
|
<!-- Items table — always rendered so HTMX always has a swap target -->
|
||||||
|
<div id="backlog-items-container">
|
||||||
{% if items %}
|
{% if items %}
|
||||||
<div class="overflow-x-auto rounded-lg border border-base-content/10">
|
<div class="overflow-x-auto rounded-lg border border-base-content/10">
|
||||||
<table class="table table-sm w-full">
|
<table class="table table-sm w-full">
|
||||||
@ -68,83 +69,7 @@
|
|||||||
<th class="w-24 text-right">Actions</th>
|
<th class="w-24 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="backlog-tbody">
|
{% include "partials/backlog_tbody.html" %}
|
||||||
{% for it in items %}
|
|
||||||
<tr class="backlog-row hover:bg-base-200/50"
|
|
||||||
data-status="{{ it.status }}"
|
|
||||||
data-priority="{{ it.priority }}">
|
|
||||||
<td class="font-mono text-xs text-base-content/50">{{ it.id }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-xs
|
|
||||||
{% if it.status == "Open" %}badge-info
|
|
||||||
{% elif it.status == "InProgress" %}badge-warning
|
|
||||||
{% elif it.status == "Done" %}badge-success
|
|
||||||
{% else %}badge-ghost{% endif %}">{{ it.status }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-xs
|
|
||||||
{% if it.priority == "Critical" %}badge-error
|
|
||||||
{% elif it.priority == "High" %}badge-warning
|
|
||||||
{% else %}badge-ghost{% endif %}">{{ it.priority }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge badge-xs badge-ghost">{{ it.kind }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="font-medium text-sm leading-tight">{{ it.title }}</div>
|
|
||||||
{% if it.detail %}
|
|
||||||
<div class="text-xs text-base-content/50 leading-tight mt-0.5 line-clamp-1">{{ it.detail }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<div class="dropdown dropdown-end">
|
|
||||||
<button tabindex="0" class="btn btn-xs btn-ghost">▾</button>
|
|
||||||
<ul tabindex="0"
|
|
||||||
class="dropdown-content menu menu-xs bg-base-200 shadow rounded-box z-50 w-40 p-1">
|
|
||||||
{% if it.status != "InProgress" %}
|
|
||||||
<li>
|
|
||||||
<form method="post" action="{{ base_url }}/backlog/status">
|
|
||||||
<input type="hidden" name="id" value="{{ it.id }}">
|
|
||||||
<input type="hidden" name="status" value="InProgress">
|
|
||||||
<button type="submit" class="w-full text-left">→ In Progress</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if it.status != "Done" %}
|
|
||||||
<li>
|
|
||||||
<form method="post" action="{{ base_url }}/backlog/status">
|
|
||||||
<input type="hidden" name="id" value="{{ it.id }}">
|
|
||||||
<input type="hidden" name="status" value="Done">
|
|
||||||
<button type="submit" class="w-full text-left">✓ Done</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if it.status != "Open" %}
|
|
||||||
<li>
|
|
||||||
<form method="post" action="{{ base_url }}/backlog/status">
|
|
||||||
<input type="hidden" name="id" value="{{ it.id }}">
|
|
||||||
<input type="hidden" name="status" value="Open">
|
|
||||||
<button type="submit" class="w-full text-left">↩ Reopen</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li>
|
|
||||||
<form method="post" action="{{ base_url }}/backlog/status">
|
|
||||||
<input type="hidden" name="id" value="{{ it.id }}">
|
|
||||||
<input type="hidden" name="status" value="Cancelled">
|
|
||||||
<button type="submit" class="w-full text-left text-error">✕ Cancel</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
<li class="border-t border-base-content/10 mt-1 pt-1">
|
|
||||||
<a href="{{ base_url }}/notifications?kind=backlog_delegation&title={{ it.id | urlencode }}%3A%20{{ it.title | urlencode }}&payload=%7B%22item_id%22%3A%22{{ it.id | urlencode }}%22%2C%22status%22%3A%22{{ it.status | urlencode }}%22%2C%22priority%22%3A%22{{ it.priority | urlencode }}%22%7D"
|
|
||||||
class="w-full text-left">↗ Send to project</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -152,6 +77,7 @@
|
|||||||
No backlog items yet.
|
No backlog items yet.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -180,7 +106,11 @@
|
|||||||
<dialog id="add-modal" class="modal">
|
<dialog id="add-modal" class="modal">
|
||||||
<div class="modal-box max-w-lg">
|
<div class="modal-box max-w-lg">
|
||||||
<h3 class="font-bold text-base mb-4">New Backlog Item</h3>
|
<h3 class="font-bold text-base mb-4">New Backlog Item</h3>
|
||||||
<form method="post" action="{{ base_url }}/backlog/add" class="space-y-3">
|
<form hx-post="{{ base_url }}/backlog/add"
|
||||||
|
hx-target="#backlog-items-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="if(event.detail.successful){document.getElementById('add-modal').close();this.reset()}"
|
||||||
|
class="space-y-3">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label py-1"><span class="label-text text-sm">Title</span></label>
|
<label class="label py-1"><span class="label-text text-sm">Title</span></label>
|
||||||
<input type="text" name="title" required placeholder="Short description of the item"
|
<input type="text" name="title" required placeholder="Short description of the item"
|
||||||
@ -222,10 +152,171 @@
|
|||||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Edit item modal -->
|
||||||
|
<dialog id="edit-modal" class="modal">
|
||||||
|
<div class="modal-box max-w-xl">
|
||||||
|
<h3 class="font-bold text-base mb-4">Edit Backlog Item <span id="edit-modal-id" class="font-mono text-xs text-base-content/40"></span></h3>
|
||||||
|
<form hx-post="{{ base_url }}/backlog/edit"
|
||||||
|
hx-target="#backlog-items-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="if(event.detail.successful){document.getElementById('edit-modal').close()}"
|
||||||
|
class="space-y-3">
|
||||||
|
<input type="hidden" name="id" id="edit-id">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-sm">Title</span></label>
|
||||||
|
<input type="text" name="title" id="edit-title" required
|
||||||
|
class="input input-bordered input-sm w-full">
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-sm">Status</span></label>
|
||||||
|
<select name="status" id="edit-status" class="select select-bordered select-sm">
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="InProgress">In Progress</option>
|
||||||
|
<option value="Done">Done</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-sm">Kind</span></label>
|
||||||
|
<select name="kind" id="edit-kind" class="select select-bordered select-sm">
|
||||||
|
<option value="Todo">Todo</option>
|
||||||
|
<option value="Wish">Wish</option>
|
||||||
|
<option value="Idea">Idea</option>
|
||||||
|
<option value="Bug">Bug</option>
|
||||||
|
<option value="Debt">Debt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-sm">Priority</span></label>
|
||||||
|
<select name="priority" id="edit-priority" class="select select-bordered select-sm">
|
||||||
|
<option value="Medium">Medium</option>
|
||||||
|
<option value="High">High</option>
|
||||||
|
<option value="Critical">Critical</option>
|
||||||
|
<option value="Low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-sm">Detail</span></label>
|
||||||
|
<textarea name="detail" id="edit-detail" rows="3"
|
||||||
|
class="textarea textarea-bordered textarea-sm w-full"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text text-sm">Related ADRs</span>
|
||||||
|
<span class="label-text-alt text-base-content/40">comma-separated</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="related_adrs" id="edit-related-adrs"
|
||||||
|
placeholder="adr-001-title, adr-002-title"
|
||||||
|
class="input input-bordered input-sm w-full font-mono text-xs">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text text-sm">Related Modes</span>
|
||||||
|
<span class="label-text-alt text-base-content/40">comma-separated</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="related_modes" id="edit-related-modes"
|
||||||
|
placeholder="develop, coder-workflow"
|
||||||
|
class="input input-bordered input-sm w-full font-mono text-xs">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-sm">Graduates to</span></label>
|
||||||
|
<select name="graduates_to" id="edit-graduates-to" class="select select-bordered select-sm">
|
||||||
|
<option value="">— unchanged —</option>
|
||||||
|
<option value="Adr">ADR</option>
|
||||||
|
<option value="Mode">Mode</option>
|
||||||
|
<option value="StateTransition">State Transition</option>
|
||||||
|
<option value="PrItem">PR Item</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action mt-2 flex justify-between">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span id="edit-delete-confirm" class="hidden items-center gap-2">
|
||||||
|
<span class="text-xs text-error font-medium">Delete this item?</span>
|
||||||
|
<button type="button" class="btn btn-xs btn-error" onclick="editModalDeleteConfirmed()">Yes, delete</button>
|
||||||
|
<button type="button" class="btn btn-xs btn-ghost" onclick="editDeleteCancel()">Cancel</button>
|
||||||
|
</span>
|
||||||
|
<button type="button" id="edit-delete-btn"
|
||||||
|
class="btn btn-sm btn-error btn-outline"
|
||||||
|
onclick="editDeleteAsk()">✕ Delete</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
onclick="editModalSendToProject()">↗ Send to project</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" onclick="document.getElementById('edit-modal').close()"
|
||||||
|
class="btn btn-sm btn-ghost">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
// ── Edit modal ────────────────────────────────────────────────────────────────
|
||||||
|
function openEditModal(row) {
|
||||||
|
document.getElementById('edit-id').value = row.dataset.id;
|
||||||
|
document.getElementById('edit-modal-id').textContent = row.dataset.id;
|
||||||
|
document.getElementById('edit-title').value = row.dataset.title;
|
||||||
|
document.getElementById('edit-detail').value = row.dataset.detail;
|
||||||
|
document.getElementById('edit-related-adrs').value = row.dataset.relatedAdrs || '';
|
||||||
|
document.getElementById('edit-related-modes').value = row.dataset.relatedModes || '';
|
||||||
|
function sel(id, val) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
for (var o of el.options) o.selected = (o.value === val);
|
||||||
|
}
|
||||||
|
sel('edit-kind', row.dataset.kind);
|
||||||
|
sel('edit-priority', row.dataset.priorityVal);
|
||||||
|
sel('edit-status', row.dataset.statusVal);
|
||||||
|
sel('edit-graduates-to', row.dataset.graduatesTo || '');
|
||||||
|
editDeleteCancel();
|
||||||
|
document.getElementById('edit-modal').showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editDeleteAsk() {
|
||||||
|
document.getElementById('edit-delete-btn').classList.add('hidden');
|
||||||
|
var c = document.getElementById('edit-delete-confirm');
|
||||||
|
c.classList.remove('hidden');
|
||||||
|
c.classList.add('flex');
|
||||||
|
}
|
||||||
|
function editDeleteCancel() {
|
||||||
|
document.getElementById('edit-delete-confirm').classList.add('hidden');
|
||||||
|
document.getElementById('edit-delete-confirm').classList.remove('flex');
|
||||||
|
document.getElementById('edit-delete-btn').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function editModalDeleteConfirmed() {
|
||||||
|
var id = document.getElementById('edit-id').value;
|
||||||
|
document.getElementById('edit-modal').close();
|
||||||
|
editDeleteCancel();
|
||||||
|
htmx.ajax('POST', '{{ base_url | safe }}/backlog/delete', {
|
||||||
|
target: '#backlog-items-container',
|
||||||
|
swap: 'innerHTML',
|
||||||
|
values: { id: id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editModalSendToProject() {
|
||||||
|
var id = document.getElementById('edit-id').value;
|
||||||
|
var title = document.getElementById('edit-title').value;
|
||||||
|
var status = document.getElementById('edit-status').value;
|
||||||
|
var priority = document.getElementById('edit-priority').value;
|
||||||
|
var payload = JSON.stringify({ item_id: id, status: status, priority: priority });
|
||||||
|
var base = '{{ base_url | safe }}';
|
||||||
|
var url = base + '/notifications?kind=backlog_delegation'
|
||||||
|
+ '&title=' + encodeURIComponent(id + ': ' + title)
|
||||||
|
+ '&payload=' + encodeURIComponent(payload);
|
||||||
|
document.getElementById('edit-modal').close();
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Filter logic ──────────────────────────────────────────────────────────────
|
// ── Filter logic ──────────────────────────────────────────────────────────────
|
||||||
let activeStatus = 'all';
|
let activeStatus = 'all';
|
||||||
let activePriority = 'all';
|
let activePriority = 'all';
|
||||||
|
|||||||
@ -71,6 +71,25 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if pending_migrations %}
|
||||||
|
<details class="collapse collapse-arrow bg-warning/10 border border-warning/30 rounded-lg mt-3 max-w-2xl">
|
||||||
|
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer text-warning">
|
||||||
|
○ {{ pending_migrations | length }} pending migration{% if pending_migrations | length > 1 %}s{% endif %}
|
||||||
|
</summary>
|
||||||
|
<div class="collapse-content px-3 pb-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
{% for m in pending_migrations %}
|
||||||
|
<div class="flex gap-2 items-start">
|
||||||
|
<span class="badge badge-xs badge-warning font-mono flex-shrink-0 mt-0.5">{{ m.id }}</span>
|
||||||
|
<span class="text-xs text-base-content/70">{{ m.description }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/40 mt-2">Run <code class="font-mono">ontoref migrate show <id></code> for instructions.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if layers or op_modes %}
|
{% if layers or op_modes %}
|
||||||
<details class="collapse collapse-arrow bg-base-200 rounded-lg mt-3 max-w-2xl">
|
<details class="collapse collapse-arrow bg-base-200 rounded-lg mt-3 max-w-2xl">
|
||||||
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
|
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
|
||||||
@ -104,14 +123,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Daemon stats -->
|
<!-- Daemon stats — polled every 10s via HTMX -->
|
||||||
<div class="stats stats-horizontal shadow w-full mb-4 bg-base-200 overflow-x-auto">
|
{% include "partials/dashboard_stats.html" %}
|
||||||
{{ m::stat(title="Uptime", value=uptime_secs ~ "s", desc="seconds since start") }}
|
|
||||||
{{ m::stat(title="Cache entries", value=cache_entries) }}
|
|
||||||
{{ m::stat(title="Cache hit rate", value=cache_hit_rate, desc=cache_hits ~ " hits / " ~ cache_misses ~ " misses", accent="success") }}
|
|
||||||
{{ m::stat(title="Sessions", value=active_actors, accent="primary") }}
|
|
||||||
{{ m::stat(title="Notifications", value=notification_count, accent="warning") }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Project status -->
|
<!-- Project status -->
|
||||||
{% if backlog %}
|
{% if backlog %}
|
||||||
|
|||||||
@ -3,11 +3,26 @@
|
|||||||
{% block title %}Ontology Graph — Ontoref{% endblock title %}
|
{% block title %}Ontology Graph — Ontoref{% endblock title %}
|
||||||
{% block nav_graph %}active{% endblock nav_graph %}
|
{% block nav_graph %}active{% endblock nav_graph %}
|
||||||
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
|
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
|
||||||
|
{% block main_class %}container mx-auto px-4 py-6{% endblock main_class %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
|
||||||
<script src="/assets/vendor/cytoscape-navigator.js"></script>
|
<script src="/assets/vendor/cytoscape-navigator.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
#graph-fullscreen-wrapper:fullscreen,
|
||||||
|
#graph-fullscreen-wrapper:-webkit-full-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: oklch(var(--b1));
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
#graph-fullscreen-wrapper:fullscreen #graph-root,
|
||||||
|
#graph-fullscreen-wrapper:-webkit-full-screen #graph-root {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 0; /* flex child — grows to fill remaining space */
|
||||||
|
}
|
||||||
|
|
||||||
#graph-root {
|
#graph-root {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: calc(100vh - 148px);
|
height: calc(100vh - 148px);
|
||||||
@ -15,11 +30,6 @@
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
#graph-root:fullscreen,
|
|
||||||
#graph-root:-webkit-full-screen {
|
|
||||||
height: 100dvh;
|
|
||||||
background: oklch(var(--b1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#cy-wrapper {
|
#cy-wrapper {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@ -172,6 +182,57 @@
|
|||||||
|
|
||||||
<div class="divider my-0"></div>
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
|
<!-- Extended levels -->
|
||||||
|
<section>
|
||||||
|
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Extended levels — code & system layers</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#6366f1;border-color:#6366f1;color:#fff;min-width:80px">⬢ Crates</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium leading-snug">Workspace crate</p>
|
||||||
|
<p class="text-base-content/60 leading-relaxed mt-0.5">A Rust crate declared in the workspace <code>Cargo.toml</code>. Edges show intra-workspace <code>DependsOn</code> relationships — external crates are omitted.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#6366f1;border-color:#6366f1;color:#fff;min-width:80px;opacity:0.8">⬡ API Crate</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium leading-snug">API surface crate</p>
|
||||||
|
<p class="text-base-content/60 leading-relaxed mt-0.5">A crate that has exported <code>#[onto_api]</code> routes. Generated from <code>artifacts/api-catalog-*.ncl</code>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#10b981;border-color:#10b981;color:#fff;min-width:80px">⊢ Routes</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium leading-snug">HTTP route</p>
|
||||||
|
<p class="text-base-content/60 leading-relaxed mt-0.5">An individual HTTP endpoint annotated with <code>#[onto_api]</code>. Each route is owned by exactly one API Crate node via a <code>Contains</code> edge.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#ec4899;border-color:#ec4899;color:#fff;min-width:80px">⬡ State</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium leading-snug">FSM dimension</p>
|
||||||
|
<p class="text-base-content/60 leading-relaxed mt-0.5">A lifecycle dimension from <code>.ontology/state.ncl</code>. Shows <code>current_state</code> → <code>desired_state</code>. <code>CoupledWith</code> edges link co-dependent dimensions. Invariant flag is set when current equals desired.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#14b8a6;border-color:#14b8a6;color:#fff;min-width:80px">⊓ Config</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium leading-snug">Config section</p>
|
||||||
|
<p class="text-base-content/60 leading-relaxed mt-0.5">A named section in <code>.ontoref/config.ncl</code> as declared in <code>manifest.config_surface.sections</code>. Includes contract reference when a Nickel type contract is attached.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#f97316;border-color:#f97316;color:#fff;min-width:80px">◈ Deps</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium leading-snug">External requirement</p>
|
||||||
|
<p class="text-base-content/60 leading-relaxed mt-0.5">A tool, service, or infrastructure dependency from <code>manifest.requirements</code>. Invariant nodes are <em>required</em> (missing them breaks the project). Optional nodes degrade gracefully.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
<!-- Poles -->
|
<!-- Poles -->
|
||||||
<section>
|
<section>
|
||||||
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Poles — which force drives the node</p>
|
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Poles — which force drives the node</p>
|
||||||
@ -197,6 +258,27 @@
|
|||||||
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that hold both sides simultaneously and move through the tension. Spiral principles cannot be resolved into Yang or Yin — they are the engine of change itself.</p>
|
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that hold both sides simultaneously and move through the tension. Spiral principles cannot be resolved into Yang or Yin — they are the engine of change itself.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#ec4899;color:#fff;border-color:#ec4899;min-width:64px">State</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium leading-snug">Lifecycle · FSM · temporal</p>
|
||||||
|
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that represent the project's operational state model — FSM dimensions tracking where the project currently is and where it intends to go.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#14b8a6;color:#fff;border-color:#14b8a6;min-width:64px">Config</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium leading-snug">Configuration · settings · tuneable</p>
|
||||||
|
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that describe the project's runtime configuration surface — sections that can be overridden without recompiling, validated by Nickel contracts at load time.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#f97316;color:#fff;border-color:#f97316;min-width:64px">Env</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium leading-snug">Environment · external · required tooling</p>
|
||||||
|
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that represent external dependencies the project cannot provide for itself — tools, services, and infrastructure that must be present in the environment.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -267,35 +349,22 @@
|
|||||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<!-- ADR modal -->
|
|
||||||
<dialog id="adr-modal" class="modal">
|
|
||||||
<div class="modal-box w-11/12 max-w-2xl">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="font-bold text-lg" id="adr-modal-title">ADR</h3>
|
|
||||||
<form method="dialog"><button class="btn btn-sm btn-circle btn-ghost">✕</button></form>
|
|
||||||
</div>
|
|
||||||
<div id="adr-modal-body" class="text-sm space-y-3 overflow-y-auto max-h-[60vh]">
|
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<div id="graph-fullscreen-wrapper">
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2 text-sm">
|
<!-- Toolbar row 1: title + inline node search + layout controls -->
|
||||||
<h1 class="text-xl font-bold">Ontology Graph</h1>
|
<div class="flex items-center justify-between mb-1 gap-2">
|
||||||
<div class="flex flex-wrap gap-1 items-center">
|
<h1 class="text-lg font-bold shrink-0">Ontology Graph</h1>
|
||||||
<!-- Level filters (toggle = hide that level) -->
|
<div class="relative flex-1 max-w-xs min-w-0" id="search-wrap">
|
||||||
<button class="filter-btn btn btn-xs btn-warning" data-level="Axiom">◆ Axiom</button>
|
<input id="graph-search" type="search" placeholder="Search nodes…"
|
||||||
<button class="filter-btn btn btn-xs btn-error" data-level="Tension">● Tension</button>
|
class="input input-bordered input-xs w-full pr-14 font-mono text-xs"
|
||||||
<button class="filter-btn btn btn-xs btn-success" data-level="Practice">▪ Practice</button>
|
autocomplete="off" autocorrect="off" spellcheck="false">
|
||||||
<div class="w-px h-4 bg-base-content/20 mx-1"></div>
|
<span class="absolute right-2 top-1.5 text-xs text-base-content/30 pointer-events-none select-none"
|
||||||
<!-- Pole filters -->
|
id="search-count"></span>
|
||||||
<button class="filter-btn btn btn-xs" style="background:#f59e0b;color:#111;border-color:#f59e0b" data-pole="Yang">Yang</button>
|
<ul id="search-dropdown"
|
||||||
<button class="filter-btn btn btn-xs" style="background:#3b82f6;color:#fff;border-color:#3b82f6" data-pole="Yin">Yin</button>
|
class="hidden absolute left-0 top-full mt-1 w-full bg-base-100 border border-base-content/15 rounded-lg shadow-xl z-50 max-h-56 overflow-y-auto text-xs">
|
||||||
<button class="filter-btn btn btn-xs" style="background:#8b5cf6;color:#fff;border-color:#8b5cf6" data-pole="Spiral">Spiral</button>
|
</ul>
|
||||||
<div class="w-px h-4 bg-base-content/20 mx-1"></div>
|
</div>
|
||||||
<!-- Layout -->
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
<div class="join">
|
<div class="join">
|
||||||
<button id="btn-bfs" class="join-item btn btn-xs btn-primary">Hierarchy</button>
|
<button id="btn-bfs" class="join-item btn btn-xs btn-primary">Hierarchy</button>
|
||||||
<button id="btn-cose" class="join-item btn btn-xs btn-ghost">Force</button>
|
<button id="btn-cose" class="join-item btn btn-xs btn-ghost">Force</button>
|
||||||
@ -305,6 +374,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar row 2: filters, grouped by category -->
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 mb-2 text-xs">
|
||||||
|
|
||||||
|
<!-- Ontology core -->
|
||||||
|
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Ontology</span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button class="filter-btn btn btn-xs btn-warning" data-level="Axiom">◆ Axiom</button>
|
||||||
|
<button class="filter-btn btn btn-xs btn-error" data-level="Tension">● Tension</button>
|
||||||
|
<button class="filter-btn btn btn-xs btn-success" data-level="Practice">▪ Practice</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px h-4 bg-base-content/15 self-center"></div>
|
||||||
|
|
||||||
|
<!-- Code surface -->
|
||||||
|
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Code</span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button class="filter-btn btn btn-xs" data-level="WorkspaceCrate" style="background:#6366f1;border-color:#6366f1;color:#fff">⬢ Crates</button>
|
||||||
|
<button class="filter-btn btn btn-xs" data-level="Crate" style="background:#6366f1;border-color:#6366f1;color:#fff;opacity:0.8">⬡ API Crate</button>
|
||||||
|
<button class="filter-btn btn btn-xs" data-level="Route" style="background:#10b981;border-color:#10b981;color:#fff">⊢ Routes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px h-4 bg-base-content/15 self-center"></div>
|
||||||
|
|
||||||
|
<!-- System -->
|
||||||
|
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">System</span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button class="filter-btn btn btn-xs" data-level="Dimension" style="background:#ec4899;border-color:#ec4899;color:#fff">⬡ State</button>
|
||||||
|
<button class="filter-btn btn btn-xs" data-level="ConfigSection" style="background:#14b8a6;border-color:#14b8a6;color:#fff">⊓ Config</button>
|
||||||
|
<button class="filter-btn btn btn-xs" data-level="Requirement" style="background:#f97316;border-color:#f97316;color:#fff">◈ Deps</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px h-4 bg-base-content/15 self-center"></div>
|
||||||
|
|
||||||
|
<!-- Poles -->
|
||||||
|
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Pole</span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button class="filter-btn btn btn-xs" style="background:#f59e0b;color:#111;border-color:#f59e0b" data-pole="Yang">Yang</button>
|
||||||
|
<button class="filter-btn btn btn-xs" style="background:#3b82f6;color:#fff;border-color:#3b82f6" data-pole="Yin">Yin</button>
|
||||||
|
<button class="filter-btn btn btn-xs" style="background:#8b5cf6;color:#fff;border-color:#8b5cf6" data-pole="Spiral">Spiral</button>
|
||||||
|
<button class="filter-btn btn btn-xs" style="background:#ec4899;color:#fff;border-color:#ec4899" data-pole="State">State</button>
|
||||||
|
<button class="filter-btn btn btn-xs" style="background:#14b8a6;color:#fff;border-color:#14b8a6" data-pole="Config">Config</button>
|
||||||
|
<button class="filter-btn btn btn-xs" style="background:#f97316;color:#fff;border-color:#f97316" data-pole="Env">Env</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Split: graph | drag handle | detail -->
|
<!-- Split: graph | drag handle | detail -->
|
||||||
<div id="graph-root">
|
<div id="graph-root">
|
||||||
<div id="cy-wrapper" class="bg-base-200">
|
<div id="cy-wrapper" class="bg-base-200">
|
||||||
@ -326,10 +441,16 @@
|
|||||||
<div id="resize-handle"></div>
|
<div id="resize-handle"></div>
|
||||||
|
|
||||||
<div id="detail-panel" class="bg-base-200 p-4 hidden">
|
<div id="detail-panel" class="bg-base-200 p-4 hidden">
|
||||||
<div class="flex justify-between items-start mb-2">
|
<!-- Panel nav header -->
|
||||||
<h3 class="font-bold text-base leading-tight" id="d-name"></h3>
|
<div class="flex items-center gap-0.5 mb-2">
|
||||||
<button id="btn-close-panel" class="btn btn-xs btn-ghost ml-2 flex-shrink-0">✕</button>
|
<button id="nav-back" class="btn btn-xs btn-ghost px-1.5" title="Back" disabled>‹</button>
|
||||||
|
<button id="nav-forward" class="btn btn-xs btn-ghost px-1.5" title="Forward" disabled>›</button>
|
||||||
|
<h3 class="font-bold text-base leading-tight flex-1 truncate mx-1" id="d-name"></h3>
|
||||||
|
<button id="btn-close-panel" class="btn btn-xs btn-ghost flex-shrink-0">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Node detail view -->
|
||||||
|
<div id="d-node-view">
|
||||||
<div class="flex flex-wrap gap-1 mb-2" id="d-badges"></div>
|
<div class="flex flex-wrap gap-1 mb-2" id="d-badges"></div>
|
||||||
<p class="text-xs text-base-content/70 mb-3 leading-relaxed" id="d-description"></p>
|
<p class="text-xs text-base-content/70 mb-3 leading-relaxed" id="d-description"></p>
|
||||||
<div id="d-artifacts" class="hidden mb-3">
|
<div id="d-artifacts" class="hidden mb-3">
|
||||||
@ -340,16 +461,46 @@
|
|||||||
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Validated by</p>
|
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Validated by</p>
|
||||||
<ul id="d-adr-list" class="text-xs font-mono space-y-1"></ul>
|
<ul id="d-adr-list" class="text-xs font-mono space-y-1"></ul>
|
||||||
</div>
|
</div>
|
||||||
<div id="d-edges" class="hidden">
|
<div id="d-edges" class="hidden mb-3">
|
||||||
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Connections</p>
|
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Connections</p>
|
||||||
<ul id="d-edge-list" class="text-xs text-base-content/60 space-y-1"></ul>
|
<ul id="d-edge-list" class="text-xs text-base-content/60 space-y-1"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="d-extra" class="hidden space-y-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content view: file or ADR rendered in-panel -->
|
||||||
|
<div id="d-content-view" class="hidden">
|
||||||
|
<p class="text-xs text-base-content/40 mb-3 font-mono break-all leading-relaxed" id="d-content-subtitle"></p>
|
||||||
|
<div id="d-content-body" class="text-sm space-y-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
// ── Source link helper ────────────────────────────────────────────────────────
|
||||||
|
// Opens file paths in the configured repo (Gitea/GitHub /src/branch/main/{path}).
|
||||||
|
// Falls back to cargo-doc root for .rs files when docs URL is set.
|
||||||
|
// option 1 (load file) is intentionally not used.
|
||||||
|
const CARD_REPO = "{{ card_repo | default(value='') | safe }}";
|
||||||
|
const CARD_DOCS = "{{ card_docs | default(value='') | safe }}";
|
||||||
|
|
||||||
|
function srcOpen(path) {
|
||||||
|
if (!path) return;
|
||||||
|
const ext = path.split('.').pop();
|
||||||
|
if (ext === 'rs' && CARD_DOCS) {
|
||||||
|
window.open(CARD_DOCS.replace(/\/$/, ''), '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CARD_REPO) {
|
||||||
|
window.open(`${CARD_REPO.replace(/\/$/, '')}/src/branch/main/${path}`, '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// no URL configured — nothing to open
|
||||||
|
}
|
||||||
|
|
||||||
const GRAPH = {{ graph_json | safe }};
|
const GRAPH = {{ graph_json | safe }};
|
||||||
|
|
||||||
// ── Icons ─────────────────────────────────────────────────────
|
// ── Icons ─────────────────────────────────────────────────────
|
||||||
@ -361,13 +512,19 @@ document.getElementById('cy-fit').innerHTML = SVG_FIT;
|
|||||||
document.getElementById('cy-fullscreen').innerHTML = SVG_FS_ENTER;
|
document.getElementById('cy-fullscreen').innerHTML = SVG_FS_ENTER;
|
||||||
|
|
||||||
// ── Graph data ────────────────────────────────────────────────
|
// ── Graph data ────────────────────────────────────────────────
|
||||||
const POLE_COLOR = { Yang: "#f59e0b", Yin: "#3b82f6", Spiral: "#8b5cf6" };
|
const POLE_COLOR = { Yang: "#f59e0b", Yin: "#3b82f6", Spiral: "#8b5cf6", Api: "#10b981", Code: "#6366f1", State: "#ec4899", Config: "#14b8a6", Env: "#f97316" };
|
||||||
const LEVEL_SHAPE = {
|
const LEVEL_SHAPE = {
|
||||||
Axiom: "diamond",
|
Axiom: "diamond",
|
||||||
Tension: "ellipse",
|
Tension: "ellipse",
|
||||||
Practice: "round-rectangle",
|
Practice: "round-rectangle",
|
||||||
Project: "hexagon",
|
Project: "hexagon",
|
||||||
Moment: "triangle",
|
Moment: "triangle",
|
||||||
|
Crate: "round-hexagon",
|
||||||
|
Route: "tag",
|
||||||
|
Dimension: "hexagon",
|
||||||
|
WorkspaceCrate: "barrel",
|
||||||
|
ConfigSection: "cut-rectangle",
|
||||||
|
Requirement: "round-tag",
|
||||||
};
|
};
|
||||||
const EDGE_STYLE = {
|
const EDGE_STYLE = {
|
||||||
ManifestsIn: { color: "#6b7280" },
|
ManifestsIn: { color: "#6b7280" },
|
||||||
@ -382,6 +539,7 @@ const EDGE_STYLE = {
|
|||||||
SpiralsWith: { color: "#8b5cf6" },
|
SpiralsWith: { color: "#8b5cf6" },
|
||||||
LimitedBy: { color: "#f43f5e" },
|
LimitedBy: { color: "#f43f5e" },
|
||||||
ValidatedBy: { color: "#84cc16" },
|
ValidatedBy: { color: "#84cc16" },
|
||||||
|
CoupledWith: { color: "#ec4899", dashed: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodes = (GRAPH.nodes || []).map(n => ({
|
const nodes = (GRAPH.nodes || []).map(n => ({
|
||||||
@ -396,6 +554,26 @@ const nodes = (GRAPH.nodes || []).map(n => ({
|
|||||||
adrs: n.adrs || [],
|
adrs: n.adrs || [],
|
||||||
color: POLE_COLOR[n.pole] || "#6b7280",
|
color: POLE_COLOR[n.pole] || "#6b7280",
|
||||||
shape: LEVEL_SHAPE[n.level] || "ellipse",
|
shape: LEVEL_SHAPE[n.level] || "ellipse",
|
||||||
|
// Route
|
||||||
|
method: n.method || "",
|
||||||
|
auth: n.auth || "",
|
||||||
|
actors: n.actors || [],
|
||||||
|
tags: n.tags || [],
|
||||||
|
params: n.params || [],
|
||||||
|
feature: n.feature || "",
|
||||||
|
// Dimension (FSM)
|
||||||
|
current_state: n.current_state || "",
|
||||||
|
desired_state: n.desired_state || "",
|
||||||
|
horizon: n.horizon || "",
|
||||||
|
// WorkspaceCrate
|
||||||
|
features: n.features || [],
|
||||||
|
ws_deps: n.ws_deps || [],
|
||||||
|
// ConfigSection
|
||||||
|
contract: n.contract || "",
|
||||||
|
// Requirement
|
||||||
|
env_kind: n.kind || "",
|
||||||
|
env_target: n.env || "",
|
||||||
|
required: !!n.required,
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -491,6 +669,14 @@ const cy = cytoscape({
|
|||||||
"text-background-shape": "round-rectangle",
|
"text-background-shape": "round-rectangle",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
selector: "node.search-dim",
|
||||||
|
style: { "opacity": 0.12 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "node.search-match",
|
||||||
|
style: { "border-color": "#ffffff", "border-width": 3, "opacity": 1 }
|
||||||
|
},
|
||||||
],
|
],
|
||||||
layout: buildBfsLayout(false),
|
layout: buildBfsLayout(false),
|
||||||
wheelSensitivity: 0.3,
|
wheelSensitivity: 0.3,
|
||||||
@ -592,7 +778,7 @@ document.getElementById('cy-fit').addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Full-screen ───────────────────────────────────────────────
|
// ── Full-screen ───────────────────────────────────────────────
|
||||||
const graphRoot = document.getElementById('graph-root');
|
const graphFsWrapper = document.getElementById('graph-fullscreen-wrapper');
|
||||||
const btnFullscreen = document.getElementById('cy-fullscreen');
|
const btnFullscreen = document.getElementById('cy-fullscreen');
|
||||||
|
|
||||||
function isFullscreen() {
|
function isFullscreen() {
|
||||||
@ -601,8 +787,8 @@ function isFullscreen() {
|
|||||||
|
|
||||||
btnFullscreen.addEventListener('click', () => {
|
btnFullscreen.addEventListener('click', () => {
|
||||||
if (!isFullscreen()) {
|
if (!isFullscreen()) {
|
||||||
const req = graphRoot.requestFullscreen || graphRoot.webkitRequestFullscreen;
|
const req = graphFsWrapper.requestFullscreen || graphFsWrapper.webkitRequestFullscreen;
|
||||||
if (req) req.call(graphRoot);
|
if (req) req.call(graphFsWrapper);
|
||||||
} else {
|
} else {
|
||||||
const exit = document.exitFullscreen || document.webkitExitFullscreen;
|
const exit = document.exitFullscreen || document.webkitExitFullscreen;
|
||||||
if (exit) exit.call(document);
|
if (exit) exit.call(document);
|
||||||
@ -620,8 +806,15 @@ document.addEventListener('fullscreenchange', onFullscreenChange);
|
|||||||
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
|
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
|
||||||
|
|
||||||
// ── Filters ───────────────────────────────────────────────────
|
// ── Filters ───────────────────────────────────────────────────
|
||||||
const hiddenLevels = new Set();
|
const hiddenLevels = new Set(["Crate", "Route", "Dimension", "WorkspaceCrate", "ConfigSection", "Requirement"]);
|
||||||
const hiddenPoles = new Set();
|
const hiddenPoles = new Set();
|
||||||
|
// Reflect initial hidden state on buttons.
|
||||||
|
document.querySelectorAll(".filter-btn[data-level]").forEach(btn => {
|
||||||
|
if (hiddenLevels.has(btn.dataset.level)) {
|
||||||
|
btn.style.opacity = "0.4";
|
||||||
|
btn.style.textDecoration = "line-through";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
cy.nodes().forEach(n => {
|
cy.nodes().forEach(n => {
|
||||||
@ -695,7 +888,7 @@ document.getElementById("btn-reset").addEventListener("click", () => {
|
|||||||
closePanel();
|
closePanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Node detail panel ─────────────────────────────────────────
|
// ── Panel refs ────────────────────────────────────────────────
|
||||||
const panel = document.getElementById("detail-panel");
|
const panel = document.getElementById("detail-panel");
|
||||||
const dName = document.getElementById("d-name");
|
const dName = document.getElementById("d-name");
|
||||||
const dBadges = document.getElementById("d-badges");
|
const dBadges = document.getElementById("d-badges");
|
||||||
@ -706,17 +899,83 @@ const dAdrs = document.getElementById("d-adrs");
|
|||||||
const dAdrList = document.getElementById("d-adr-list");
|
const dAdrList = document.getElementById("d-adr-list");
|
||||||
const dEdges = document.getElementById("d-edges");
|
const dEdges = document.getElementById("d-edges");
|
||||||
const dEdgeList = document.getElementById("d-edge-list");
|
const dEdgeList = document.getElementById("d-edge-list");
|
||||||
|
const dExtra = document.getElementById("d-extra");
|
||||||
|
const dNodeView = document.getElementById("d-node-view");
|
||||||
|
const dContentView = document.getElementById("d-content-view");
|
||||||
|
const dContentSub = document.getElementById("d-content-subtitle");
|
||||||
|
const dContentBody = document.getElementById("d-content-body");
|
||||||
|
const navBack = document.getElementById("nav-back");
|
||||||
|
const navForward = document.getElementById("nav-forward");
|
||||||
|
const GRAPH_SLUG = document.getElementById("graph-slug").value || null;
|
||||||
|
|
||||||
|
// ── Panel history (browser-style back/forward) ────────────────
|
||||||
|
const panelNav = {
|
||||||
|
stack: [],
|
||||||
|
cursor: -1,
|
||||||
|
push(entry) {
|
||||||
|
this.stack = this.stack.slice(0, this.cursor + 1);
|
||||||
|
this.stack.push(entry);
|
||||||
|
this.cursor = this.stack.length - 1;
|
||||||
|
this._sync();
|
||||||
|
},
|
||||||
|
back() {
|
||||||
|
if (this.cursor <= 0) return;
|
||||||
|
this.cursor--;
|
||||||
|
this._replay();
|
||||||
|
this._sync();
|
||||||
|
},
|
||||||
|
forward() {
|
||||||
|
if (this.cursor >= this.stack.length - 1) return;
|
||||||
|
this.cursor++;
|
||||||
|
this._replay();
|
||||||
|
this._sync();
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.stack = [];
|
||||||
|
this.cursor = -1;
|
||||||
|
this._sync();
|
||||||
|
},
|
||||||
|
_replay() {
|
||||||
|
const e = this.stack[this.cursor];
|
||||||
|
if (e.type === "node") openNode(e.id, { push: false, animate: true });
|
||||||
|
else if (e.type === "adr") openAdr(e.id, { push: false });
|
||||||
|
else if (e.type === "file") srcOpen(e.id);
|
||||||
|
},
|
||||||
|
_sync() {
|
||||||
|
navBack.disabled = this.cursor <= 0;
|
||||||
|
navForward.disabled = this.cursor >= this.stack.length - 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
navBack.addEventListener("click", () => panelNav.back());
|
||||||
|
navForward.addEventListener("click", () => panelNav.forward());
|
||||||
|
|
||||||
|
// ── Panel view switching ──────────────────────────────────────
|
||||||
|
function showNodeView() {
|
||||||
|
dNodeView.classList.remove("hidden");
|
||||||
|
dContentView.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showContentView(subtitle) {
|
||||||
|
dNodeView.classList.add("hidden");
|
||||||
|
dContentView.classList.remove("hidden");
|
||||||
|
dContentSub.textContent = subtitle || "";
|
||||||
|
}
|
||||||
|
|
||||||
function closePanel() {
|
function closePanel() {
|
||||||
panel.classList.add("hidden");
|
panel.classList.add("hidden");
|
||||||
cy.elements().removeClass("faded highlighted");
|
cy.elements().removeClass("faded highlighted");
|
||||||
|
panelNav.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
cy.on("tap", "node", evt => {
|
// ── Open node in panel ────────────────────────────────────────
|
||||||
const node = evt.target;
|
function openNode(id, { push = true, animate = true } = {}) {
|
||||||
|
const node = cy.getElementById(id);
|
||||||
|
if (!node.length) return;
|
||||||
const d = node.data();
|
const d = node.data();
|
||||||
|
|
||||||
panel.classList.remove("hidden");
|
panel.classList.remove("hidden");
|
||||||
|
showNodeView();
|
||||||
|
|
||||||
dName.textContent = d.label;
|
dName.textContent = d.label;
|
||||||
dBadges.innerHTML =
|
dBadges.innerHTML =
|
||||||
@ -728,9 +987,15 @@ cy.on("tap", "node", evt => {
|
|||||||
|
|
||||||
if (d.artifact_paths.length) {
|
if (d.artifact_paths.length) {
|
||||||
dArtifacts.classList.remove("hidden");
|
dArtifacts.classList.remove("hidden");
|
||||||
dList.innerHTML = d.artifact_paths.map(p =>
|
dList.innerHTML = d.artifact_paths.map(p => {
|
||||||
`<li class="break-all"><code>${p}</code></li>`
|
const ext = p.split(".").pop();
|
||||||
).join("");
|
const canView = ["ncl","toml","rs","nu","md","json","html"].includes(ext);
|
||||||
|
return `<li class="flex items-center gap-1.5 break-all">` +
|
||||||
|
(canView
|
||||||
|
? `<button class="artifact-link font-mono text-xs text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 text-left cursor-pointer" data-path="${p}">${p}</button>`
|
||||||
|
: `<code class="text-xs text-base-content/60">${p}</code>`) +
|
||||||
|
`</li>`;
|
||||||
|
}).join("");
|
||||||
} else {
|
} else {
|
||||||
dArtifacts.classList.add("hidden");
|
dArtifacts.classList.add("hidden");
|
||||||
}
|
}
|
||||||
@ -739,7 +1004,7 @@ cy.on("tap", "node", evt => {
|
|||||||
dAdrs.classList.remove("hidden");
|
dAdrs.classList.remove("hidden");
|
||||||
dAdrList.innerHTML = d.adrs.map(a =>
|
dAdrList.innerHTML = d.adrs.map(a =>
|
||||||
`<li><span class="text-success mr-1">◆</span>` +
|
`<li><span class="text-success mr-1">◆</span>` +
|
||||||
`<button class="adr-link font-mono text-base-content/70 hover:text-primary underline-offset-2 hover:underline cursor-pointer bg-transparent border-none p-0" data-adr="${a}">${a}</button></li>`
|
`<button class="adr-link font-mono text-xs text-base-content/70 hover:text-primary underline-offset-2 hover:underline cursor-pointer bg-transparent border-none p-0" data-adr="${a}">${a}</button></li>`
|
||||||
).join("");
|
).join("");
|
||||||
} else {
|
} else {
|
||||||
dAdrs.classList.add("hidden");
|
dAdrs.classList.add("hidden");
|
||||||
@ -750,32 +1015,38 @@ cy.on("tap", "node", evt => {
|
|||||||
dEdges.classList.remove("hidden");
|
dEdges.classList.remove("hidden");
|
||||||
dEdgeList.innerHTML = conn.map(e => {
|
dEdgeList.innerHTML = conn.map(e => {
|
||||||
const isSrc = e.data("source") === d.id;
|
const isSrc = e.data("source") === d.id;
|
||||||
const other = isSrc
|
const otherId = isSrc ? e.data("target") : e.data("source");
|
||||||
? cy.getElementById(e.data("target")).data("label")
|
const other = cy.getElementById(otherId);
|
||||||
: cy.getElementById(e.data("source")).data("label");
|
const otherLbl = other.data("label") || otherId;
|
||||||
const arrow = isSrc ? "→" : "←";
|
const arrow = isSrc ? "→" : "←";
|
||||||
return `<li class="flex gap-1"><span class="opacity-40 flex-shrink-0">${arrow}</span>` +
|
return `<li class="flex gap-1 items-baseline">` +
|
||||||
`<span class="text-base-content/80 flex-shrink-0">${e.data("kind")}</span>` +
|
`<span class="opacity-40 flex-shrink-0">${arrow}</span>` +
|
||||||
`<span class="opacity-60 break-all">${other}</span></li>`;
|
`<span class="text-base-content/80 flex-shrink-0 text-xs font-medium">${e.data("kind")}</span>` +
|
||||||
|
`<button class="node-jump-link text-xs opacity-60 break-all bg-transparent border-none p-0 cursor-pointer hover:text-primary hover:underline underline-offset-2 text-left" data-node-id="${otherId}">${otherLbl}</button>` +
|
||||||
|
`</li>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
} else {
|
} else {
|
||||||
dEdges.classList.add("hidden");
|
dEdges.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dim non-neighbours
|
renderNodeExtra(d);
|
||||||
|
|
||||||
cy.elements().addClass("faded").removeClass("highlighted");
|
cy.elements().addClass("faded").removeClass("highlighted");
|
||||||
node.closedNeighborhood().removeClass("faded").addClass("highlighted");
|
node.closedNeighborhood().removeClass("faded").addClass("highlighted");
|
||||||
|
|
||||||
// Animate center + zoom to selected node
|
if (animate) {
|
||||||
cy.animate(
|
cy.animate(
|
||||||
{ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) },
|
{ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) },
|
||||||
{ duration: 350, easing: 'ease-in-out-cubic' }
|
{ duration: 350, easing: "ease-in-out-cubic" }
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
cy.on("tap", evt => {
|
if (push) panelNav.push({ type: "node", id });
|
||||||
if (evt.target === cy) closePanel();
|
}
|
||||||
});
|
|
||||||
|
cy.on("tap", "node", evt => { openNode(evt.target.data("id")); });
|
||||||
|
|
||||||
|
cy.on("tap", evt => { if (evt.target === cy) closePanel(); });
|
||||||
|
|
||||||
document.getElementById("btn-close-panel").addEventListener("click", closePanel);
|
document.getElementById("btn-close-panel").addEventListener("click", closePanel);
|
||||||
|
|
||||||
@ -792,6 +1063,8 @@ handle.addEventListener("mousedown", e => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const graphRoot = document.getElementById("graph-root");
|
||||||
|
|
||||||
document.addEventListener("mousemove", e => {
|
document.addEventListener("mousemove", e => {
|
||||||
if (!resizing) return;
|
if (!resizing) return;
|
||||||
const rect = graphRoot.getBoundingClientRect();
|
const rect = graphRoot.getBoundingClientRect();
|
||||||
@ -814,63 +1087,241 @@ document.addEventListener("mouseup", () => {
|
|||||||
document.body.style.cursor = "";
|
document.body.style.cursor = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ADR modal ─────────────────────────────────────────────────
|
// ── Shared helpers ────────────────────────────────────────────
|
||||||
const adrModal = document.getElementById("adr-modal");
|
function esc(s) { return String(s ?? "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
|
||||||
const adrModalTitle = document.getElementById("adr-modal-title");
|
|
||||||
const adrModalBody = document.getElementById("adr-modal-body");
|
|
||||||
const GRAPH_SLUG = document.getElementById("graph-slug").value || null;
|
|
||||||
|
|
||||||
function renderAdrBody(data) {
|
function renderKvSection(data, skipKeys = []) {
|
||||||
if (data.error) {
|
return Object.entries(data)
|
||||||
return `<p class="text-error">${data.error}</p>`;
|
.filter(([k, v]) => !skipKeys.includes(k) && v !== "" && v !== null && v !== undefined)
|
||||||
}
|
|
||||||
const rows = Object.entries(data)
|
|
||||||
.filter(([k]) => k !== "id")
|
|
||||||
.map(([k, v]) => {
|
.map(([k, v]) => {
|
||||||
const label = k.replace(/_/g, " ");
|
const label = k.replace(/_/g, " ");
|
||||||
let val;
|
let val;
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
if (v.length === 0) return null;
|
if (v.length === 0) return null;
|
||||||
val = `<ul class="list-disc pl-4 space-y-0.5">${v.map(item =>
|
val = `<ul class="list-disc pl-4 space-y-0.5 text-base-content/70">${v.map(item =>
|
||||||
typeof item === "object"
|
typeof item === "object"
|
||||||
? `<li><pre class="text-xs whitespace-pre-wrap">${JSON.stringify(item, null, 2)}</pre></li>`
|
? `<li><pre class="text-xs whitespace-pre-wrap bg-base-300 p-1.5 rounded mt-0.5">${JSON.stringify(item, null, 2)}</pre></li>`
|
||||||
: `<li>${item}</li>`
|
: `<li>${item}</li>`
|
||||||
).join("")}</ul>`;
|
).join("")}</ul>`;
|
||||||
} else if (typeof v === "object" && v !== null) {
|
} else if (typeof v === "object" && v !== null) {
|
||||||
val = `<pre class="text-xs whitespace-pre-wrap bg-base-300 p-2 rounded">${JSON.stringify(v, null, 2)}</pre>`;
|
val = `<pre class="text-xs whitespace-pre-wrap bg-base-300 p-2 rounded">${JSON.stringify(v, null, 2)}</pre>`;
|
||||||
|
} else if (typeof v === "boolean") {
|
||||||
|
val = `<span class="badge badge-xs ${v ? "badge-success" : "badge-ghost"}">${v}</span>`;
|
||||||
} else {
|
} else {
|
||||||
val = `<span class="text-base-content/80">${v}</span>`;
|
val = `<span class="text-base-content/80">${v}</span>`;
|
||||||
}
|
}
|
||||||
return `<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-0.5">${label}</p>${val}</div>`;
|
return `<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-0.5">${label}</p>${val}</div>`;
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("");
|
.join("");
|
||||||
return rows || `<p class="text-base-content/50">No details available.</p>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAdr(id) {
|
// ── Open ADR in panel ─────────────────────────────────────────
|
||||||
adrModalTitle.textContent = id;
|
async function openAdr(id, { push = true } = {}) {
|
||||||
adrModalBody.innerHTML = `<span class="loading loading-spinner loading-sm"></span>`;
|
panel.classList.remove("hidden");
|
||||||
adrModal.showModal();
|
showContentView("Architecture Decision Record");
|
||||||
|
dName.textContent = id;
|
||||||
|
dContentBody.innerHTML = `<span class="loading loading-spinner loading-sm"></span>`;
|
||||||
|
|
||||||
const slug = GRAPH_SLUG ? `&slug=${encodeURIComponent(GRAPH_SLUG)}` : "";
|
const params = new URLSearchParams({ ...(GRAPH_SLUG ? { slug: GRAPH_SLUG } : {}) });
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${slug}`);
|
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${params}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
adrModalBody.innerHTML = renderAdrBody(data);
|
dContentBody.innerHTML = data.error
|
||||||
|
? `<p class="text-error text-sm">${esc(data.error)}</p>`
|
||||||
|
: renderKvSection(data, ["id"]) || `<p class="text-base-content/50 text-sm">No details.</p>`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
adrModalBody.innerHTML = `<p class="text-error">Failed to load ADR: ${err}</p>`;
|
dContentBody.innerHTML = `<p class="text-error text-sm">Failed: ${esc(String(err))}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (push) panelNav.push({ type: "adr", id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFile is replaced by srcOpen — opens in repo/docs tab, no panel loading.
|
||||||
|
|
||||||
|
// ── Node-type extra sections in the side panel ────────────────
|
||||||
|
function renderNodeExtra(d) {
|
||||||
|
const el = dExtra;
|
||||||
|
el.innerHTML = "";
|
||||||
|
el.classList.add("hidden");
|
||||||
|
|
||||||
|
const sec = (label, content) =>
|
||||||
|
`<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-1">${label}</p>${content}</div>`;
|
||||||
|
|
||||||
|
const badge = (text, color) =>
|
||||||
|
`<span class="badge badge-xs" style="background:${color};color:#fff;border:none">${text}</span>`;
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
|
||||||
|
if (d.level === "Route") {
|
||||||
|
const authColor = { none: "#6b7280", viewer: "#f59e0b", bearer: "#3b82f6", admin: "#ef4444" };
|
||||||
|
const authBadge = badge(d.auth || "none", authColor[d.auth] || "#6b7280");
|
||||||
|
const methodBadge = badge(d.method, "#10b981");
|
||||||
|
html += sec("Endpoint",
|
||||||
|
`<div class="flex flex-wrap gap-1">${methodBadge}${authBadge}` +
|
||||||
|
(d.feature ? `<span class="badge badge-xs badge-outline">feature:${d.feature}</span>` : "") +
|
||||||
|
`</div>`);
|
||||||
|
if (d.actors?.length)
|
||||||
|
html += sec("Actors", d.actors.map(a => `<span class="badge badge-xs badge-ghost">${a}</span>`).join(" "));
|
||||||
|
if (d.tags?.length)
|
||||||
|
html += sec("Tags", d.tags.map(t => `<code class="text-xs bg-base-300 px-1 rounded">${t}</code>`).join(" "));
|
||||||
|
if (d.params?.length) {
|
||||||
|
const rows = d.params.map(p =>
|
||||||
|
`<li class="flex flex-wrap gap-1 items-baseline">` +
|
||||||
|
`<code class="text-xs font-bold">${p.name}</code>` +
|
||||||
|
`<span class="text-xs text-base-content/40">${p.kind}</span>` +
|
||||||
|
`<span class="badge badge-xs badge-ghost">${p.constraint}</span>` +
|
||||||
|
(p.description ? `<span class="text-xs text-base-content/50">— ${p.description}</span>` : "") +
|
||||||
|
`</li>`
|
||||||
|
).join("");
|
||||||
|
html += sec("Parameters", `<ul class="space-y-1">${rows}</ul>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (d.level === "Dimension") {
|
||||||
|
const reached = d.current_state && d.current_state === d.desired_state;
|
||||||
|
const statusBadge = reached
|
||||||
|
? `<span class="badge badge-xs badge-success">reached</span>`
|
||||||
|
: `<span class="badge badge-xs badge-warning">in progress</span>`;
|
||||||
|
html += sec("FSM State",
|
||||||
|
`<div class="space-y-1 text-xs">` +
|
||||||
|
`<div class="flex gap-2 items-center">${statusBadge}<span class="badge badge-xs badge-ghost">${d.horizon}</span></div>` +
|
||||||
|
`<div><span class="text-base-content/40">current: </span><code>${d.current_state}</code></div>` +
|
||||||
|
`<div><span class="text-base-content/40">desired: </span><code>${d.desired_state}</code></div>` +
|
||||||
|
`</div>`);
|
||||||
|
|
||||||
|
} else if (d.level === "WorkspaceCrate") {
|
||||||
|
if (d.features?.length)
|
||||||
|
html += sec("Features", d.features.map(f =>
|
||||||
|
`<span class="badge badge-xs badge-outline font-mono">${f}</span>`).join(" "));
|
||||||
|
if (d.ws_deps?.length) {
|
||||||
|
html += sec("Depends on",
|
||||||
|
d.ws_deps.map(dep => {
|
||||||
|
const depId = dep;
|
||||||
|
const depNode = cy.nodes(`[id = "${depId}"]`);
|
||||||
|
const label = depNode.length ? depNode.data("label") : depId.replace("ws:", "");
|
||||||
|
return `<button class="node-jump-link badge badge-xs badge-ghost cursor-pointer" data-node-id="${depId}">${label}</button>`;
|
||||||
|
}).join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (d.level === "ConfigSection") {
|
||||||
|
if (d.contract)
|
||||||
|
html += sec("Contract",
|
||||||
|
`<code class="text-xs bg-base-300 px-1.5 py-0.5 rounded">${d.contract}</code>`);
|
||||||
|
|
||||||
|
} else if (d.level === "Requirement") {
|
||||||
|
html += sec("Details",
|
||||||
|
`<div class="flex flex-wrap gap-1">` +
|
||||||
|
(d.env_kind ? badge(d.env_kind, "#6366f1") : "") +
|
||||||
|
(d.env_target ? badge(d.env_target, "#8b5cf6") : "") +
|
||||||
|
(d.required ? `<span class="badge badge-xs badge-error">required</span>`
|
||||||
|
: `<span class="badge badge-xs badge-ghost">optional</span>`) +
|
||||||
|
`</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html) {
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Click delegation ──────────────────────────────────────────
|
||||||
document.addEventListener("click", e => {
|
document.addEventListener("click", e => {
|
||||||
const btn = e.target.closest(".adr-link");
|
// Close search dropdown on outside click
|
||||||
if (btn) fetchAdr(btn.dataset.adr);
|
if (!e.target.closest("#search-wrap")) {
|
||||||
|
searchDropdown.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const adrBtn = e.target.closest(".adr-link");
|
||||||
|
if (adrBtn) { openAdr(adrBtn.dataset.adr); return; }
|
||||||
|
|
||||||
|
const artBtn = e.target.closest(".artifact-link");
|
||||||
|
if (artBtn) { srcOpen(artBtn.dataset.path); return; }
|
||||||
|
|
||||||
|
const jumpBtn = e.target.closest(".node-jump-link");
|
||||||
|
if (jumpBtn) { openNode(jumpBtn.dataset.nodeId, { push: true, animate: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Inline node search ────────────────────────────────────────
|
||||||
|
const graphSearch = document.getElementById("graph-search");
|
||||||
|
const searchCount = document.getElementById("search-count");
|
||||||
|
const searchDropdown = document.getElementById("search-dropdown");
|
||||||
|
|
||||||
|
let searchMatches = [];
|
||||||
|
|
||||||
|
function clearGraphSearch() {
|
||||||
|
cy.nodes().removeClass("search-match search-dim");
|
||||||
|
searchMatches = [];
|
||||||
|
searchCount.textContent = "";
|
||||||
|
searchDropdown.classList.add("hidden");
|
||||||
|
searchDropdown.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function doGraphSearch(q) {
|
||||||
|
clearGraphSearch();
|
||||||
|
if (!q) return;
|
||||||
|
const ql = q.toLowerCase();
|
||||||
|
searchMatches = cy.nodes(":visible").filter(n =>
|
||||||
|
(n.data("label") || "").toLowerCase().includes(ql) ||
|
||||||
|
(n.data("id") || "").toLowerCase().includes(ql) ||
|
||||||
|
(n.data("description") || "").toLowerCase().includes(ql)
|
||||||
|
).toArray();
|
||||||
|
|
||||||
|
searchCount.textContent = `${searchMatches.length}`;
|
||||||
|
|
||||||
|
if (searchMatches.length === 0) return;
|
||||||
|
|
||||||
|
// Highlight in graph
|
||||||
|
cy.nodes().addClass("search-dim");
|
||||||
|
searchMatches.forEach(n => n.removeClass("search-dim").addClass("search-match"));
|
||||||
|
|
||||||
|
// Dropdown list (cap 12)
|
||||||
|
const shown = searchMatches.slice(0, 12);
|
||||||
|
searchDropdown.innerHTML = shown.map((n, i) =>
|
||||||
|
`<li class="search-hit cursor-pointer px-3 py-2 hover:bg-base-300 transition-colors flex items-center gap-2" data-idx="${i}">` +
|
||||||
|
`<span class="badge badge-xs flex-shrink-0" style="background:${n.data("color")};border:none;color:#fff">${esc(n.data("level"))}</span>` +
|
||||||
|
`<span class="truncate">${esc(n.data("label"))}</span>` +
|
||||||
|
`</li>`
|
||||||
|
).join("");
|
||||||
|
if (searchMatches.length > 12) {
|
||||||
|
searchDropdown.innerHTML +=
|
||||||
|
`<li class="px-3 py-1.5 text-xs text-base-content/40">+${searchMatches.length - 12} more…</li>`;
|
||||||
|
}
|
||||||
|
searchDropdown.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
graphSearch.addEventListener("input", () => {
|
||||||
|
const q = graphSearch.value.trim();
|
||||||
|
if (!q) { clearGraphSearch(); return; }
|
||||||
|
doGraphSearch(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
graphSearch.addEventListener("keydown", e => {
|
||||||
|
if (e.key === "Escape") { graphSearch.value = ""; clearGraphSearch(); e.preventDefault(); return; }
|
||||||
|
if (e.key === "Enter" && searchMatches.length > 0) {
|
||||||
|
openNode(searchMatches[0].data("id"));
|
||||||
|
searchDropdown.classList.add("hidden");
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchDropdown.addEventListener("click", e => {
|
||||||
|
const li = e.target.closest(".search-hit");
|
||||||
|
if (!li) return;
|
||||||
|
openNode(searchMatches[parseInt(li.dataset.idx, 10)].data("id"));
|
||||||
|
searchDropdown.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── URL param auto-open ───────────────────────────────────────
|
||||||
|
const urlNode = new URLSearchParams(location.search).get("node");
|
||||||
|
if (urlNode) {
|
||||||
|
cy.ready(() => {
|
||||||
|
// Wait for layout to finish before centering
|
||||||
|
setTimeout(() => openNode(urlNode, { push: true, animate: true }), 600);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Keyboard shortcuts ────────────────────────────────────────
|
// ── Keyboard shortcuts ────────────────────────────────────────
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
// Skip when focus is inside an input, textarea, or dialog
|
|
||||||
const tag = document.activeElement?.tagName;
|
const tag = document.activeElement?.tagName;
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||||
if (document.querySelector('dialog[open]')) return;
|
if (document.querySelector('dialog[open]')) return;
|
||||||
|
|||||||
@ -17,84 +17,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
<div id="manage-error" class="{% if error %}alert alert-error mb-4 text-sm{% endif %}">
|
||||||
<div class="alert alert-error mb-4 text-sm">
|
{% if error %}<span>{{ error }}</span>{% endif %}
|
||||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ error }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Registered Projects -->
|
<!-- Registered Projects -->
|
||||||
<div class="mb-6">
|
<div class="mb-6" id="manage-projects-section">
|
||||||
<h2 class="text-sm font-semibold text-base-content/50 uppercase tracking-wider mb-3">Registered Projects</h2>
|
{% include "partials/manage_projects_section.html" %}
|
||||||
{% if projects %}
|
|
||||||
<div class="overflow-x-auto rounded-lg border border-base-content/10">
|
|
||||||
<table class="table table-sm w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-xs text-base-content/40 uppercase tracking-wider">
|
|
||||||
<th>Slug</th>
|
|
||||||
<th>Root</th>
|
|
||||||
<th>Mode</th>
|
|
||||||
<th>Auth</th>
|
|
||||||
<th>Persisted</th>
|
|
||||||
<th class="text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for p in projects %}
|
|
||||||
<tr class="hover:bg-base-200/50">
|
|
||||||
<td class="font-mono font-medium">
|
|
||||||
{% if not p.push_only %}
|
|
||||||
<a href="/ui/{{ p.slug }}/" class="link link-hover text-primary">{{ p.slug }}</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-base-content/60">{{ p.slug }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono text-xs text-base-content/60 max-w-xs truncate" title="{{ p.root }}">{{ p.root }}</td>
|
|
||||||
<td>
|
|
||||||
{% if p.opmode == "daemon" %}
|
|
||||||
<span class="badge badge-xs badge-success gap-1">
|
|
||||||
<span class="w-1 h-1 rounded-full bg-current inline-block"></span>daemon
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-xs badge-info">push</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if p.auth %}
|
|
||||||
<span class="badge badge-xs badge-warning" title="{{ p.roles | join(sep=', ') }}">
|
|
||||||
{{ p.key_count }} key{% if p.key_count != 1 %}s{% endif %}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-xs badge-ghost">open</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if p.in_projects_ncl %}
|
|
||||||
<span class="badge badge-xs badge-ghost text-success">✓</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-xs badge-warning" title="In memory only — will be lost on restart">memory only</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<form method="post" action="/ui/manage/remove" class="inline"
|
|
||||||
onsubmit="return confirm('Remove project {{ p.slug }}?')">
|
|
||||||
<input type="hidden" name="slug" value="{{ p.slug }}">
|
|
||||||
<button type="submit" class="btn btn-xs btn-error btn-outline">Remove</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-10 text-base-content/30 text-sm border border-base-content/10 rounded-lg">
|
|
||||||
No projects registered. Add one below.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Project Form -->
|
<!-- Add Project Form -->
|
||||||
@ -102,7 +31,11 @@
|
|||||||
<div class="card-body p-5">
|
<div class="card-body p-5">
|
||||||
<h2 class="card-title text-sm font-semibold">Add Project</h2>
|
<h2 class="card-title text-sm font-semibold">Add Project</h2>
|
||||||
<p class="text-xs text-base-content/40 -mt-1">Slug and import paths are read from <code>.ontoref/project.ncl</code>. Change is persisted to <code>~/.config/ontoref/projects.ncl</code>.</p>
|
<p class="text-xs text-base-content/40 -mt-1">Slug and import paths are read from <code>.ontoref/project.ncl</code>. Change is persisted to <code>~/.config/ontoref/projects.ncl</code>.</p>
|
||||||
<form method="post" action="/ui/manage/add" class="flex flex-col sm:flex-row gap-3 mt-2">
|
<form hx-post="/ui/manage/add"
|
||||||
|
hx-target="#manage-projects-section"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="if(event.detail.successful)this.reset()"
|
||||||
|
class="flex flex-col sm:flex-row gap-3 mt-2">
|
||||||
<div class="form-control flex-1">
|
<div class="form-control flex-1">
|
||||||
<label class="label py-1">
|
<label class="label py-1">
|
||||||
<span class="label-text text-xs text-base-content/60">Absolute root path</span>
|
<span class="label-text text-xs text-base-content/60">Absolute root path</span>
|
||||||
|
|||||||
@ -64,13 +64,20 @@
|
|||||||
<!-- DAG action buttons -->
|
<!-- DAG action buttons -->
|
||||||
<div class="flex flex-wrap gap-1 mt-1.5">
|
<div class="flex flex-wrap gap-1 mt-1.5">
|
||||||
{% for act in p.actions %}
|
{% for act in p.actions %}
|
||||||
<form method="post" action="{{ base_url }}/notifications/{{ n.id }}/action" class="inline">
|
<form hx-post="{{ base_url }}/notifications/{{ n.id }}/action"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="inline">
|
||||||
<input type="hidden" name="action_id" value="{{ act.id }}">
|
<input type="hidden" name="action_id" value="{{ act.id }}">
|
||||||
<button type="submit" class="btn btn-xs
|
<button type="submit" class="btn btn-xs
|
||||||
{% if act.mode == 'auto' %}btn-error
|
{% if act.mode == 'backlog_approve' %}btn-success
|
||||||
|
{% elif act.mode == 'backlog_reject' %}btn-ghost
|
||||||
|
{% elif act.mode == 'auto' %}btn-error
|
||||||
{% elif act.mode == 'semi' %}btn-warning
|
{% elif act.mode == 'semi' %}btn-warning
|
||||||
{% else %}btn-ghost{% endif %} gap-1">
|
{% else %}btn-ghost{% endif %} gap-1">
|
||||||
{% if act.mode == 'auto' %}▶{% elif act.mode == 'semi' %}◑{% else %}→{% endif %}
|
{% if act.mode == 'backlog_approve' %}✓
|
||||||
|
{% elif act.mode == 'backlog_reject' %}✕
|
||||||
|
{% elif act.mode == 'auto' %}▶{% elif act.mode == 'semi' %}◑{% else %}→{% endif %}
|
||||||
{{ act.label }}
|
{{ act.label }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -79,7 +86,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<details class="mt-1">
|
<details class="mt-1">
|
||||||
<summary class="text-xs text-base-content/40 cursor-pointer">payload</summary>
|
<summary class="text-xs text-base-content/40 cursor-pointer">payload</summary>
|
||||||
<pre class="text-xs text-base-content/60 mt-1 bg-base-300 rounded p-2 max-w-xs overflow-auto">{{ p }}</pre>
|
<pre class="text-xs text-base-content/60 mt-1 bg-base-300 rounded p-2 max-w-xs overflow-auto">{{ p | json_encode(pretty=true) }}</pre>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -107,7 +114,10 @@
|
|||||||
<dialog id="emit-modal" class="modal">
|
<dialog id="emit-modal" class="modal">
|
||||||
<div class="modal-box max-w-lg">
|
<div class="modal-box max-w-lg">
|
||||||
<h3 class="font-bold text-base mb-4">Emit Notification</h3>
|
<h3 class="font-bold text-base mb-4">Emit Notification</h3>
|
||||||
<form method="post" action="{{ base_url }}/notifications/emit" class="space-y-3">
|
<form hx-post="{{ base_url }}/notifications/emit"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){document.getElementById('emit-modal').close();this.reset()}"
|
||||||
|
class="space-y-3">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label py-1"><span class="label-text text-sm">Target project</span></label>
|
<label class="label py-1"><span class="label-text text-sm">Target project</span></label>
|
||||||
<select name="target_slug" class="select select-bordered select-sm" required>
|
<select name="target_slug" class="select select-bordered select-sm" required>
|
||||||
|
|||||||
@ -79,7 +79,7 @@
|
|||||||
<div class="flex items-center gap-0.5 flex-shrink-0">
|
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||||
{% if p.card %}
|
{% if p.card %}
|
||||||
<button onclick="openCard('{{ p.slug }}')" title="Project card"
|
<button onclick="openCard('{{ p.slug }}')" title="Project card"
|
||||||
class="btn btn-ghost btn-xs btn-circle">
|
class="btn btn-ghost btn-xs">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
@ -87,21 +87,21 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/ui/{{ p.slug }}/search" title="Search"
|
<a href="/ui/{{ p.slug }}/search" title="Search"
|
||||||
class="btn btn-ghost btn-xs btn-circle">
|
class="btn btn-ghost btn-xs">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a href="/ui/{{ p.slug }}/backlog" title="Backlog"
|
<a href="/ui/{{ p.slug }}/backlog" title="Backlog"
|
||||||
class="btn btn-ghost btn-xs btn-circle">
|
class="btn btn-ghost btn-xs">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<a href="/ui/{{ p.slug }}/" title="Open dashboard"
|
<a href="/ui/{{ p.slug }}/" title="Open dashboard"
|
||||||
class="btn btn-ghost btn-xs btn-circle">
|
class="btn btn-ghost btn-xs">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||||
@ -178,11 +178,18 @@
|
|||||||
{% if p.op_modes %}
|
{% if p.op_modes %}
|
||||||
<span class="badge badge-sm badge-ghost">{{ p.op_modes | length }} modes</span>
|
<span class="badge badge-sm badge-ghost">{{ p.op_modes | length }} modes</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<span id="mig-badge-{{ p.slug }}" class="badge badge-sm badge-warning hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Accordion panels -->
|
<!-- Accordion panels -->
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
|
||||||
|
<div id="mig-panel-{{ p.slug }}"
|
||||||
|
hx-get="/ui/{{ p.slug }}/migrations/pending"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-swap="innerHTML"></div>
|
||||||
|
|
||||||
{% if p.layers or p.op_modes %}
|
{% if p.layers or p.op_modes %}
|
||||||
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
|
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
|
||||||
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
|
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
|
||||||
|
|||||||
@ -77,10 +77,16 @@
|
|||||||
<!-- Resize handle -->
|
<!-- Resize handle -->
|
||||||
<div id="search-resize" class="w-1.5 bg-base-300 hover:bg-primary/40 cursor-col-resize transition-colors flex-shrink-0"></div>
|
<div id="search-resize" class="w-1.5 bg-base-300 hover:bg-primary/40 cursor-col-resize transition-colors flex-shrink-0"></div>
|
||||||
|
|
||||||
<!-- Right: detail panel -->
|
<!-- Right: detail panel with nav bar -->
|
||||||
<div id="detail-panel" class="flex-1 bg-base-200 rounded-r-lg overflow-y-auto p-5 hidden min-w-[200px]">
|
<div id="detail-panel" class="flex-1 bg-base-200 rounded-r-lg overflow-hidden flex flex-col hidden min-w-[200px]">
|
||||||
|
<div class="flex items-center gap-1 px-3 py-2 border-b border-base-content/10 flex-shrink-0">
|
||||||
|
<button id="dp-back" class="btn btn-xs btn-ghost px-2" title="Back" disabled>‹</button>
|
||||||
|
<button id="dp-forward" class="btn btn-xs btn-ghost px-2" title="Forward" disabled>›</button>
|
||||||
|
</div>
|
||||||
|
<div id="dp-content" class="flex-1 overflow-y-auto p-5">
|
||||||
<!-- filled by JS -->
|
<!-- filled by JS -->
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Empty right side when nothing selected -->
|
<!-- Empty right side when nothing selected -->
|
||||||
<div id="detail-empty" class="flex-1 bg-base-200 rounded-r-lg flex items-center justify-center text-base-content/25 text-sm">
|
<div id="detail-empty" class="flex-1 bg-base-200 rounded-r-lg flex items-center justify-center text-base-content/25 text-sm">
|
||||||
@ -103,14 +109,136 @@ const input = document.getElementById('search-input');
|
|||||||
const resultsList = document.getElementById('results-list');
|
const resultsList = document.getElementById('results-list');
|
||||||
const resultsCount = document.getElementById('results-count');
|
const resultsCount = document.getElementById('results-count');
|
||||||
const detail = document.getElementById('detail-panel');
|
const detail = document.getElementById('detail-panel');
|
||||||
|
const dpContent = document.getElementById('dp-content');
|
||||||
const detailEmpty = document.getElementById('detail-empty');
|
const detailEmpty = document.getElementById('detail-empty');
|
||||||
const resetBtn = document.getElementById('btn-reset-search');
|
const resetBtn = document.getElementById('btn-reset-search');
|
||||||
const resizeHandle = document.getElementById('search-resize');
|
const resizeHandle = document.getElementById('search-resize');
|
||||||
|
const dpBack = document.getElementById('dp-back');
|
||||||
|
const dpForward = document.getElementById('dp-forward');
|
||||||
|
|
||||||
const LEFT_PANEL = document.querySelector('#search-pane').closest('div.flex-col');
|
const LEFT_PANEL = document.querySelector('#search-pane').closest('div.flex-col');
|
||||||
const CONTAINER = LEFT_PANEL.parentElement;
|
const CONTAINER = LEFT_PANEL.parentElement;
|
||||||
|
|
||||||
|
// ── Panel navigation (back/forward) ────────────────────────────────────────
|
||||||
|
|
||||||
|
const dpNav = {
|
||||||
|
stack: [],
|
||||||
|
cursor: -1,
|
||||||
|
push(entry) {
|
||||||
|
this.stack = this.stack.slice(0, this.cursor + 1);
|
||||||
|
this.stack.push(entry);
|
||||||
|
this.cursor = this.stack.length - 1;
|
||||||
|
this._sync();
|
||||||
|
},
|
||||||
|
back() {
|
||||||
|
if (this.cursor <= 0) return;
|
||||||
|
this.cursor--;
|
||||||
|
this._replay();
|
||||||
|
this._sync();
|
||||||
|
},
|
||||||
|
forward() {
|
||||||
|
if (this.cursor >= this.stack.length - 1) return;
|
||||||
|
this.cursor++;
|
||||||
|
this._replay();
|
||||||
|
this._sync();
|
||||||
|
},
|
||||||
|
reset() { this.stack = []; this.cursor = -1; this._sync(); },
|
||||||
|
_replay() {
|
||||||
|
const e = this.stack[this.cursor];
|
||||||
|
if (e.type === 'result') _showResultContent(e.r, false);
|
||||||
|
else if (e.type === 'file') srcOpen(e.path);
|
||||||
|
else if (e.type === 'adr') openAdrInPanel(e.id, e.title, false);
|
||||||
|
},
|
||||||
|
_sync() {
|
||||||
|
dpBack.disabled = this.cursor <= 0;
|
||||||
|
dpForward.disabled = this.cursor >= this.stack.length - 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dpBack.addEventListener('click', () => dpNav.back());
|
||||||
|
dpForward.addEventListener('click', () => dpNav.forward());
|
||||||
|
|
||||||
|
function showPanel() {
|
||||||
|
detail.classList.remove('hidden');
|
||||||
|
detailEmpty.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePanel() {
|
||||||
|
detail.classList.add('hidden');
|
||||||
|
detailEmpty.classList.remove('hidden');
|
||||||
|
dpNav.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ADR inline loader ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function openAdrInPanel(id, title = '', push = true) {
|
||||||
|
showPanel();
|
||||||
|
dpContent.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
|
||||||
|
const params = new URLSearchParams({});
|
||||||
|
if (SLUG) params.set('slug', SLUG);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${params}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
dpContent.innerHTML = `<p class="text-error text-sm">${esc(data.error)}</p>`;
|
||||||
|
} else {
|
||||||
|
const entries = Object.entries(data)
|
||||||
|
.filter(([k, v]) => k !== 'id' && v !== '' && v !== null && v !== undefined)
|
||||||
|
.map(([k, v]) => {
|
||||||
|
const label = k.replace(/_/g, ' ');
|
||||||
|
let val;
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
if (v.length === 0) return null;
|
||||||
|
val = `<ul class="list-disc pl-4 space-y-0.5 text-base-content/70 text-xs">${
|
||||||
|
v.map(item => typeof item === 'object'
|
||||||
|
? `<li><pre class="text-xs whitespace-pre-wrap bg-base-300 p-1.5 rounded">${esc(JSON.stringify(item, null, 2))}</pre></li>`
|
||||||
|
: `<li>${esc(String(item))}</li>`
|
||||||
|
).join('')}</ul>`;
|
||||||
|
} else if (typeof v === 'object') {
|
||||||
|
val = `<pre class="text-xs whitespace-pre-wrap bg-base-300 p-2 rounded">${esc(JSON.stringify(v, null, 2))}</pre>`;
|
||||||
|
} else if (typeof v === 'boolean') {
|
||||||
|
val = `<span class="badge badge-xs ${v ? 'badge-success' : 'badge-ghost'}">${v}</span>`;
|
||||||
|
} else {
|
||||||
|
val = `<span class="text-base-content/80 text-xs">${esc(String(v))}</span>`;
|
||||||
|
}
|
||||||
|
return `<div class="mb-3"><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-0.5">${esc(label)}</p>${val}</div>`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
dpContent.innerHTML = entries || '<p class="text-base-content/50 text-sm">No details.</p>';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
dpContent.innerHTML = `<p class="text-error text-sm">Failed: ${esc(String(err))}</p>`;
|
||||||
|
}
|
||||||
|
if (push) dpNav.push({ type: 'adr', id, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Click delegation inside detail panel ─────────────────────────────────
|
||||||
|
|
||||||
|
document.getElementById('detail-panel').addEventListener('click', e => {
|
||||||
|
const fileBtn = e.target.closest('.s-file-link');
|
||||||
|
if (fileBtn) { srcOpen(fileBtn.dataset.path); return; }
|
||||||
|
|
||||||
|
const adrBtn = e.target.closest('.s-adr-link');
|
||||||
|
if (adrBtn) { openAdrInPanel(adrBtn.dataset.adr, adrBtn.dataset.adr); }
|
||||||
|
});
|
||||||
|
|
||||||
const BASE_URL = "{{ base_url }}";
|
const BASE_URL = "{{ base_url }}";
|
||||||
|
const CARD_REPO = "{{ card_repo | default(value='') | safe }}";
|
||||||
|
const CARD_DOCS = "{{ card_docs | default(value='') | safe }}";
|
||||||
|
|
||||||
|
function srcOpen(path) {
|
||||||
|
if (!path) return;
|
||||||
|
const ext = path.split('.').pop().toLowerCase();
|
||||||
|
if (ext === 'rs' && CARD_DOCS) {
|
||||||
|
window.open(CARD_DOCS.replace(/\/$/, ''), '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (CARD_REPO) {
|
||||||
|
window.open(`${CARD_REPO.replace(/\/$/, '')}/src/branch/main/${path}`, '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let results = [];
|
let results = [];
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
@ -231,7 +359,7 @@ function renderBookmarks() {
|
|||||||
<div class="px-3 py-2 flex items-center gap-2">
|
<div class="px-3 py-2 flex items-center gap-2">
|
||||||
<span class="badge badge-xs ${kindCls(b.kind)} flex-shrink-0">${esc(b.kind)}</span>
|
<span class="badge badge-xs ${kindCls(b.kind)} flex-shrink-0">${esc(b.kind)}</span>
|
||||||
<span class="text-xs font-medium truncate flex-1">${esc(b.title)}</span>
|
<span class="text-xs font-medium truncate flex-1">${esc(b.title)}</span>
|
||||||
<button class="btn-unbm btn btn-ghost btn-xs btn-circle flex-shrink-0 opacity-40 hover:opacity-100 hover:text-error"
|
<button class="btn-unbm btn btn-ghost btn-xs flex-shrink-0 opacity-40 hover:opacity-100 hover:text-error"
|
||||||
data-nid="${esc(b.node_id)}" title="Remove">
|
data-nid="${esc(b.node_id)}" title="Remove">
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
@ -276,9 +404,8 @@ function renderBookmarks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showDetailBm(bm) {
|
function showDetailBm(bm) {
|
||||||
detail.classList.remove('hidden');
|
showPanel();
|
||||||
detailEmpty.classList.add('hidden');
|
dpContent.innerHTML = `
|
||||||
detail.innerHTML = `
|
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h2 class="font-bold text-base leading-tight">${esc(bm.title)}</h2>
|
<h2 class="font-bold text-base leading-tight">${esc(bm.title)}</h2>
|
||||||
@ -287,8 +414,7 @@ function showDetailBm(bm) {
|
|||||||
${bm.level ? `<span class="badge badge-xs badge-ghost">${esc(bm.level)}</span>` : ''}
|
${bm.level ? `<span class="badge badge-xs badge-ghost">${esc(bm.level)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="bm-detail-star" class="btn btn-ghost btn-xs btn-circle text-warning"
|
<button id="bm-detail-star" class="btn btn-ghost btn-xs text-warning" title="Remove bookmark">
|
||||||
title="Remove bookmark">
|
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
|
<path d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -300,8 +426,7 @@ function showDetailBm(bm) {
|
|||||||
`;
|
`;
|
||||||
document.getElementById('bm-detail-star').addEventListener('click', async () => {
|
document.getElementById('bm-detail-star').addEventListener('click', async () => {
|
||||||
await toggleBookmark({ id: bm.node_id, kind: bm.kind, title: bm.title, level: bm.level });
|
await toggleBookmark({ id: bm.node_id, kind: bm.kind, title: bm.title, level: bm.level });
|
||||||
detail.classList.add('hidden');
|
hidePanel();
|
||||||
detailEmpty.classList.remove('hidden');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,13 +504,20 @@ function renderResults() {
|
|||||||
<div class="text-sm font-medium leading-tight truncate">${esc(r.title)}</div>
|
<div class="text-sm font-medium leading-tight truncate">${esc(r.title)}</div>
|
||||||
<div class="text-xs text-base-content/50 leading-tight truncate mt-0.5">${esc(r.description)}</div>
|
<div class="text-xs text-base-content/50 leading-tight truncate mt-0.5">${esc(r.description)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-star btn btn-ghost btn-xs btn-circle flex-shrink-0 mt-0.5 ${starred ? 'text-warning' : 'text-base-content/20 hover:text-warning'}"
|
<div class="flex items-center gap-0.5 flex-shrink-0 mt-0.5">
|
||||||
|
${r.kind === 'node' ? `<a href="${graphUrl(r.id)}" class="btn btn-ghost btn-xs text-base-content/20 hover:text-primary" title="View in graph">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12-3c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</a>` : ''}
|
||||||
|
<button class="btn-star btn btn-ghost btn-xs ${starred ? 'text-warning' : 'text-base-content/20 hover:text-warning'}"
|
||||||
data-idx="${i}" title="${starred ? 'Remove bookmark' : 'Bookmark'}">
|
data-idx="${i}" title="${starred ? 'Remove bookmark' : 'Bookmark'}">
|
||||||
<svg class="w-3.5 h-3.5" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>`;
|
</li>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@ -422,12 +554,11 @@ async function copyResultToClipboard(r, btn) {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDetail(idx) {
|
function _showResultContent(r, push) {
|
||||||
const r = results[idx];
|
|
||||||
const starred = isBookmarked(r);
|
const starred = isBookmarked(r);
|
||||||
detail.classList.remove('hidden');
|
showPanel();
|
||||||
detailEmpty.classList.add('hidden');
|
const canView = r.path && /\.(ncl|toml|rs|nu|md|json|html|yaml|yml)$/.test(r.path);
|
||||||
detail.innerHTML = `
|
dpContent.innerHTML = `
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h2 class="font-bold text-base leading-tight">${esc(r.title)}</h2>
|
<h2 class="font-bold text-base leading-tight">${esc(r.title)}</h2>
|
||||||
@ -438,14 +569,18 @@ function showDetail(idx) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 flex-shrink-0 mt-0.5">
|
<div class="flex items-center gap-1 flex-shrink-0 mt-0.5">
|
||||||
<button id="detail-copy" class="btn btn-ghost btn-xs btn-circle text-base-content/25 hover:text-base-content"
|
${r.kind === 'node' ? `<a href="${graphUrl(r.id)}" class="btn btn-ghost btn-xs text-base-content/25 hover:text-primary" title="View in graph">
|
||||||
title="Copy to clipboard">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12-3c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</a>` : ''}
|
||||||
|
<button id="detail-copy" class="btn btn-ghost btn-xs text-base-content/25 hover:text-base-content" title="Copy to clipboard">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3"/>
|
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button id="detail-star" class="btn btn-ghost btn-xs btn-circle ${starred ? 'text-warning' : 'text-base-content/25 hover:text-warning'}"
|
<button id="detail-star" class="btn btn-ghost btn-xs ${starred ? 'text-warning' : 'text-base-content/25 hover:text-warning'}"
|
||||||
title="${starred ? 'Remove bookmark' : 'Bookmark this'}">
|
title="${starred ? 'Remove bookmark' : 'Bookmark this'}">
|
||||||
<svg class="w-4 h-4" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
|
||||||
@ -453,7 +588,11 @@ function showDetail(idx) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs font-mono text-base-content/30 mb-4 truncate">${esc(r.path)}</p>
|
${r.path ? `<p class="text-xs font-mono text-base-content/30 mb-4 break-all">${
|
||||||
|
canView
|
||||||
|
? `<button class="s-file-link text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 cursor-pointer text-left" data-path="${esc(r.path)}">${esc(r.path)}</button>`
|
||||||
|
: esc(r.path)
|
||||||
|
}</p>` : ''}
|
||||||
<div class="space-y-1 text-sm">${r.detail_html}</div>
|
<div class="space-y-1 text-sm">${r.detail_html}</div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('detail-copy').addEventListener('click', async e => {
|
document.getElementById('detail-copy').addEventListener('click', async e => {
|
||||||
@ -461,17 +600,22 @@ function showDetail(idx) {
|
|||||||
});
|
});
|
||||||
document.getElementById('detail-star').addEventListener('click', async () => {
|
document.getElementById('detail-star').addEventListener('click', async () => {
|
||||||
await toggleBookmark(r);
|
await toggleBookmark(r);
|
||||||
showDetail(idx);
|
_showResultContent(r, false);
|
||||||
});
|
});
|
||||||
|
if (push) dpNav.push({ type: 'result', r });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(idx) {
|
||||||
|
const r = results[idx];
|
||||||
|
if (!r) return;
|
||||||
|
_showResultContent(r, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearResults() {
|
function clearResults() {
|
||||||
results = [];
|
results = [];
|
||||||
resultsList.innerHTML = '';
|
resultsList.innerHTML = '';
|
||||||
resultsCount.classList.add('hidden');
|
resultsCount.classList.add('hidden');
|
||||||
detail.classList.add('hidden');
|
hidePanel();
|
||||||
detail.innerHTML = '';
|
|
||||||
detailEmpty.classList.remove('hidden');
|
|
||||||
selectedItem = null;
|
selectedItem = null;
|
||||||
}
|
}
|
||||||
function reset() { input.value = ''; saveQuery(''); clearResults(); input.focus(); }
|
function reset() { input.value = ''; saveQuery(''); clearResults(); input.focus(); }
|
||||||
@ -502,7 +646,12 @@ document.addEventListener('mouseup', () => {
|
|||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function kindCls(kind) { return { node: 'badge-primary', adr: 'badge-secondary', mode: 'badge-accent' }[kind] || 'badge-neutral'; }
|
function kindCls(kind) { return { node: 'badge-primary', adr: 'badge-secondary', mode: 'badge-accent' }[kind] || 'badge-neutral'; }
|
||||||
function poleColor(p) { return { Yang: '#f59e0b', Yin: '#3b82f6', Spiral: '#8b5cf6' }[p] || '#6b7280'; }
|
function poleColor(p) { return { Yang: '#f59e0b', Yin: '#3b82f6', Spiral: '#8b5cf6', State: '#ec4899', Config: '#14b8a6', Env: '#f97316' }[p] || '#6b7280'; }
|
||||||
|
function graphUrl(id) {
|
||||||
|
const params = new URLSearchParams({ node: id });
|
||||||
|
if (SLUG) params.set('slug', SLUG);
|
||||||
|
return `/graph?${params}`;
|
||||||
|
}
|
||||||
function esc(s) { return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function esc(s) { return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────────────────────
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
{% import "macros/ui.html" as m %}
|
||||||
|
{% if items %}
|
||||||
|
<div class="overflow-x-auto rounded-lg border border-base-content/10">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs text-base-content/40 uppercase tracking-wider">
|
||||||
|
<th class="w-20">ID</th>
|
||||||
|
<th class="w-24">Status</th>
|
||||||
|
<th class="w-20">Priority</th>
|
||||||
|
<th class="w-16">Kind</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th class="w-24 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{% include "partials/backlog_tbody.html" %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-10 text-base-content/30 text-sm border border-base-content/10 rounded-lg">
|
||||||
|
No backlog items yet.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
118
crates/ontoref-daemon/templates/partials/backlog_tbody.html
Normal file
118
crates/ontoref-daemon/templates/partials/backlog_tbody.html
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<tbody id="backlog-tbody">
|
||||||
|
{% for it in items %}
|
||||||
|
<tr class="backlog-row hover:bg-base-200/50 cursor-pointer"
|
||||||
|
data-status="{{ it.status }}"
|
||||||
|
data-priority="{{ it.priority }}"
|
||||||
|
data-id="{{ it.id }}"
|
||||||
|
data-title="{{ it.title }}"
|
||||||
|
data-kind="{{ it.kind }}"
|
||||||
|
data-priority-val="{{ it.priority }}"
|
||||||
|
data-status-val="{{ it.status }}"
|
||||||
|
data-detail="{{ it.detail | default(value='') }}"
|
||||||
|
data-related-adrs="{{ it.related_adrs | default(value=[]) | join(sep=',') }}"
|
||||||
|
data-related-modes="{{ it.related_modes | default(value=[]) | join(sep=',') }}"
|
||||||
|
data-graduates-to="{{ it.graduates_to | default(value='') }}"
|
||||||
|
onclick="openEditModal(this)"
|
||||||
|
onmousedown="event.stopPropagation()">
|
||||||
|
<td class="font-mono text-xs text-base-content/50">{{ it.id }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-xs
|
||||||
|
{% if it.status == "Open" %}badge-info
|
||||||
|
{% elif it.status == "InProgress" %}badge-warning
|
||||||
|
{% elif it.status == "Done" %}badge-success
|
||||||
|
{% else %}badge-ghost{% endif %}">{{ it.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-xs
|
||||||
|
{% if it.priority == "Critical" %}badge-error
|
||||||
|
{% elif it.priority == "High" %}badge-warning
|
||||||
|
{% else %}badge-ghost{% endif %}">{{ it.priority }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-xs badge-ghost">{{ it.kind }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="font-medium text-sm leading-tight">{{ it.title }}</div>
|
||||||
|
{% if it.detail %}
|
||||||
|
<div class="text-xs text-base-content/50 leading-tight mt-0.5 line-clamp-1">{{ it.detail }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right" onclick="event.stopPropagation()">
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<button tabindex="0" class="btn btn-xs btn-ghost">▾</button>
|
||||||
|
<ul tabindex="0"
|
||||||
|
class="dropdown-content menu menu-xs bg-base-200 shadow rounded-box z-50 w-40 p-1">
|
||||||
|
{% if it.status != "InProgress" %}
|
||||||
|
<li>
|
||||||
|
<form hx-post="{{ base_url }}/backlog/status"
|
||||||
|
hx-target="#backlog-items-container" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="id" value="{{ it.id }}">
|
||||||
|
<input type="hidden" name="status" value="InProgress">
|
||||||
|
<button type="submit" class="w-full text-left">→ In Progress</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if it.status != "Done" %}
|
||||||
|
<li>
|
||||||
|
<form hx-post="{{ base_url }}/backlog/status"
|
||||||
|
hx-target="#backlog-items-container" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="id" value="{{ it.id }}">
|
||||||
|
<input type="hidden" name="status" value="Done">
|
||||||
|
<button type="submit" class="w-full text-left">✓ Done</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if it.status != "Open" %}
|
||||||
|
<li>
|
||||||
|
<form hx-post="{{ base_url }}/backlog/status"
|
||||||
|
hx-target="#backlog-items-container" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="id" value="{{ it.id }}">
|
||||||
|
<input type="hidden" name="status" value="Open">
|
||||||
|
<button type="submit" class="w-full text-left">↩ Reopen</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<form hx-post="{{ base_url }}/backlog/status"
|
||||||
|
hx-target="#backlog-items-container" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="id" value="{{ it.id }}">
|
||||||
|
<input type="hidden" name="status" value="Cancelled">
|
||||||
|
<button type="submit" class="w-full text-left text-error">✕ Cancel</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li class="border-t border-base-content/10 mt-1 pt-1">
|
||||||
|
{% if it.status != "InProgress" %}
|
||||||
|
<form hx-post="{{ base_url }}/backlog/propose-status"
|
||||||
|
hx-target="this" hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="id" value="{{ it.id }}">
|
||||||
|
<input type="hidden" name="proposed_status" value="InProgress">
|
||||||
|
<button type="submit" class="w-full text-left text-warning">⏳ Propose: In Progress</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if it.status != "Done" %}
|
||||||
|
<form hx-post="{{ base_url }}/backlog/propose-status"
|
||||||
|
hx-target="this" hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="id" value="{{ it.id }}">
|
||||||
|
<input type="hidden" name="proposed_status" value="Done">
|
||||||
|
<button type="submit" class="w-full text-left text-warning">⏳ Propose: Done</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
<li class="border-t border-base-content/10 mt-1 pt-1">
|
||||||
|
<form hx-post="{{ base_url }}/backlog/delete"
|
||||||
|
hx-target="#backlog-items-container" hx-swap="innerHTML"
|
||||||
|
hx-confirm="Delete {{ it.id }}? This cannot be undone.">
|
||||||
|
<input type="hidden" name="id" value="{{ it.id }}">
|
||||||
|
<button type="submit" class="w-full text-left text-error">✕ Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ base_url }}/notifications?kind=backlog_delegation&title={{ it.id | urlencode }}%3A%20{{ it.title | urlencode }}&payload=%7B%22item_id%22%3A%22{{ it.id | urlencode }}%22%2C%22status%22%3A%22{{ it.status | urlencode }}%22%2C%22priority%22%3A%22{{ it.priority | urlencode }}%22%7D"
|
||||||
|
class="w-full text-left">↗ Send to project</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
{% import "macros/ui.html" as m %}
|
||||||
|
<div class="stats stats-horizontal shadow w-full mb-4 bg-base-200 overflow-x-auto"
|
||||||
|
id="dash-stats"
|
||||||
|
hx-get="{{ base_url }}/stats"
|
||||||
|
hx-trigger="every 10s"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{{ m::stat(title="Uptime", value=uptime_secs ~ "s", desc="seconds since start") }}
|
||||||
|
{{ m::stat(title="Cache entries", value=cache_entries) }}
|
||||||
|
{{ m::stat(title="Cache hit rate", value=cache_hit_rate, desc=cache_hits ~ " hits / " ~ cache_misses ~ " misses", accent="success") }}
|
||||||
|
{{ m::stat(title="Sessions", value=active_actors, accent="primary") }}
|
||||||
|
{{ m::stat(title="Notifications", value=notification_count, accent="warning") }}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
<h2 class="text-sm font-semibold text-base-content/50 uppercase tracking-wider mb-3">Registered Projects</h2>
|
||||||
|
{% if projects %}
|
||||||
|
<div class="overflow-x-auto rounded-lg border border-base-content/10">
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs text-base-content/40 uppercase tracking-wider">
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Root</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>Auth</th>
|
||||||
|
<th>Persisted</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in projects %}
|
||||||
|
<tr class="hover:bg-base-200/50">
|
||||||
|
<td class="font-mono font-medium">
|
||||||
|
{% if not p.push_only %}
|
||||||
|
<a href="/ui/{{ p.slug }}/" class="link link-hover text-primary">{{ p.slug }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-base-content/60">{{ p.slug }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-xs text-base-content/60 max-w-xs truncate" title="{{ p.root }}">{{ p.root }}</td>
|
||||||
|
<td>
|
||||||
|
{% if p.opmode == "daemon" %}
|
||||||
|
<span class="badge badge-xs badge-success gap-1">
|
||||||
|
<span class="w-1 h-1 rounded-full bg-current inline-block"></span>daemon
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-xs badge-info">push</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if p.auth %}
|
||||||
|
<span class="badge badge-xs badge-warning" title="{{ p.roles | join(sep=', ') }}">
|
||||||
|
{{ p.key_count }} key{% if p.key_count != 1 %}s{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-xs badge-ghost">open</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if p.in_projects_ncl %}
|
||||||
|
<span class="badge badge-xs badge-ghost text-success">✓</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-xs badge-warning" title="In memory only — will be lost on restart">memory only</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<form hx-post="/ui/manage/remove"
|
||||||
|
hx-target="#manage-projects-section"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Remove project {{ p.slug }}?"
|
||||||
|
class="inline">
|
||||||
|
<input type="hidden" name="slug" value="{{ p.slug }}">
|
||||||
|
<button type="submit" class="btn btn-xs btn-error btn-outline">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-10 text-base-content/30 text-sm border border-base-content/10 rounded-lg">
|
||||||
|
No projects registered. Add one below.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if manage_error %}
|
||||||
|
<div id="manage-error" hx-swap-oob="true" class="alert alert-error mb-4 text-sm">
|
||||||
|
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ manage_error }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div id="manage-error" hx-swap-oob="true"></div>
|
||||||
|
{% endif %}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
{% if migrations and migrations | length > 0 %}
|
||||||
|
<span id="mig-badge-{{ slug }}" hx-swap-oob="true"
|
||||||
|
class="badge badge-sm badge-warning">
|
||||||
|
○ {{ migrations | length }} migration{% if migrations | length > 1 %}s{% endif %}
|
||||||
|
</span>
|
||||||
|
<details class="collapse collapse-arrow bg-warning/10 border border-warning/30 rounded-lg">
|
||||||
|
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer text-warning">
|
||||||
|
○ Pending migrations
|
||||||
|
</summary>
|
||||||
|
<div class="collapse-content px-3 pb-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
{% for m in migrations %}
|
||||||
|
<div class="flex gap-2 items-start">
|
||||||
|
<span class="badge badge-xs badge-warning font-mono flex-shrink-0 mt-0.5">{{ m.id }}</span>
|
||||||
|
<span class="text-xs text-base-content/70">{{ m.description }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/40 mt-2">Run <code class="font-mono">ontoref migrate show <id></code> for instructions.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
@ -236,6 +236,8 @@ fn emit_onto_api(attr: OntoApiAttr, item: proc_macro2::TokenStream) -> proc_macr
|
|||||||
params: &[#(#param_exprs),*],
|
params: &[#(#param_exprs),*],
|
||||||
tags: &[#(#tag_lits),*],
|
tags: &[#(#tag_lits),*],
|
||||||
feature: #feature,
|
feature: #feature,
|
||||||
|
// file!() expands at call site — the .rs file where #[onto_api] is placed.
|
||||||
|
source_file: file!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,18 +28,91 @@ pub struct ApiRouteEntry {
|
|||||||
pub tags: &'static [&'static str],
|
pub tags: &'static [&'static str],
|
||||||
/// Non-empty when the endpoint is only compiled under a feature flag.
|
/// Non-empty when the endpoint is only compiled under a feature flag.
|
||||||
pub feature: &'static str,
|
pub feature: &'static str,
|
||||||
|
/// Source file path (relative to workspace root) captured via `file!()`.
|
||||||
|
pub source_file: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
inventory::collect!(ApiRouteEntry);
|
inventory::collect!(ApiRouteEntry);
|
||||||
|
|
||||||
/// Serialize all statically-registered [`ApiRouteEntry`] items to a
|
/// Serialize all statically-registered [`ApiRouteEntry`] items to a
|
||||||
/// pretty-printed JSON array, sorted by path then method.
|
/// pretty-printed JSON array, sorted by path then method.
|
||||||
///
|
|
||||||
/// Intended for daemon binaries that expose a `--dump-api-catalog` flag: write
|
|
||||||
/// the output to `api-catalog.json` in the project root so the ontoref UI can
|
|
||||||
/// display the API surface of consumer projects that run as separate processes.
|
|
||||||
pub fn dump_catalog_json() -> String {
|
pub fn dump_catalog_json() -> String {
|
||||||
let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::<ApiRouteEntry>().collect();
|
let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::<ApiRouteEntry>().collect();
|
||||||
routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method)));
|
routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method)));
|
||||||
serde_json::to_string_pretty(&routes).unwrap_or_else(|_| "[]".to_string())
|
serde_json::to_string_pretty(&routes).unwrap_or_else(|_| "[]".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize all statically-registered [`ApiRouteEntry`] items to a Nickel
|
||||||
|
/// record that imports `../reflection/schemas/api-catalog.ncl`.
|
||||||
|
///
|
||||||
|
/// The output path is `artifacts/api-catalog-{crate_name}.ncl` relative to
|
||||||
|
/// the project root. Enum tags are capitalised to match the schema contracts:
|
||||||
|
/// `"GET"` → `'GET`, `"viewer"` → `'Viewer`, `"developer"` → `'Developer`.
|
||||||
|
pub fn dump_catalog_ncl(crate_name: &str) -> String {
|
||||||
|
let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::<ApiRouteEntry>().collect();
|
||||||
|
routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method)));
|
||||||
|
|
||||||
|
let routes_ncl = routes
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let actors = r
|
||||||
|
.actors
|
||||||
|
.iter()
|
||||||
|
.map(|a| format!("'{}", ncl_capitalize(a)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let tags = r
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.map(|t| format!("\"{}\"", ncl_escape(t)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let params = r
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
format!(
|
||||||
|
"{{ name = \"{}\", kind = \"{}\", constraint = \"{}\", description = \
|
||||||
|
\"{}\" }}",
|
||||||
|
ncl_escape(p.name),
|
||||||
|
ncl_escape(p.kind),
|
||||||
|
ncl_escape(p.constraint),
|
||||||
|
ncl_escape(p.description),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
format!(
|
||||||
|
" {{\n method = '{method},\n path = \"{path}\",\n description = \
|
||||||
|
\"{desc}\",\n auth = '{auth},\n actors = [{actors}],\n tags = \
|
||||||
|
[{tags}],\n params = [{params}],\n feature = \"{feature}\",\n \
|
||||||
|
source_file = \"{source_file}\",\n }}",
|
||||||
|
method = r.method,
|
||||||
|
path = ncl_escape(r.path),
|
||||||
|
desc = ncl_escape(r.description),
|
||||||
|
auth = ncl_capitalize(r.auth),
|
||||||
|
feature = ncl_escape(r.feature),
|
||||||
|
source_file = ncl_escape(r.source_file),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",\n");
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"let schema = import \"../reflection/schemas/api-catalog.ncl\" in\n{{\n crate = \
|
||||||
|
\"{crate_name}\",\n routes | Array schema.Route = [\n{routes_ncl}\n ],\n}} | \
|
||||||
|
schema.Catalog\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ncl_capitalize(s: &str) -> String {
|
||||||
|
let mut chars = s.chars();
|
||||||
|
match chars.next() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ncl_escape(s: &str) -> String {
|
||||||
|
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
|
}
|
||||||
|
|||||||
@ -6,20 +6,22 @@
|
|||||||
# Pattern guide: reflection/templates/vendor-frontend-assets-prompt.md
|
# Pattern guide: reflection/templates/vendor-frontend-assets-prompt.md
|
||||||
|
|
||||||
CYTOSCAPE_NAVIGATOR_VERSION := "2.0.1"
|
CYTOSCAPE_NAVIGATOR_VERSION := "2.0.1"
|
||||||
|
HTMX_VERSION := "2.0.7"
|
||||||
|
|
||||||
# Export this daemon's API catalog to api-catalog.json.
|
# Export this daemon's API catalog to api-catalog.json.
|
||||||
# Run after any #[onto_api] annotation is added or changed.
|
# Run after any #[onto_api] annotation is added or changed.
|
||||||
# The file is read by the ontoref UI when this project is registered as a
|
# The file is read by the ontoref UI when this project is registered as a
|
||||||
# non-primary slug — consumer projects that run as separate binaries use this
|
# non-primary slug — consumer projects that run as separate binaries use this
|
||||||
# to expose their API surface in the ontoref UI.
|
# to expose their API surface in the ontoref UI.
|
||||||
[doc("Export #[onto_api] routes to api-catalog.json")]
|
[doc("Export #[onto_api] routes to artifacts/api-catalog-ontoref-daemon.ncl")]
|
||||||
export-api-catalog:
|
export-api-catalog:
|
||||||
cargo run -p ontoref-daemon --no-default-features -- --dump-api-catalog > api-catalog.json
|
mkdir -p artifacts
|
||||||
@echo "exported $(cat api-catalog.json | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))') routes to api-catalog.json"
|
cargo run -p ontoref-daemon --no-default-features -- --dump-api-catalog > artifacts/api-catalog-ontoref-daemon.ncl
|
||||||
|
@echo "exported routes to artifacts/api-catalog-ontoref-daemon.ncl"
|
||||||
|
|
||||||
# Download/update all vendored frontend JS dependencies
|
# Download/update all vendored frontend JS dependencies
|
||||||
[doc("Vendor all frontend JS dependencies")]
|
[doc("Vendor all frontend JS dependencies")]
|
||||||
vendor-js: vendor-cytoscape-navigator
|
vendor-js: vendor-cytoscape-navigator vendor-htmx
|
||||||
|
|
||||||
# cytoscape-navigator — minimap extension for Cytoscape.js
|
# cytoscape-navigator — minimap extension for Cytoscape.js
|
||||||
[doc("Vendor cytoscape-navigator (minimap)")]
|
[doc("Vendor cytoscape-navigator (minimap)")]
|
||||||
@ -29,3 +31,12 @@ vendor-cytoscape-navigator:
|
|||||||
"https://cdn.jsdelivr.net/npm/cytoscape-navigator@{{CYTOSCAPE_NAVIGATOR_VERSION}}/cytoscape-navigator.js" \
|
"https://cdn.jsdelivr.net/npm/cytoscape-navigator@{{CYTOSCAPE_NAVIGATOR_VERSION}}/cytoscape-navigator.js" \
|
||||||
-o assets/vendor/cytoscape-navigator.js
|
-o assets/vendor/cytoscape-navigator.js
|
||||||
@echo "vendored cytoscape-navigator@{{CYTOSCAPE_NAVIGATOR_VERSION}}"
|
@echo "vendored cytoscape-navigator@{{CYTOSCAPE_NAVIGATOR_VERSION}}"
|
||||||
|
|
||||||
|
# htmx — HTML-first hypermedia library (goes to public/vendor — served via /public/)
|
||||||
|
[doc("Vendor htmx")]
|
||||||
|
vendor-htmx:
|
||||||
|
mkdir -p crates/ontoref-daemon/public/vendor
|
||||||
|
curl -fsSL \
|
||||||
|
"https://cdn.jsdelivr.net/npm/htmx.org@{{HTMX_VERSION}}/dist/htmx.min.js" \
|
||||||
|
-o crates/ontoref-daemon/public/vendor/htmx.min.js
|
||||||
|
@echo "vendored htmx@{{HTMX_VERSION}}"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
let s = import "backlog" in
|
let s = import "schemas/backlog.ncl" in
|
||||||
|
|
||||||
{
|
{
|
||||||
items = [
|
items = [
|
||||||
|
|||||||
@ -174,6 +174,8 @@ def "main help" [...args: string] {
|
|||||||
fmt-cmd $"($cmd) config-setup" "validate config.ncl schema and probe external services"
|
fmt-cmd $"($cmd) config-setup" "validate config.ncl schema and probe external services"
|
||||||
fmt-cmd $"($cmd) describe diff [--file]" "semantic diff of ontology vs HEAD (nodes/edges added/removed/changed)"
|
fmt-cmd $"($cmd) describe diff [--file]" "semantic diff of ontology vs HEAD (nodes/edges added/removed/changed)"
|
||||||
fmt-cmd $"($cmd) describe api [--actor] [--tag]" "annotated API surface grouped by tag (requires daemon)"
|
fmt-cmd $"($cmd) describe api [--actor] [--tag]" "annotated API surface grouped by tag (requires daemon)"
|
||||||
|
fmt-cmd $"($cmd) describe state [id]" "FSM dimensions — current/desired state + transitions"
|
||||||
|
fmt-cmd $"($cmd) describe workspace" "workspace crates + intra-workspace dependency graph"
|
||||||
fmt-cmd $"($cmd) run update_ontoref" "bring project up to current protocol version (adds manifest.ncl, connections.ncl)"
|
fmt-cmd $"($cmd) run update_ontoref" "bring project up to current protocol version (adds manifest.ncl, connections.ncl)"
|
||||||
print ""
|
print ""
|
||||||
|
|
||||||
@ -184,7 +186,7 @@ def "main help" [...args: string] {
|
|||||||
print $" (ansi cyan)mf(ansi reset) → manifest (ansi cyan)dg(ansi reset) → diagram (ansi cyan)md(ansi reset) → mode (ansi cyan)st(ansi reset) → status"
|
print $" (ansi cyan)mf(ansi reset) → manifest (ansi cyan)dg(ansi reset) → diagram (ansi cyan)md(ansi reset) → mode (ansi cyan)st(ansi reset) → status"
|
||||||
print $" (ansi cyan)fm(ansi reset) → form (ansi cyan)s(ansi reset) → search (ansi cyan)ru(ansi reset) → run \(mode\) (ansi cyan)sv(ansi reset) → services"
|
print $" (ansi cyan)fm(ansi reset) → form (ansi cyan)s(ansi reset) → search (ansi cyan)ru(ansi reset) → run \(mode\) (ansi cyan)sv(ansi reset) → services"
|
||||||
print $" (ansi cyan)nv(ansi reset) → nats (ansi cyan)q(ansi reset) → qa query (ansi cyan)f(ansi reset) → search \(alias\) (ansi cyan)df(ansi reset) → describe diff"
|
print $" (ansi cyan)nv(ansi reset) → nats (ansi cyan)q(ansi reset) → qa query (ansi cyan)f(ansi reset) → search \(alias\) (ansi cyan)df(ansi reset) → describe diff"
|
||||||
print $" (ansi cyan)da(ansi reset) → describe api"
|
print $" (ansi cyan)da(ansi reset) → describe api (ansi cyan)dst(ansi reset) → describe state (ansi cyan)dws(ansi reset) → describe workspace"
|
||||||
print ""
|
print ""
|
||||||
print $" (ansi dark_gray)Tip: any group accepts(ansi reset) (ansi cyan)h(ansi reset) (ansi dark_gray)for help,(ansi reset) (ansi cyan)?(ansi reset) (ansi dark_gray)for interactive selector, or bare for picker(ansi reset)"
|
print $" (ansi dark_gray)Tip: any group accepts(ansi reset) (ansi cyan)h(ansi reset) (ansi dark_gray)for help,(ansi reset) (ansi cyan)?(ansi reset) (ansi dark_gray)for interactive selector, or bare for picker(ansi reset)"
|
||||||
print $" (ansi dark_gray)Any command:(ansi reset) (ansi cyan)--fmt|-f(ansi reset) (ansi dark_gray)text*|json|yaml|toml|md(ansi reset) · (ansi cyan)--clip(ansi reset) (ansi dark_gray)copy output to clipboard(ansi reset)"
|
print $" (ansi dark_gray)Any command:(ansi reset) (ansi cyan)--fmt|-f(ansi reset) (ansi dark_gray)text*|json|yaml|toml|md(ansi reset) · (ansi cyan)--clip(ansi reset) (ansi dark_gray)copy output to clipboard(ansi reset)"
|
||||||
@ -564,6 +566,22 @@ def "main describe api" [--actor: string = "", --tag: string = "", --auth: strin
|
|||||||
describe api --actor $actor --tag $tag --auth $auth --fmt $f
|
describe api --actor $actor --tag $tag --auth $auth --fmt $f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def "main describe state" [id?: string, --fmt (-f): string = "", --actor: string = ""] {
|
||||||
|
log-action "describe state" "read"
|
||||||
|
let f = (resolve-fmt $fmt [text json])
|
||||||
|
if ($id | is-empty) or ($id == "") {
|
||||||
|
describe state --fmt $f --actor $actor
|
||||||
|
} else {
|
||||||
|
describe state $id --fmt $f --actor $actor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def "main describe workspace" [--fmt (-f): string = "", --actor: string = ""] {
|
||||||
|
log-action "describe workspace" "read"
|
||||||
|
let f = (resolve-fmt $fmt [text json])
|
||||||
|
describe workspace --fmt $f --actor $actor
|
||||||
|
}
|
||||||
|
|
||||||
# ── Diagram ───────────────────────────────────────────────────────────────────
|
# ── Diagram ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def "main diagram" [] {
|
def "main diagram" [] {
|
||||||
@ -720,9 +738,15 @@ def "main d extensions" [--fmt (-f): string = "", --actor: string = "", --dump:
|
|||||||
def "main d ext" [--fmt (-f): string = "", --actor: string = "", --dump: string = "", --clip] { main describe extensions --fmt $fmt --actor $actor --dump $dump --clip=$clip }
|
def "main d ext" [--fmt (-f): string = "", --actor: string = "", --dump: string = "", --clip] { main describe extensions --fmt $fmt --actor $actor --dump $dump --clip=$clip }
|
||||||
def "main d diff" [--fmt (-f): string = "", --file: string = ""] { main describe diff --fmt $fmt --file $file }
|
def "main d diff" [--fmt (-f): string = "", --file: string = ""] { main describe diff --fmt $fmt --file $file }
|
||||||
def "main d api" [--actor: string = "", --tag: string = "", --auth: string = "", --fmt (-f): string = ""] { main describe api --actor $actor --tag $tag --auth $auth --fmt $fmt }
|
def "main d api" [--actor: string = "", --tag: string = "", --auth: string = "", --fmt (-f): string = ""] { main describe api --actor $actor --tag $tag --auth $auth --fmt $fmt }
|
||||||
|
def "main d state" [id?: string, --fmt (-f): string = "", --actor: string = ""] { main describe state $id --fmt $fmt --actor $actor }
|
||||||
|
def "main d st" [id?: string, --fmt (-f): string = "", --actor: string = ""] { main describe state $id --fmt $fmt --actor $actor }
|
||||||
|
def "main d workspace" [--fmt (-f): string = "", --actor: string = ""] { main describe workspace --fmt $fmt --actor $actor }
|
||||||
|
def "main d ws" [--fmt (-f): string = "", --actor: string = ""] { main describe workspace --fmt $fmt --actor $actor }
|
||||||
|
|
||||||
def "main df" [--fmt (-f): string = "", --file: string = ""] { main describe diff --fmt $fmt --file $file }
|
def "main df" [--fmt (-f): string = "", --file: string = ""] { main describe diff --fmt $fmt --file $file }
|
||||||
def "main da" [--actor: string = "", --tag: string = "", --auth: string = "", --fmt (-f): string = ""] { main describe api --actor $actor --tag $tag --auth $auth --fmt $fmt }
|
def "main da" [--actor: string = "", --tag: string = "", --auth: string = "", --fmt (-f): string = ""] { main describe api --actor $actor --tag $tag --auth $auth --fmt $fmt }
|
||||||
|
def "main dst" [id?: string, --fmt (-f): string = "", --actor: string = ""] { main describe state $id --fmt $fmt --actor $actor }
|
||||||
|
def "main dws" [--fmt (-f): string = "", --actor: string = ""] { main describe workspace --fmt $fmt --actor $actor }
|
||||||
|
|
||||||
def "main bkl" [action?: string] { main backlog $action }
|
def "main bkl" [action?: string] { main backlog $action }
|
||||||
def "main bkl help" [] { help-group "backlog" }
|
def "main bkl help" [] { help-group "backlog" }
|
||||||
|
|||||||
30
reflection/migrations/0007-card-repo-field.ncl
Normal file
30
reflection/migrations/0007-card-repo-field.ncl
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
id = "0007",
|
||||||
|
slug = "card-repo-field",
|
||||||
|
description = "Add repo field to card.ncl for UI file-path navigation to the project repository",
|
||||||
|
check = {
|
||||||
|
tag = "NuCmd",
|
||||||
|
cmd = "let f = $\"($env.ONTOREF_PROJECT_ROOT)/card.ncl\"; if not ($f | path exists) { exit 0 }; let r = (do { ^rg -q 'repo\\s*=' $f } | complete); if $r.exit_code == 0 { exit 0 } else { exit 1 }",
|
||||||
|
expect_exit = 0,
|
||||||
|
},
|
||||||
|
instructions = "
|
||||||
|
Open card.ncl and add a repo field pointing to the project source repository.
|
||||||
|
The field must be a full URL (Gitea, GitHub, Sourcehut, etc.).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
repo = \"https://repo.example.com/owner/project\",
|
||||||
|
|
||||||
|
The daemon injects this value as card_repo into every Tera template.
|
||||||
|
srcOpen() in graph, search, and api_catalog pages uses it to build the URL:
|
||||||
|
{repo}/src/branch/main/{path}
|
||||||
|
|
||||||
|
If the project also publishes cargo docs, add:
|
||||||
|
docs = \"https://docs.example.com/project\",
|
||||||
|
|
||||||
|
That value is injected as card_docs. For .rs file paths, srcOpen() opens
|
||||||
|
docs instead of the source file (cargo doc output is more useful than raw source).
|
||||||
|
|
||||||
|
Verify card.ncl still exports cleanly:
|
||||||
|
nickel export --import-path \"$NICKEL_IMPORT_PATH\" card.ncl > /dev/null && echo ok
|
||||||
|
",
|
||||||
|
}
|
||||||
@ -163,6 +163,82 @@ export def "backlog roadmap" [] {
|
|||||||
print ""
|
print ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Propose a status change for a backlog item (requires admin approval).
|
||||||
|
# Sends a backlog_review notification to the daemon. An admin approves or
|
||||||
|
# rejects it via the UI notifications page or via `backlog approve`.
|
||||||
|
export def "backlog propose-status" [
|
||||||
|
id: string, # Item id (e.g. bl-001)
|
||||||
|
status: string, # Proposed status: Open | InProgress | Done | Cancelled
|
||||||
|
--by: string = "", # Actor label shown in the notification
|
||||||
|
--slug: string = "", # Project slug (defaults to primary)
|
||||||
|
] {
|
||||||
|
if not (daemon-available) {
|
||||||
|
print " error: daemon unavailable — cannot propose status"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let url = $"(daemon-url)/backlog/propose-status"
|
||||||
|
let body = {
|
||||||
|
id: $id,
|
||||||
|
proposed_status: $status,
|
||||||
|
proposed_by: $by,
|
||||||
|
slug: (if $slug == "" { null } else { $slug }),
|
||||||
|
} | to json
|
||||||
|
let auth = (bearer-args)
|
||||||
|
let r = do { ^curl -sf -X POST -H "Content-Type: application/json" ...$auth -d $body $url } | complete
|
||||||
|
if $r.exit_code != 0 {
|
||||||
|
print $" error: propose-status failed\n ($r.stderr)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let resp = ($r.stdout | from json)
|
||||||
|
print $" ⏳ proposed ($resp.item_id) → ($resp.proposed_status) — awaiting admin approval"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Approve a pending backlog_review notification by notification id.
|
||||||
|
# Equivalent to clicking "Approve" in the UI notifications page.
|
||||||
|
export def "backlog approve" [
|
||||||
|
notif_id: int, # Notification id (from `backlog pending` or the UI)
|
||||||
|
--slug: string = "", # Project slug (defaults to primary)
|
||||||
|
] {
|
||||||
|
if not (daemon-available) {
|
||||||
|
print " error: daemon unavailable"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let s = if $slug == "" { (daemon-url | str replace "//" "//x" | split row "/" | get 0) } else { $slug }
|
||||||
|
# Resolve slug from config if not provided
|
||||||
|
let root = ($env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT)
|
||||||
|
let resolved_slug = if $slug == "" {
|
||||||
|
let cfg_path = $"($root)/.ontoref/config.ncl"
|
||||||
|
if ($cfg_path | path exists) {
|
||||||
|
do { ^nickel export $cfg_path } | complete
|
||||||
|
| get stdout | from json | get slug? | default "ontoref"
|
||||||
|
} else { "ontoref" }
|
||||||
|
} else { $slug }
|
||||||
|
|
||||||
|
let url = $"(daemon-url | str replace --regex '/$' '')/ui/($resolved_slug)/notifications/($notif_id)/action"
|
||||||
|
let auth = (bearer-args)
|
||||||
|
let r = do { ^curl -sf -X POST -H "Content-Type: application/x-www-form-urlencoded" ...$auth -d "action_id=approve" $url } | complete
|
||||||
|
if $r.exit_code != 0 {
|
||||||
|
print $" error: approve failed\n ($r.stderr)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
print $" ✓ notification ($notif_id) approved"
|
||||||
|
}
|
||||||
|
|
||||||
|
# List pending backlog_review notifications awaiting approval.
|
||||||
|
export def "backlog pending" [
|
||||||
|
--slug: string = "",
|
||||||
|
] {
|
||||||
|
if not (daemon-available) { return [] }
|
||||||
|
let url = $"(daemon-url)/notifications/pending"
|
||||||
|
let auth = (bearer-args)
|
||||||
|
let r = do { ^curl -sf ...$auth $url } | complete
|
||||||
|
if $r.exit_code != 0 { return [] }
|
||||||
|
let all = ($r.stdout | from json | get notifications? | default [])
|
||||||
|
$all | where { |n| ($n.custom_kind? | default "") == "backlog_review" }
|
||||||
|
| select id custom_title timestamp? source_actor?
|
||||||
|
| rename id title timestamp actor
|
||||||
|
}
|
||||||
|
|
||||||
# ── Internal ────────────────────────────────────────────────────────────────────
|
# ── Internal ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def backlog-root []: nothing -> string {
|
def backlog-root []: nothing -> string {
|
||||||
|
|||||||
@ -1427,6 +1427,171 @@ def render-api-text [data: record]: nothing -> nothing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── describe state ──────────────────────────────────────────────────────────────
|
||||||
|
# "What FSM dimensions exist and where are they currently?"
|
||||||
|
# Reads .ontology/state.ncl and prints each dimension with current/desired state,
|
||||||
|
# horizon, and whether the desired state has been reached.
|
||||||
|
|
||||||
|
export def "describe state" [
|
||||||
|
id?: string, # Dimension id to detail with transitions (omit for list)
|
||||||
|
--fmt: string = "", # Output format: text* | json
|
||||||
|
--actor: string = "",
|
||||||
|
]: nothing -> nothing {
|
||||||
|
let root = (project-root)
|
||||||
|
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||||||
|
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||||||
|
|
||||||
|
let dims = (collect-dimensions $root)
|
||||||
|
if ($dims | is-empty) {
|
||||||
|
print " No .ontology/state.ncl found or no dimensions declared."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id | is-empty) or ($id == "") {
|
||||||
|
let data = { dimensions: $dims }
|
||||||
|
emit-output $data $f { ||
|
||||||
|
print $"(ansi white_bold)FSM Dimensions(ansi reset) ($dims | length) total"
|
||||||
|
print ""
|
||||||
|
for d in $dims {
|
||||||
|
let reached = $d.reached? | default false
|
||||||
|
let status = if $reached {
|
||||||
|
$"(ansi green)✓ reached(ansi reset)"
|
||||||
|
} else {
|
||||||
|
$"(ansi yellow)→ in progress(ansi reset)"
|
||||||
|
}
|
||||||
|
print $" (ansi white_bold)($d.id)(ansi reset) ($status)"
|
||||||
|
print $" (ansi cyan)($d.name)(ansi reset) horizon: ($d.horizon)"
|
||||||
|
print $" current: (ansi white)($d.current_state)(ansi reset) desired: (ansi dark_gray)($d.desired_state)(ansi reset)"
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let ip = (nickel-import-path $root)
|
||||||
|
let state = (daemon-export-safe $"($root)/.ontology/state.ncl" --import-path $ip)
|
||||||
|
if $state == null {
|
||||||
|
print " Failed to export .ontology/state.ncl"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let target = ($state.dimensions? | default [] | where id == $id)
|
||||||
|
if ($target | is-empty) {
|
||||||
|
let avail = ($dims | get id | str join ", ")
|
||||||
|
print $" Dimension '($id)' not found. Available: ($avail)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let dim = ($target | first)
|
||||||
|
let transitions = ($dim.transitions? | default [])
|
||||||
|
let data = {
|
||||||
|
id: $dim.id,
|
||||||
|
name: $dim.name,
|
||||||
|
description: ($dim.description? | default ""),
|
||||||
|
current_state: $dim.current_state,
|
||||||
|
desired_state: $dim.desired_state,
|
||||||
|
horizon: ($dim.horizon? | default ""),
|
||||||
|
reached: ($dim.current_state == $dim.desired_state),
|
||||||
|
coupled_with: ($dim.coupled_with? | default []),
|
||||||
|
transitions: ($transitions | each { |t| {
|
||||||
|
from: $t.from,
|
||||||
|
to: $t.to,
|
||||||
|
condition: ($t.condition? | default ""),
|
||||||
|
catalyst: ($t.catalyst? | default ""),
|
||||||
|
blocker: ($t.blocker? | default ""),
|
||||||
|
}}),
|
||||||
|
}
|
||||||
|
emit-output $data $f { ||
|
||||||
|
let reached = $data.reached
|
||||||
|
print $"(ansi white_bold)($data.id)(ansi reset)"
|
||||||
|
print $" (ansi cyan)($data.name)(ansi reset) horizon: ($data.horizon)"
|
||||||
|
print $" ($data.description)"
|
||||||
|
print ""
|
||||||
|
print $" current : (ansi white_bold)($data.current_state)(ansi reset)"
|
||||||
|
let status_badge = if $reached { $"(ansi green)✓ reached(ansi reset)" } else { $"(ansi yellow)in progress(ansi reset)" }
|
||||||
|
print $" desired : ($data.desired_state) ($status_badge)"
|
||||||
|
if ($data.coupled_with | is-not-empty) {
|
||||||
|
print $" coupled : ($data.coupled_with | str join ', ')"
|
||||||
|
}
|
||||||
|
if ($data.transitions | is-not-empty) {
|
||||||
|
print ""
|
||||||
|
print $" (ansi white_bold)Transitions(ansi reset)"
|
||||||
|
for t in $data.transitions {
|
||||||
|
let marker = if $t.from == $data.current_state { $"(ansi green)▶(ansi reset)" } else { " " }
|
||||||
|
print $" ($marker) ($t.from) → ($t.to)"
|
||||||
|
if ($t.condition | is-not-empty) { print $" condition : ($t.condition)" }
|
||||||
|
if ($t.catalyst | is-not-empty) { print $" catalyst : ($t.catalyst)" }
|
||||||
|
if ($t.blocker | is-not-empty) and ($t.blocker != "none") {
|
||||||
|
print $" (ansi yellow)blocker : ($t.blocker)(ansi reset)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── describe workspace ───────────────────────────────────────────────────────────
|
||||||
|
# "What crates are in this workspace and how do they depend on each other?"
|
||||||
|
# Reads workspace Cargo.toml + member manifests. Shows only intra-workspace deps —
|
||||||
|
# external crate dependencies are omitted to keep the output focused.
|
||||||
|
|
||||||
|
export def "describe workspace" [
|
||||||
|
--fmt: string = "", # Output format: text* | json
|
||||||
|
--actor: string = "",
|
||||||
|
]: nothing -> nothing {
|
||||||
|
let root = (project-root)
|
||||||
|
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||||||
|
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||||||
|
|
||||||
|
let ws_toml = $"($root)/Cargo.toml"
|
||||||
|
if not ($ws_toml | path exists) {
|
||||||
|
print " No Cargo.toml found at project root — not a Rust workspace."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws = (open $ws_toml)
|
||||||
|
let member_globs = ($ws | get -o workspace.members | default [])
|
||||||
|
if ($member_globs | is-empty) {
|
||||||
|
print " No workspace.members declared in Cargo.toml."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect all member crate names + features.
|
||||||
|
mut crates = []
|
||||||
|
for mg in $member_globs {
|
||||||
|
let expanded = (glob $"($root)/($mg)/Cargo.toml")
|
||||||
|
for ct in $expanded {
|
||||||
|
let c = (open $ct)
|
||||||
|
let name = ($c | get -o package.name | default ($ct | path dirname | path basename))
|
||||||
|
let features = ($c | get -o features | default {} | columns)
|
||||||
|
let all_deps = ($c | get -o dependencies | default {})
|
||||||
|
$crates = ($crates | append [{ name: $name, features: $features, all_deps: $all_deps, path: ($ct | path dirname | path relative-to $root) }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let crate_names = ($crates | get name)
|
||||||
|
|
||||||
|
# Build intra-workspace dep edges.
|
||||||
|
let crates_with_ws_deps = ($crates | each { |cr|
|
||||||
|
let dep_names = (try { $cr.all_deps | columns } catch { [] })
|
||||||
|
let ws_deps = ($dep_names | where { |d| $d in $crate_names })
|
||||||
|
{ name: $cr.name, features: $cr.features, path: $cr.path, depends_on: $ws_deps }
|
||||||
|
})
|
||||||
|
|
||||||
|
let data = { crates: $crates_with_ws_deps }
|
||||||
|
|
||||||
|
emit-output $data $f { ||
|
||||||
|
print $"(ansi white_bold)Workspace Crates(ansi reset) ($crates_with_ws_deps | length) members"
|
||||||
|
print ""
|
||||||
|
for cr in $crates_with_ws_deps {
|
||||||
|
print $" (ansi white_bold)($cr.name)(ansi reset) (ansi dark_gray)($cr.path)(ansi reset)"
|
||||||
|
if ($cr.features | is-not-empty) {
|
||||||
|
print $" features : ($cr.features | str join ', ')"
|
||||||
|
}
|
||||||
|
if ($cr.depends_on | is-not-empty) {
|
||||||
|
print $" deps : ($cr.depends_on | each { |d| $'(ansi cyan)($d)(ansi reset)' } | str join ', ')"
|
||||||
|
}
|
||||||
|
print ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# ── describe diff ───────────────────────────────────────────────────────────────
|
# ── describe diff ───────────────────────────────────────────────────────────────
|
||||||
# "What changed in the ontology since the last commit?"
|
# "What changed in the ontology since the last commit?"
|
||||||
# Compares the current working-tree core.ncl against the HEAD-committed version.
|
# Compares the current working-tree core.ncl against the HEAD-committed version.
|
||||||
@ -1848,6 +2013,7 @@ def scan-ontoref-commands []: nothing -> list<string> {
|
|||||||
"describe project", "describe capabilities", "describe constraints",
|
"describe project", "describe capabilities", "describe constraints",
|
||||||
"describe tools", "describe features", "describe impact", "describe why",
|
"describe tools", "describe features", "describe impact", "describe why",
|
||||||
"describe guides", "describe diff", "describe api",
|
"describe guides", "describe diff", "describe api",
|
||||||
|
"describe state", "describe workspace",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
let s = import "qa" in
|
let s = import "schemas/qa.ncl" in
|
||||||
|
|
||||||
{
|
{
|
||||||
entries = [],
|
entries = [],
|
||||||
|
|||||||
36
reflection/schemas/api-catalog.ncl
Normal file
36
reflection/schemas/api-catalog.ncl
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
let method_type = [| 'GET, 'POST, 'PUT, 'DELETE, 'PATCH |] in
|
||||||
|
let auth_type = [| 'None, 'Viewer, 'Bearer, 'Admin |] in
|
||||||
|
let actor_type = [| 'Developer, 'Agent, 'Ci, 'Admin |] in
|
||||||
|
|
||||||
|
let param_type = {
|
||||||
|
name | String,
|
||||||
|
kind | String,
|
||||||
|
constraint | String | default = "optional",
|
||||||
|
description | String | default = "",
|
||||||
|
} in
|
||||||
|
|
||||||
|
let route_type = {
|
||||||
|
method | method_type,
|
||||||
|
path | String,
|
||||||
|
description | String | default = "",
|
||||||
|
auth | auth_type | default = 'None,
|
||||||
|
actors | Array actor_type | default = [],
|
||||||
|
params | Array param_type | default = [],
|
||||||
|
tags | Array String | default = [],
|
||||||
|
feature | String | default = "",
|
||||||
|
source_file | String | default = "",
|
||||||
|
} in
|
||||||
|
|
||||||
|
let catalog_type = {
|
||||||
|
crate | String,
|
||||||
|
routes | Array route_type,
|
||||||
|
} in
|
||||||
|
|
||||||
|
{
|
||||||
|
Method = method_type,
|
||||||
|
Auth = auth_type,
|
||||||
|
Actor = actor_type,
|
||||||
|
Param = param_type,
|
||||||
|
Route = route_type,
|
||||||
|
Catalog = catalog_type,
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user