1060 lines
53 KiB
HTML
1060 lines
53 KiB
HTML
|
|
<!doctype html>
|
||
|
|
<html lang="en" data-theme="dark">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>{% block title %}{{ project_name }}{% endblock title %} — Provisioning</title>
|
||
|
|
<!-- Apply saved theme + nav-mode before paint to avoid flash -->
|
||
|
|
<script>
|
||
|
|
(function() {
|
||
|
|
var t = localStorage.getItem("prov-theme");
|
||
|
|
if (t) document.documentElement.setAttribute("data-theme", t);
|
||
|
|
var m = localStorage.getItem("prov-nav-mode");
|
||
|
|
if (m === "icons") document.documentElement.classList.add("nav-icons");
|
||
|
|
else if (m === "names") document.documentElement.classList.add("nav-names");
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
<link href="/public/css/provisioning.css" rel="stylesheet">
|
||
|
|
<style>
|
||
|
|
.badge-xs { height: 1rem; }
|
||
|
|
.badge-success, .badge-info, .badge-error, .badge-warning { color: #ffffff; }
|
||
|
|
html.nav-icons .nav-label { display: none !important; }
|
||
|
|
html.nav-icons .dropdown-content .nav-label { display: inline !important; }
|
||
|
|
html.nav-names .nav-icon { display: none !important; }
|
||
|
|
@media (max-width: 767px) {
|
||
|
|
.ws-nav .nav-label, .ws-subnav .nav-label { display: none !important; }
|
||
|
|
.ws-nav .dropdown-content .nav-label,
|
||
|
|
.ws-subnav .dropdown-content .nav-label { display: inline !important; }
|
||
|
|
}
|
||
|
|
.btn svg.w-3, .btn svg.h-3 { width: 0.75rem; height: 0.75rem; }
|
||
|
|
.btn svg.w-3\.5, .btn svg.h-3\.5 { width: 0.875rem; height: 0.875rem; }
|
||
|
|
.btn svg.w-4, .btn svg.h-4 { width: 1rem; height: 1rem; }
|
||
|
|
.btn svg.w-5, .btn svg.h-5 { width: 1.25rem; height: 1.25rem; }
|
||
|
|
</style>
|
||
|
|
<script src="/public/vendor/htmx.min.js"></script>
|
||
|
|
{% block head %}{% endblock head %}
|
||
|
|
</head>
|
||
|
|
<body class="min-h-screen bg-base-100 text-base-content">
|
||
|
|
|
||
|
|
<div class="navbar bg-base-200 shadow-lg px-4">
|
||
|
|
|
||
|
|
<!-- ── Mobile: burger + brand ─────────────────────────────────────────── -->
|
||
|
|
<div class="navbar-start md:hidden">
|
||
|
|
<div class="dropdown">
|
||
|
|
<button tabindex="0" class="btn btn-ghost btn-sm" aria-label="Menu">
|
||
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-200 shadow-lg rounded-box z-50 w-56 mt-2 p-2 gap-0.5">
|
||
|
|
<li><a href="/ui/" class="gap-1.5">Dashboard</a></li>
|
||
|
|
{% if ws_name %}
|
||
|
|
<li>
|
||
|
|
<details>
|
||
|
|
<summary class="gap-1.5 font-medium font-mono">{{ ws_name }}</summary>
|
||
|
|
<ul>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}">Overview</a></li>
|
||
|
|
{% if ws_envs %}
|
||
|
|
<li class="disabled"><span class="text-[10px] opacity-40 uppercase tracking-wider px-1">Infrastructure</span></li>
|
||
|
|
{% for env in ws_envs %}
|
||
|
|
<li>
|
||
|
|
<details>
|
||
|
|
<summary class="font-mono text-xs">{{ env }}</summary>
|
||
|
|
<ul>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/servers?env={{ env }}">Servers</a></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/components?env={{ env }}">Components</a></li>
|
||
|
|
</ul>
|
||
|
|
</details>
|
||
|
|
</li>
|
||
|
|
{% endfor %}
|
||
|
|
{% else %}
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/servers">Servers</a></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/components">Components</a></li>
|
||
|
|
{% endif %}
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/dag">DAG</a></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/clusters">Clusters</a></li>
|
||
|
|
<li class="divider my-0.5"></li>
|
||
|
|
<li class="disabled"><span class="text-[10px] opacity-40 uppercase tracking-wider px-1">Reflect</span></li>
|
||
|
|
<li><a href="/ui/modes">Modes</a></li>
|
||
|
|
<li><a href="/ui/jobs">Track / Jobs</a></li>
|
||
|
|
<li><a href="/ui/ontology">Knowledge</a></li>
|
||
|
|
<li class="divider my-0.5"></li>
|
||
|
|
<li><a href="/ui/workspaces">← All Workspaces</a></li>
|
||
|
|
</ul>
|
||
|
|
</details>
|
||
|
|
</li>
|
||
|
|
{% else %}
|
||
|
|
<li><a href="/ui/workspaces" class="gap-1.5">Workspace</a></li>
|
||
|
|
{% endif %}
|
||
|
|
<li><a href="/ui/jobs" class="gap-1.5">Jobs</a></li>
|
||
|
|
<li><a href="/ui/tools" class="gap-1.5">Tools</a></li>
|
||
|
|
<li><a href="/ui/modes" class="gap-1.5">Modes</a></li>
|
||
|
|
<li><a href="/ui/catalog" class="gap-1.5">Catalog</a></li>
|
||
|
|
<li><a href="/ui/ontology" class="gap-1.5">Ontology</a></li>
|
||
|
|
<li class="divider my-0.5"></li>
|
||
|
|
<li>
|
||
|
|
<a href="/ui/logout" class="gap-1.5"
|
||
|
|
onclick="localStorage.removeItem('prov-last-workspace'); localStorage.removeItem('prov-auth');">Logout</a>
|
||
|
|
</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
<!-- Mobile brand -->
|
||
|
|
<a href="/ui/" class="btn btn-ghost p-1 ml-1">
|
||
|
|
{% if logo %}
|
||
|
|
<img id="proj-logo-m" src="{{ logo }}" alt="{{ project_name }}"
|
||
|
|
class="h-10 max-w-[8rem] object-contain object-left">
|
||
|
|
{% else %}
|
||
|
|
<span class="font-bold font-mono text-primary text-base tracking-tight">⚙ {{ project_name }}</span>
|
||
|
|
{% endif %}
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- ── Desktop: brand ─────────────────────────────────────────────────── -->
|
||
|
|
<div class="navbar-start hidden md:flex items-center gap-2">
|
||
|
|
{% block nav_brand %}
|
||
|
|
<a href="/ui/" class="btn btn-ghost p-1">
|
||
|
|
{% if logo or logo_dark %}
|
||
|
|
{% if logo %}<img id="proj-logo-light" src="{{ logo }}" alt="{{ project_name }}"
|
||
|
|
class="h-10 max-w-[9rem] object-contain object-left">{% endif %}
|
||
|
|
{% if logo_dark %}<img id="proj-logo-dark" src="{{ logo_dark }}" alt="{{ project_name }}"
|
||
|
|
class="h-10 max-w-[9rem] object-contain object-left">{% endif %}
|
||
|
|
{% else %}
|
||
|
|
<span class="font-bold font-mono text-primary text-lg tracking-tight">⚙ {{ project_name }}</span>
|
||
|
|
{% endif %}
|
||
|
|
</a>
|
||
|
|
{% endblock nav_brand %}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- ── Desktop: nav links ─────────────────────────────────────────────── -->
|
||
|
|
<div class="navbar-center hidden md:flex">
|
||
|
|
<div class="flex items-center gap-0.5 text-sm">
|
||
|
|
|
||
|
|
<!-- Dashboard -->
|
||
|
|
<a href="/ui/" class="btn btn-ghost btn-sm gap-1.5 {% block nav_dashboard %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||
|
|
</svg>
|
||
|
|
<span class="nav-label">Dashboard</span>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<!-- Workspace — dropdown when ws_name is set, plain link otherwise -->
|
||
|
|
<div class="dropdown dropdown-hover">
|
||
|
|
<a tabindex="0"
|
||
|
|
href="{% if ws_name %}/ui/workspaces/{{ ws_name }}{% else %}/ui/workspaces{% endif %}"
|
||
|
|
class="btn btn-ghost btn-sm gap-1.5 {% block nav_workspaces %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||
|
|
</svg>
|
||
|
|
<span class="nav-label">{% if ws_name %}{{ ws_name }}{% else %}Workspace{% endif %}</span>
|
||
|
|
{% if ws_name %}
|
||
|
|
<svg class="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||
|
|
</svg>
|
||
|
|
{% endif %}
|
||
|
|
</a>
|
||
|
|
{% if ws_name %}
|
||
|
|
<ul tabindex="0" class="dropdown-content menu bg-base-200 shadow-lg rounded-box z-50 w-60 p-2 mt-1 max-h-[85vh] overflow-y-auto">
|
||
|
|
|
||
|
|
<!-- Overview -->
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}" class="gap-1.5 {% block nav_ws_overview %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||
|
|
</svg>
|
||
|
|
Overview
|
||
|
|
</a></li>
|
||
|
|
|
||
|
|
{% if ws_envs %}
|
||
|
|
<!-- ── Per-environment infrastructure ────────────────────────────── -->
|
||
|
|
<li class="divider my-0.5"></li>
|
||
|
|
<li class="disabled">
|
||
|
|
<span class="text-[10px] opacity-40 uppercase tracking-wider font-semibold px-1">Infrastructure</span>
|
||
|
|
</li>
|
||
|
|
{% for env in ws_envs %}
|
||
|
|
<li>
|
||
|
|
<details>
|
||
|
|
<summary class="gap-1.5 font-mono text-xs py-1.5 text-primary/80">
|
||
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0 text-primary/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/>
|
||
|
|
</svg>
|
||
|
|
{{ env }}
|
||
|
|
</summary>
|
||
|
|
<ul>
|
||
|
|
<!-- Infrastructure resources -->
|
||
|
|
<li class="menu-title py-0.5"><span class="text-[9px] opacity-50 uppercase tracking-wider">Resources</span></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/servers?env={{ env }}" class="gap-1.5 text-xs">
|
||
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
|
||
|
|
</svg>
|
||
|
|
Servers
|
||
|
|
</a></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/components?env={{ env }}" class="gap-1.5 text-xs">
|
||
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"/>
|
||
|
|
</svg>
|
||
|
|
Components
|
||
|
|
</a></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/dag?env={{ env }}" class="gap-1.5 text-xs">
|
||
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||
|
|
</svg>
|
||
|
|
DAG
|
||
|
|
</a></li>
|
||
|
|
<!-- Reflect subsection per env -->
|
||
|
|
<li class="menu-title py-0.5 mt-1"><span class="text-[9px] opacity-50 uppercase tracking-wider">Reflect</span></li>
|
||
|
|
<li><a href="/ui/modes" class="gap-1.5 text-xs">
|
||
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
|
|
</svg>
|
||
|
|
Modes
|
||
|
|
</a></li>
|
||
|
|
<li><a href="/ui/jobs" class="gap-1.5 text-xs">
|
||
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<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 2"/>
|
||
|
|
</svg>
|
||
|
|
Track
|
||
|
|
</a></li>
|
||
|
|
<li>
|
||
|
|
<details>
|
||
|
|
<summary class="gap-1.5 text-xs py-1">
|
||
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||
|
|
</svg>
|
||
|
|
Knowledge
|
||
|
|
</summary>
|
||
|
|
<ul>
|
||
|
|
<li><a href="/ui/ontology" class="text-xs">Ontology</a></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/dag?env={{ env }}" class="text-xs">DAG View</a></li>
|
||
|
|
</ul>
|
||
|
|
</details>
|
||
|
|
</li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}" class="gap-1.5 text-xs">
|
||
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||
|
|
</svg>
|
||
|
|
Domain
|
||
|
|
</a></li>
|
||
|
|
</ul>
|
||
|
|
</details>
|
||
|
|
</li>
|
||
|
|
{% endfor %}
|
||
|
|
|
||
|
|
{% else %}
|
||
|
|
<!-- ── Flat nav when no environments declared ─────────────────────── -->
|
||
|
|
<li class="divider my-0.5"></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/servers" class="gap-1.5 {% block nav_ws_servers %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
|
||
|
|
</svg>
|
||
|
|
Servers
|
||
|
|
</a></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/components" class="gap-1.5 {% block nav_ws_components %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"/>
|
||
|
|
</svg>
|
||
|
|
Components
|
||
|
|
</a></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/dag" class="gap-1.5 {% block nav_ws_dag %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||
|
|
</svg>
|
||
|
|
DAG
|
||
|
|
</a></li>
|
||
|
|
<li class="divider my-0.5"></li>
|
||
|
|
<li class="disabled">
|
||
|
|
<span class="text-[10px] opacity-40 uppercase tracking-wider font-semibold px-1">Reflect</span>
|
||
|
|
</li>
|
||
|
|
<li><a href="/ui/modes" class="gap-1.5">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
|
|
</svg>
|
||
|
|
Modes
|
||
|
|
</a></li>
|
||
|
|
<li><a href="/ui/jobs" class="gap-1.5">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<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 2"/>
|
||
|
|
</svg>
|
||
|
|
Track
|
||
|
|
</a></li>
|
||
|
|
<li>
|
||
|
|
<details>
|
||
|
|
<summary class="gap-1.5 text-xs py-1.5">
|
||
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||
|
|
</svg>
|
||
|
|
Knowledge
|
||
|
|
</summary>
|
||
|
|
<ul>
|
||
|
|
<li><a href="/ui/ontology">Ontology</a></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/dag">DAG View</a></li>
|
||
|
|
</ul>
|
||
|
|
</details>
|
||
|
|
</li>
|
||
|
|
{% endif %}
|
||
|
|
|
||
|
|
<!-- ── Workspace-level ────────────────────────────────────────────── -->
|
||
|
|
<li class="divider my-0.5"></li>
|
||
|
|
<li><a href="/ui/workspaces/{{ ws_name }}/clusters" class="gap-1.5 {% block nav_ws_clusters %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
|
|
</svg>
|
||
|
|
Clusters
|
||
|
|
</a></li>
|
||
|
|
<li><a href="/ui/workspaces" class="gap-1.5 text-base-content/50 text-xs">← All Workspaces</a></li>
|
||
|
|
</ul>
|
||
|
|
{% endif %}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Jobs -->
|
||
|
|
<a href="/ui/jobs" class="btn btn-ghost btn-sm gap-1.5 {% block nav_jobs %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<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 2"/>
|
||
|
|
</svg>
|
||
|
|
<span class="nav-label">Jobs</span>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<!-- Tools -->
|
||
|
|
<a href="/ui/tools" class="btn btn-ghost btn-sm gap-1.5 {% block nav_tools %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||
|
|
</svg>
|
||
|
|
<span class="nav-label">Tools</span>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<!-- Modes -->
|
||
|
|
<a href="/ui/modes" class="btn btn-ghost btn-sm gap-1.5 {% block nav_modes %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
|
|
</svg>
|
||
|
|
<span class="nav-label">Modes</span>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<!-- Catalog -->
|
||
|
|
<a href="/ui/catalog" class="btn btn-ghost btn-sm gap-1.5 {% block nav_catalog %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"/>
|
||
|
|
</svg>
|
||
|
|
<span class="nav-label">Catalog</span>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<!-- Ontology -->
|
||
|
|
<a href="/ui/ontology" class="btn btn-ghost btn-sm gap-1.5 {% block nav_ontology %}{% endblock %}">
|
||
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
|
||
|
|
</svg>
|
||
|
|
<span class="nav-label">Ontology</span>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- ── Right side ─────────────────────────────────────────────────────── -->
|
||
|
|
<div class="navbar-end gap-1">
|
||
|
|
|
||
|
|
<!-- Status badges -->
|
||
|
|
{% if nats_enabled %}
|
||
|
|
<span class="badge badge-success badge-xs">NATS</span>
|
||
|
|
{% else %}
|
||
|
|
<span class="badge badge-ghost badge-xs opacity-40">NATS</span>
|
||
|
|
{% endif %}
|
||
|
|
{% if jwt_enabled %}
|
||
|
|
<span class="badge badge-info badge-xs">JWT</span>
|
||
|
|
{% else %}
|
||
|
|
<span class="badge badge-warning badge-xs">solo</span>
|
||
|
|
{% endif %}
|
||
|
|
|
||
|
|
<!-- MCP dropdown -->
|
||
|
|
<div class="dropdown dropdown-end hidden sm:block">
|
||
|
|
<button tabindex="0" class="btn btn-xs btn-ghost gap-1" title="MCP Tools">
|
||
|
|
<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 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2v-4M9 21H5a2 2 0 01-2-2v-4m0 0h18"/>
|
||
|
|
</svg>
|
||
|
|
<span class="text-xs font-mono">MCP</span>
|
||
|
|
<span class="inline-block w-1.5 h-1.5 rounded-full bg-success"></span>
|
||
|
|
</button>
|
||
|
|
<div tabindex="0"
|
||
|
|
class="dropdown-content bg-base-200 border border-base-content/10 rounded-box z-50 shadow-lg w-64 p-3 mt-1">
|
||
|
|
<p class="text-xs font-semibold text-base-content/60 mb-2 uppercase tracking-wide">MCP Tools</p>
|
||
|
|
<ul class="space-y-0.5 max-h-80 overflow-y-auto">
|
||
|
|
{% for tool in tool_names %}
|
||
|
|
<li class="text-xs font-mono text-base-content/80 py-0.5 px-1 rounded hover:bg-base-300">
|
||
|
|
{{ tool }}
|
||
|
|
</li>
|
||
|
|
{% endfor %}
|
||
|
|
</ul>
|
||
|
|
<div class="mt-2 pt-2 border-t border-base-content/10 text-[11px] text-base-content/40 space-y-0.5">
|
||
|
|
<div>HTTP: <code class="font-mono">/mcp</code></div>
|
||
|
|
<div>stdio: <code class="font-mono">--mcp-stdio</code></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Gear → Workspaces -->
|
||
|
|
<a href="/ui/workspaces" class="btn btn-xs btn-ghost hidden sm:inline-flex" title="Manage workspaces">
|
||
|
|
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||
|
|
</svg>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<!-- Daemon loopback / IP dropdown -->
|
||
|
|
<div class="dropdown dropdown-end hidden sm:block" id="loopback-wrap">
|
||
|
|
<button tabindex="0" id="loopback-btn"
|
||
|
|
class="btn btn-xs btn-ghost font-mono gap-1 text-base-content/50"
|
||
|
|
title="Daemon status">
|
||
|
|
<span id="loopback-dot" class="inline-block w-1.5 h-1.5 rounded-full bg-base-content/20"></span>
|
||
|
|
<span id="loopback-host">loopback</span>
|
||
|
|
</button>
|
||
|
|
<div tabindex="0"
|
||
|
|
class="dropdown-content bg-base-200 border border-base-content/10 rounded-box z-50 shadow-lg w-64 p-3 mt-1 text-xs">
|
||
|
|
<div class="flex items-center justify-between mb-2">
|
||
|
|
<span class="font-semibold text-base-content/60 uppercase tracking-wide">Daemon</span>
|
||
|
|
<button onclick="daemonPing()" class="btn btn-xs btn-ghost py-0 h-5 min-h-0 text-base-content/40">
|
||
|
|
↺ ping
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div id="loopback-details" class="space-y-1 text-base-content/70">
|
||
|
|
<div class="text-base-content/30 italic">loading…</div>
|
||
|
|
</div>
|
||
|
|
<div class="mt-2 pt-2 border-t border-base-content/10 flex justify-between items-center">
|
||
|
|
<code class="font-mono text-[11px] text-base-content/40" id="loopback-addr"></code>
|
||
|
|
<button onclick="navigator.clipboard.writeText(document.getElementById('loopback-addr').textContent)"
|
||
|
|
class="btn btn-xs btn-ghost py-0 h-5 min-h-0 text-base-content/30"
|
||
|
|
title="Copy address">⎘</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Nav display mode toggle -->
|
||
|
|
<button id="nav-mode-btn" class="btn btn-ghost btn-xs hidden sm:inline-flex"
|
||
|
|
title="Toggle nav display (icons+labels / icons / labels)"
|
||
|
|
aria-label="Toggle nav display">
|
||
|
|
<svg id="nav-mode-icon" 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="M4 6h16M4 12h16M4 18h7"/>
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<!-- Identity + role badge -->
|
||
|
|
{% if perm_sub %}
|
||
|
|
<div class="hidden sm:flex items-center gap-1">
|
||
|
|
<span class="font-mono text-[11px] text-base-content/30">{{ perm_sub }}</span>
|
||
|
|
{% if can_admin %}
|
||
|
|
<span class="badge badge-warning badge-xs font-mono">admin</span>
|
||
|
|
{% elif can_operate %}
|
||
|
|
<span class="badge badge-info badge-xs font-mono">op</span>
|
||
|
|
{% else %}
|
||
|
|
<span class="badge badge-ghost badge-xs font-mono">viewer</span>
|
||
|
|
{% endif %}
|
||
|
|
</div>
|
||
|
|
{% endif %}
|
||
|
|
|
||
|
|
<!-- Logout -->
|
||
|
|
<a href="/ui/logout"
|
||
|
|
class="btn btn-ghost btn-xs hidden sm:inline-flex opacity-40"
|
||
|
|
title="Sign out"
|
||
|
|
onclick="localStorage.removeItem('prov-last-workspace'); localStorage.removeItem('prov-auth');">
|
||
|
|
<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="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
|
|
</svg>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<!-- Theme toggle -->
|
||
|
|
<button id="theme-toggle" class="btn btn-ghost btn-xs" title="Toggle theme" aria-label="Toggle theme">
|
||
|
|
<svg id="icon-sun" class="w-4 h-4 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z"/>
|
||
|
|
</svg>
|
||
|
|
<svg id="icon-moon" class="w-4 h-4 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/>
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{% if ws_name %}
|
||
|
|
<div id="breadcrumb-bar" class="bg-base-200/50 border-b border-base-300 px-6 py-1.5 text-sm flex items-center gap-1.5">
|
||
|
|
<a href="/ui/" class="text-base-content/40 hover:text-base-content font-mono text-xs">⚙ {{ project_name }}</a>
|
||
|
|
<span class="text-base-content/20">/</span>
|
||
|
|
<a href="/ui/workspaces" class="text-base-content/50 hover:text-base-content text-xs">workspaces</a>
|
||
|
|
<span class="text-base-content/20">/</span>
|
||
|
|
{% if current_env and current_env != ws_name %}
|
||
|
|
<a href="/ui/workspaces/{{ ws_name }}" class="font-mono text-primary/70 hover:text-primary text-xs">{{ ws_name }}</a>
|
||
|
|
<span class="text-base-content/20">/</span>
|
||
|
|
<span class="font-mono text-primary/70 text-xs">{{ current_env }}</span>
|
||
|
|
{% else %}
|
||
|
|
<a href="/ui/workspaces/{{ ws_name }}" class="font-mono text-primary/70 hover:text-primary text-xs">{{ ws_name }}</a>
|
||
|
|
{% endif %}
|
||
|
|
{% block breadcrumb_extra %}{% endblock %}
|
||
|
|
</div>
|
||
|
|
{% endif %}
|
||
|
|
|
||
|
|
<main id="spa-main" class="{% block main_class %}container mx-auto px-4 py-6 max-w-7xl{% endblock main_class %}">
|
||
|
|
{% block content %}{% endblock content %}
|
||
|
|
{% block scripts %}{% endblock scripts %}
|
||
|
|
</main>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
function liveBtnClick(btn) {
|
||
|
|
var spin = btn.querySelector('.live-spin');
|
||
|
|
var icon = btn.querySelector('.live-icon');
|
||
|
|
var lbl = btn.querySelector('.live-label');
|
||
|
|
if (spin) spin.classList.remove('hidden');
|
||
|
|
if (icon) icon.style.display = 'none';
|
||
|
|
if (lbl) lbl.style.display = 'none';
|
||
|
|
btn.disabled = true;
|
||
|
|
var url = (btn.getAttribute('data-live-url') || '').trim();
|
||
|
|
if (!url || url === '?') url = location.pathname + location.search;
|
||
|
|
if (window._spaNavigate) {
|
||
|
|
window._spaNavigate(url, false).finally(function() {
|
||
|
|
if (spin) spin.classList.add('hidden');
|
||
|
|
if (icon) icon.style.display = '';
|
||
|
|
if (lbl) lbl.style.display = '';
|
||
|
|
btn.disabled = false;
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
location.href = url;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
(function() {
|
||
|
|
var html = document.documentElement;
|
||
|
|
|
||
|
|
// ── Theme ──────────────────────────────────────────────────────────────
|
||
|
|
var DARK = "dark", LIGHT = "light", THEME_KEY = "prov-theme";
|
||
|
|
var btn = document.getElementById("theme-toggle");
|
||
|
|
var iconSun = document.getElementById("icon-sun");
|
||
|
|
var iconMoon = document.getElementById("icon-moon");
|
||
|
|
|
||
|
|
var logoLight = document.getElementById("proj-logo-light");
|
||
|
|
var logoDark = document.getElementById("proj-logo-dark");
|
||
|
|
|
||
|
|
function applyLogos(theme) {
|
||
|
|
if (logoLight && !logoDark) { logoLight.style.display = ""; return; }
|
||
|
|
if (logoLight) logoLight.style.display = (theme === DARK) ? "none" : "";
|
||
|
|
if (logoDark) logoDark.style.display = (theme === DARK) ? "" : "none";
|
||
|
|
}
|
||
|
|
|
||
|
|
function currentTheme() { return html.getAttribute("data-theme") === DARK ? DARK : LIGHT; }
|
||
|
|
|
||
|
|
function applyTheme(t) {
|
||
|
|
html.setAttribute("data-theme", t);
|
||
|
|
localStorage.setItem(THEME_KEY, t);
|
||
|
|
if (t === DARK) { iconSun.classList.remove("hidden"); iconMoon.classList.add("hidden"); }
|
||
|
|
else { iconMoon.classList.remove("hidden"); iconSun.classList.add("hidden"); }
|
||
|
|
applyLogos(t);
|
||
|
|
}
|
||
|
|
applyTheme(currentTheme());
|
||
|
|
if (btn) btn.addEventListener("click", function() {
|
||
|
|
applyTheme(currentTheme() === DARK ? LIGHT : DARK);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Nav display mode ───────────────────────────────────────────────────
|
||
|
|
var NAV_KEY = "prov-nav-mode";
|
||
|
|
var NAV_MODES = ["both", "icons", "names"];
|
||
|
|
var navBtn = document.getElementById("nav-mode-btn");
|
||
|
|
var navIcon = document.getElementById("nav-mode-icon");
|
||
|
|
|
||
|
|
var NAV_PATHS = {
|
||
|
|
both: "M4 6h16M4 12h16M4 18h7",
|
||
|
|
icons: "M4 6h4M4 12h4M4 18h4M10 6h10M10 12h10M10 18h10",
|
||
|
|
names: "M4 6h16M4 12h16M4 18h16"
|
||
|
|
};
|
||
|
|
|
||
|
|
function currentNavMode() { return localStorage.getItem(NAV_KEY) || "both"; }
|
||
|
|
|
||
|
|
function applyNavMode(mode) {
|
||
|
|
html.classList.remove("nav-icons", "nav-names");
|
||
|
|
if (mode === "icons") html.classList.add("nav-icons");
|
||
|
|
if (mode === "names") html.classList.add("nav-names");
|
||
|
|
localStorage.setItem(NAV_KEY, mode);
|
||
|
|
if (navIcon) {
|
||
|
|
var p = navIcon.querySelector("path");
|
||
|
|
if (p) p.setAttribute("d", NAV_PATHS[mode] || NAV_PATHS.both);
|
||
|
|
}
|
||
|
|
if (navBtn) navBtn.title = "Nav: " + mode + " — click to change";
|
||
|
|
}
|
||
|
|
|
||
|
|
applyNavMode(currentNavMode());
|
||
|
|
if (navBtn) navBtn.addEventListener("click", function() {
|
||
|
|
var cur = currentNavMode();
|
||
|
|
var next = NAV_MODES[(NAV_MODES.indexOf(cur) + 1) % NAV_MODES.length];
|
||
|
|
applyNavMode(next);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Last workspace ─────────────────────────────────────────────────────
|
||
|
|
{% if ws_name %}
|
||
|
|
try { localStorage.setItem("prov-last-workspace", "{{ ws_name }}"); } catch (_) {}
|
||
|
|
{% endif %}
|
||
|
|
|
||
|
|
})();
|
||
|
|
|
||
|
|
// ── Daemon loopback status ─────────────────────────────────────────────
|
||
|
|
(function() {
|
||
|
|
var dot = document.getElementById("loopback-dot");
|
||
|
|
var host = document.getElementById("loopback-host");
|
||
|
|
var details = document.getElementById("loopback-details");
|
||
|
|
var addr = document.getElementById("loopback-addr");
|
||
|
|
|
||
|
|
if (!dot) return;
|
||
|
|
|
||
|
|
if (host) host.textContent = window.location.host;
|
||
|
|
if (addr) addr.textContent = window.location.origin;
|
||
|
|
|
||
|
|
function fmtUptime(secs) {
|
||
|
|
if (secs < 60) return secs + "s";
|
||
|
|
if (secs < 3600) return Math.floor(secs / 60) + "m " + (secs % 60) + "s";
|
||
|
|
var h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60);
|
||
|
|
return h + "h " + m + "m";
|
||
|
|
}
|
||
|
|
|
||
|
|
function row(label, value) {
|
||
|
|
return '<div class="flex justify-between">' +
|
||
|
|
'<span class="text-base-content/40">' + label + '</span>' +
|
||
|
|
'<span class="font-mono">' + value + '</span></div>';
|
||
|
|
}
|
||
|
|
|
||
|
|
function ping() {
|
||
|
|
fetch("/health")
|
||
|
|
.then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||
|
|
.then(function(d) {
|
||
|
|
if (dot) {
|
||
|
|
dot.className = "inline-block w-1.5 h-1.5 rounded-full " +
|
||
|
|
(d.status === "ok" ? "bg-success" : "bg-warning");
|
||
|
|
}
|
||
|
|
if (details) {
|
||
|
|
details.innerHTML =
|
||
|
|
row("tools", d.tools || 0) +
|
||
|
|
row("invocations", d.invocations || 0);
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.catch(function() {
|
||
|
|
if (dot) dot.className = "inline-block w-1.5 h-1.5 rounded-full bg-error";
|
||
|
|
if (details) details.innerHTML = '<div class="text-error/70 italic">unreachable</div>';
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
window.daemonPing = ping;
|
||
|
|
ping();
|
||
|
|
setInterval(ping, 30000);
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<!-- ── Floating panel dock (minimized sessions) ─────────────────────────── -->
|
||
|
|
<div id="fp-dock" style="position:fixed;bottom:0;left:0;display:flex;gap:4px;padding:6px 8px;z-index:299;pointer-events:none;flex-wrap:wrap;max-width:60vw;"></div>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
/* FloatPanels — manages floating terminal and content panels across pages */
|
||
|
|
const FloatPanels = (function() {
|
||
|
|
let _z = 200;
|
||
|
|
const _s = {};
|
||
|
|
|
||
|
|
function _nextZ() { return ++_z; }
|
||
|
|
|
||
|
|
function _dock() { return document.getElementById('fp-dock'); }
|
||
|
|
|
||
|
|
function _create(key, title, bodyHtml, onMount) {
|
||
|
|
const n = Object.keys(_s).length;
|
||
|
|
const right = 28 + n * 26;
|
||
|
|
const bottom = 28 + n * 26;
|
||
|
|
const el = document.createElement('div');
|
||
|
|
el.id = `fp-${key}`;
|
||
|
|
el.style.cssText = [
|
||
|
|
'position:fixed', `z-index:${_nextZ()}`,
|
||
|
|
'width:680px', 'height:420px',
|
||
|
|
`right:${right}px`, `bottom:${bottom}px`,
|
||
|
|
'display:flex', 'flex-direction:column',
|
||
|
|
'background:#0d1117',
|
||
|
|
'border:1px solid rgba(255,255,255,0.12)',
|
||
|
|
'border-radius:8px',
|
||
|
|
'box-shadow:0 20px 60px rgba(0,0,0,0.7)',
|
||
|
|
'overflow:hidden',
|
||
|
|
].join(';');
|
||
|
|
el.innerHTML = `
|
||
|
|
<div class="fp-hdr" style="display:flex;align-items:center;gap:6px;padding:5px 10px;background:var(--b2,#1d232a);border-bottom:1px solid rgba(255,255,255,0.07);cursor:move;user-select:none;flex-shrink:0;height:32px;">
|
||
|
|
<svg style="width:13px;height:13px;color:rgba(255,255,255,0.3);flex-shrink:0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||
|
|
<span class="fp-ttl" style="font-family:monospace;font-size:12px;color:rgba(255,255,255,0.55);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${title}</span>
|
||
|
|
<button data-fp-action="min" data-fp-key="${key}" style="padding:1px 7px;font-family:monospace;font-size:13px;background:none;border:none;color:rgba(255,255,255,0.3);cursor:pointer;line-height:1;" title="Minimize">—</button>
|
||
|
|
<button data-fp-action="cls" data-fp-key="${key}" style="padding:1px 7px;font-family:monospace;font-size:13px;background:none;border:none;color:rgba(255,255,255,0.3);cursor:pointer;line-height:1;" title="Close">✕</button>
|
||
|
|
</div>
|
||
|
|
<div class="fp-body" style="flex:1;min-height:0;position:relative;overflow:hidden;">${bodyHtml}</div>
|
||
|
|
<div class="fp-rsz" style="position:absolute;right:0;bottom:0;width:18px;height:18px;cursor:se-resize;background:linear-gradient(135deg,transparent 50%,rgba(255,255,255,0.1) 50%);border-radius:0 0 8px 0;"></div>`;
|
||
|
|
document.body.appendChild(el);
|
||
|
|
_s[key] = { el, minimized: false, title };
|
||
|
|
|
||
|
|
el.querySelector('[data-fp-action="min"]').addEventListener('click', () => _minimize(key));
|
||
|
|
el.querySelector('[data-fp-action="cls"]').addEventListener('click', () => _close(key));
|
||
|
|
el.addEventListener('mousedown', () => { el.style.zIndex = _nextZ(); });
|
||
|
|
|
||
|
|
_makeDraggable(key);
|
||
|
|
_makeResizable(key);
|
||
|
|
if (onMount) onMount(el);
|
||
|
|
return el;
|
||
|
|
}
|
||
|
|
|
||
|
|
function _minimize(key) {
|
||
|
|
const s = _s[key];
|
||
|
|
if (!s || s.minimized) return;
|
||
|
|
s.el.style.display = 'none';
|
||
|
|
s.minimized = true;
|
||
|
|
if (s._x && s._x.tailSrc) { s._x.tailSrc.close(); s._x.tailSrc = null; }
|
||
|
|
const d = _dock();
|
||
|
|
const btn = document.createElement('button');
|
||
|
|
btn.id = `fp-db-${key}`;
|
||
|
|
btn.style.cssText = 'pointer-events:auto;padding:3px 9px;font-family:monospace;font-size:11px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.15);border-radius:4px;color:rgba(255,255,255,0.5);cursor:pointer;white-space:nowrap;';
|
||
|
|
btn.textContent = s.title.length > 24 ? s.title.slice(0, 24) + '…' : s.title;
|
||
|
|
btn.addEventListener('click', () => _restore(key));
|
||
|
|
d.appendChild(btn);
|
||
|
|
}
|
||
|
|
|
||
|
|
function _restore(key) {
|
||
|
|
const s = _s[key];
|
||
|
|
if (!s) return;
|
||
|
|
s.el.style.display = 'flex';
|
||
|
|
s.minimized = false;
|
||
|
|
s.el.style.zIndex = _nextZ();
|
||
|
|
document.getElementById(`fp-db-${key}`)?.remove();
|
||
|
|
if (s._x && s._x.fit) requestAnimationFrame(() => s._x.fit.fit());
|
||
|
|
}
|
||
|
|
|
||
|
|
function _close(key) {
|
||
|
|
const s = _s[key];
|
||
|
|
if (!s) return;
|
||
|
|
if (s._x) {
|
||
|
|
if (s._x.ro) s._x.ro.disconnect();
|
||
|
|
if (s._x.ws) s._x.ws.close();
|
||
|
|
if (s._x.term) s._x.term.dispose();
|
||
|
|
if (s._x.tailSrc) s._x.tailSrc.close();
|
||
|
|
}
|
||
|
|
s.el.remove();
|
||
|
|
document.getElementById(`fp-db-${key}`)?.remove();
|
||
|
|
delete _s[key];
|
||
|
|
}
|
||
|
|
|
||
|
|
function _makeDraggable(key) {
|
||
|
|
const s = _s[key]; let drag = null;
|
||
|
|
s.el.querySelector('.fp-hdr').addEventListener('mousedown', e => {
|
||
|
|
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return;
|
||
|
|
drag = { sx: e.clientX, sy: e.clientY, l: s.el.offsetLeft, t: s.el.offsetTop };
|
||
|
|
s.el.style.zIndex = _nextZ(); e.preventDefault();
|
||
|
|
});
|
||
|
|
document.addEventListener('mousemove', e => {
|
||
|
|
if (!drag) return;
|
||
|
|
s.el.style.left = Math.max(0, drag.l + e.clientX - drag.sx) + 'px';
|
||
|
|
s.el.style.top = Math.max(0, drag.t + e.clientY - drag.sy) + 'px';
|
||
|
|
s.el.style.right = 'auto';
|
||
|
|
s.el.style.bottom = 'auto';
|
||
|
|
});
|
||
|
|
document.addEventListener('mouseup', () => { drag = null; });
|
||
|
|
}
|
||
|
|
|
||
|
|
function _makeResizable(key) {
|
||
|
|
const s = _s[key]; let rsz = null;
|
||
|
|
s.el.querySelector('.fp-rsz').addEventListener('mousedown', e => {
|
||
|
|
rsz = { sx: e.clientX, sy: e.clientY, w: s.el.offsetWidth, h: s.el.offsetHeight };
|
||
|
|
e.preventDefault(); e.stopPropagation();
|
||
|
|
});
|
||
|
|
document.addEventListener('mousemove', e => {
|
||
|
|
if (!rsz) return;
|
||
|
|
s.el.style.width = Math.max(380, rsz.w + e.clientX - rsz.sx) + 'px';
|
||
|
|
s.el.style.height = Math.max(220, rsz.h + e.clientY - rsz.sy) + 'px';
|
||
|
|
if (s._x && s._x.fit) s._x.fit.fit();
|
||
|
|
});
|
||
|
|
document.addEventListener('mouseup', () => { rsz = null; });
|
||
|
|
}
|
||
|
|
|
||
|
|
function _loadXterm(cb) {
|
||
|
|
if (typeof Terminal !== 'undefined' && typeof FitAddon !== 'undefined') { cb(); return; }
|
||
|
|
if (document.getElementById('fp-xterm-css')) {
|
||
|
|
// scripts are loading — poll briefly
|
||
|
|
let tries = 0;
|
||
|
|
const t = setInterval(() => {
|
||
|
|
if (typeof Terminal !== 'undefined' && typeof FitAddon !== 'undefined') { clearInterval(t); cb(); }
|
||
|
|
if (++tries > 60) clearInterval(t);
|
||
|
|
}, 100);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const css = document.createElement('link');
|
||
|
|
css.id = 'fp-xterm-css'; css.rel = 'stylesheet';
|
||
|
|
css.href = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.css';
|
||
|
|
document.head.appendChild(css);
|
||
|
|
const s1 = document.createElement('script');
|
||
|
|
s1.src = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5/lib/xterm.js';
|
||
|
|
s1.onload = () => {
|
||
|
|
const s2 = document.createElement('script');
|
||
|
|
s2.src = 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10/lib/addon-fit.js';
|
||
|
|
s2.onload = cb;
|
||
|
|
document.head.appendChild(s2);
|
||
|
|
};
|
||
|
|
document.head.appendChild(s1);
|
||
|
|
}
|
||
|
|
|
||
|
|
function openTerminal(key, title, wsUrl) {
|
||
|
|
if (_s[key]) { _restore(key); return; }
|
||
|
|
const body = '<div class="fp-xterm" style="width:100%;height:100%;"></div>';
|
||
|
|
_create(key, title, body, el => {
|
||
|
|
_loadXterm(() => {
|
||
|
|
const xterm = el.querySelector('.fp-xterm');
|
||
|
|
const term = new Terminal({
|
||
|
|
cursorBlink: true, fontSize: 13,
|
||
|
|
fontFamily: '"Cascadia Code","Fira Code","JetBrains Mono",monospace',
|
||
|
|
theme: { background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff' },
|
||
|
|
scrollback: 5000,
|
||
|
|
});
|
||
|
|
const fit = new FitAddon.FitAddon();
|
||
|
|
term.loadAddon(fit);
|
||
|
|
term.open(xterm);
|
||
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
|
|
const ws = new WebSocket(`${proto}//${location.host}${wsUrl}`);
|
||
|
|
ws.binaryType = 'arraybuffer';
|
||
|
|
ws.onopen = () => { fit.fit(); term.write('\r\n\x1b[32m[connected]\x1b[0m\r\n'); };
|
||
|
|
ws.onclose = () => term.write('\r\n\x1b[33m[closed]\x1b[0m\r\n');
|
||
|
|
ws.onerror = () => term.write('\r\n\x1b[31m[ws error]\x1b[0m\r\n');
|
||
|
|
ws.onmessage = e => term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data));
|
||
|
|
term.onData(d => ws.readyState === 1 && ws.send(new TextEncoder().encode(d)));
|
||
|
|
const ro = new ResizeObserver(() => fit && fit.fit());
|
||
|
|
ro.observe(xterm);
|
||
|
|
requestAnimationFrame(() => fit.fit());
|
||
|
|
_s[key]._x = { ws, term, fit, ro };
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function openContent(key, title, fetchUrl, opts) {
|
||
|
|
opts = opts || {};
|
||
|
|
if (_s[key]) { _restore(key); return; }
|
||
|
|
const hasLog = !!opts.logControls;
|
||
|
|
const ctls = hasLog ? `
|
||
|
|
<input id="fp-ti-${key}" type="number" value="100" min="10" max="5000"
|
||
|
|
style="width:56px;font-family:monospace;font-size:11px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:4px;color:rgba(255,255,255,0.6);padding:2px 4px;">
|
||
|
|
<button id="fp-fb-${key}" data-url="${fetchUrl}" data-key="${key}"
|
||
|
|
style="padding:2px 8px;font-family:monospace;font-size:11px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:4px;color:rgba(255,255,255,0.5);cursor:pointer;">fetch</button>
|
||
|
|
<button id="fp-tb-${key}" data-url="${fetchUrl}" data-key="${key}"
|
||
|
|
style="padding:2px 8px;font-family:monospace;font-size:11px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:4px;color:rgba(255,255,255,0.5);cursor:pointer;">tail -f</button>` : '';
|
||
|
|
const body = `
|
||
|
|
<div style="display:flex;flex-direction:column;height:100%;padding:8px 10px;box-sizing:border-box;gap:6px;">
|
||
|
|
<div style="display:flex;align-items:center;gap:6px;flex-shrink:0;">
|
||
|
|
<span style="font-family:monospace;font-size:11px;color:rgba(255,255,255,0.3);flex:1;overflow:hidden;text-overflow:ellipsis;">${opts.subtitle||''}</span>
|
||
|
|
${ctls}
|
||
|
|
<button id="fp-cp-${key}" data-key="${key}"
|
||
|
|
style="padding:2px 8px;font-family:monospace;font-size:11px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:4px;color:rgba(255,255,255,0.5);cursor:pointer;">copy</button>
|
||
|
|
</div>
|
||
|
|
<div id="fp-ld-${key}" style="display:flex;align-items:center;justify-content:center;flex:1;font-family:monospace;font-size:12px;color:rgba(255,255,255,0.3);">
|
||
|
|
<span class="loading loading-spinner loading-sm" style="margin-right:8px;"></span>loading…
|
||
|
|
</div>
|
||
|
|
<pre id="fp-ot-${key}"
|
||
|
|
style="display:none;flex:1;min-height:0;overflow:auto;font-family:'Cascadia Code','Fira Code',monospace;font-size:11px;line-height:1.5;white-space:pre;color:rgba(255,255,255,0.75);margin:0;padding:0;"></pre>
|
||
|
|
</div>`;
|
||
|
|
_create(key, title, body, el => {
|
||
|
|
_s[key]._x = { tailSrc: null };
|
||
|
|
if (hasLog) {
|
||
|
|
el.querySelector(`#fp-fb-${key}`)?.addEventListener('click', () => _fetchContent(key, fetchUrl));
|
||
|
|
el.querySelector(`#fp-tb-${key}`)?.addEventListener('click', () => _toggleTail(key, fetchUrl));
|
||
|
|
}
|
||
|
|
el.querySelector(`#fp-cp-${key}`)?.addEventListener('click', () => _copy(key));
|
||
|
|
_fetchContent(key, fetchUrl);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function _fetchContent(key, url) {
|
||
|
|
const ti = document.getElementById(`fp-ti-${key}`);
|
||
|
|
const tail = ti ? ti.value : '';
|
||
|
|
const full = tail ? `${url}?tail=${tail}` : url;
|
||
|
|
const ld = document.getElementById(`fp-ld-${key}`);
|
||
|
|
const ot = document.getElementById(`fp-ot-${key}`);
|
||
|
|
if (ld) ld.style.display = 'flex';
|
||
|
|
if (ot) ot.style.display = 'none';
|
||
|
|
fetch(full).then(r => r.json()).then(d => {
|
||
|
|
if (ld) ld.style.display = 'none';
|
||
|
|
if (ot) { ot.textContent = d.output || d.error || '(empty)'; ot.style.display = 'block'; ot.scrollTop = ot.scrollHeight; }
|
||
|
|
}).catch(e => {
|
||
|
|
if (ld) ld.style.display = 'none';
|
||
|
|
if (ot) { ot.textContent = `fetch error: ${e}`; ot.style.display = 'block'; }
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function _toggleTail(key, url) {
|
||
|
|
const x = _s[key] && _s[key]._x;
|
||
|
|
const btn = document.getElementById(`fp-tb-${key}`);
|
||
|
|
if (!x) return;
|
||
|
|
if (x.tailSrc) { x.tailSrc.close(); x.tailSrc = null; if (btn) btn.style.color = 'rgba(255,255,255,0.5)'; return; }
|
||
|
|
const ti = document.getElementById(`fp-ti-${key}`);
|
||
|
|
const tail = ti ? ti.value : 50;
|
||
|
|
const ld = document.getElementById(`fp-ld-${key}`);
|
||
|
|
const ot = document.getElementById(`fp-ot-${key}`);
|
||
|
|
if (ld) ld.style.display = 'none';
|
||
|
|
if (ot) { ot.textContent = ''; ot.style.display = 'block'; }
|
||
|
|
x.tailSrc = new EventSource(`${url}/stream?tail=${tail}`);
|
||
|
|
x.tailSrc.onmessage = e => { if (ot) { ot.textContent += e.data + '\n'; ot.scrollTop = ot.scrollHeight; } };
|
||
|
|
x.tailSrc.onerror = () => { x.tailSrc = null; if (btn) btn.style.color = 'rgba(255,255,255,0.5)'; };
|
||
|
|
if (btn) btn.style.color = '#22c55e';
|
||
|
|
}
|
||
|
|
|
||
|
|
function _copy(key) {
|
||
|
|
const ot = document.getElementById(`fp-ot-${key}`);
|
||
|
|
const btn = document.getElementById(`fp-cp-${key}`);
|
||
|
|
if (!ot || !btn) return;
|
||
|
|
navigator.clipboard.writeText(ot.textContent).then(() => {
|
||
|
|
btn.textContent = 'copied!'; btn.style.color = '#22c55e';
|
||
|
|
setTimeout(() => { btn.textContent = 'copy'; btn.style.color = 'rgba(255,255,255,0.5)'; }, 1500);
|
||
|
|
}).catch(() => { btn.textContent = 'failed'; setTimeout(() => btn.textContent = 'copy', 1500); });
|
||
|
|
}
|
||
|
|
|
||
|
|
return { openTerminal, openContent };
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<!-- SPA router — swaps <main> content on /ui/workspaces/ links, preserving floating panels -->
|
||
|
|
<script>
|
||
|
|
(function() {
|
||
|
|
var _m = document.getElementById('spa-main');
|
||
|
|
if (!_m) return;
|
||
|
|
|
||
|
|
function _ok(href) {
|
||
|
|
try {
|
||
|
|
var u = new URL(href, location.href);
|
||
|
|
return u.origin === location.origin && u.pathname.startsWith('/ui/workspaces');
|
||
|
|
} catch (_) { return false; }
|
||
|
|
}
|
||
|
|
|
||
|
|
function _exec(container) {
|
||
|
|
// Re-execute inert scripts inserted via innerHTML
|
||
|
|
container.querySelectorAll('script').forEach(function(old) {
|
||
|
|
if (old.src) return;
|
||
|
|
var s = document.createElement('script');
|
||
|
|
s.textContent = old.textContent;
|
||
|
|
old.replaceWith(s);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Load external <head> assets a page declares via {% block head %} (per-page
|
||
|
|
// vendor libs, e.g. cytoscape on the DAG view), deduped by resolved URL. The
|
||
|
|
// SPA swap only touches #spa-main, so without this these never load on
|
||
|
|
// client-side navigation — the webview is SPA-only, so the page breaks there
|
||
|
|
// while a full browser load (which runs <head>) works. Resolves once external
|
||
|
|
// scripts have loaded so the page's inline init runs with them defined.
|
||
|
|
function _headAssets(doc) {
|
||
|
|
var haveJs = {};
|
||
|
|
Array.prototype.forEach.call(document.scripts, function(s) { if (s.src) haveJs[s.src] = 1; });
|
||
|
|
var haveCss = {};
|
||
|
|
document.querySelectorAll('link[rel="stylesheet"]').forEach(function(l) { if (l.href) haveCss[l.href] = 1; });
|
||
|
|
|
||
|
|
doc.querySelectorAll('head link[rel="stylesheet"][href]').forEach(function(l) {
|
||
|
|
var abs = new URL(l.getAttribute('href'), location.href).href;
|
||
|
|
if (haveCss[abs]) return;
|
||
|
|
haveCss[abs] = 1;
|
||
|
|
var nl = document.createElement('link');
|
||
|
|
nl.rel = 'stylesheet'; nl.href = abs;
|
||
|
|
document.head.appendChild(nl);
|
||
|
|
});
|
||
|
|
|
||
|
|
var waits = [];
|
||
|
|
doc.querySelectorAll('head script[src]').forEach(function(sc) {
|
||
|
|
var abs = new URL(sc.getAttribute('src'), location.href).href;
|
||
|
|
if (haveJs[abs]) return;
|
||
|
|
haveJs[abs] = 1;
|
||
|
|
waits.push(new Promise(function(res) {
|
||
|
|
var ns = document.createElement('script');
|
||
|
|
ns.src = abs;
|
||
|
|
ns.onload = res; ns.onerror = res; // onerror→res: a failed asset must not stall navigation
|
||
|
|
document.head.appendChild(ns);
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
return Promise.all(waits);
|
||
|
|
}
|
||
|
|
|
||
|
|
window._spaNavigate = function(href, push) {
|
||
|
|
_m.style.opacity = '0.4';
|
||
|
|
return fetch(href)
|
||
|
|
.then(function(r) { return r.text(); })
|
||
|
|
.then(function(html) {
|
||
|
|
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||
|
|
var nm = doc.getElementById('spa-main');
|
||
|
|
if (!nm) throw new Error('no main');
|
||
|
|
|
||
|
|
document.title = doc.title || document.title;
|
||
|
|
|
||
|
|
// Update breadcrumb content only (the wrapping div stays)
|
||
|
|
var bc = document.getElementById('breadcrumb-bar');
|
||
|
|
var nbc = doc.getElementById('breadcrumb-bar');
|
||
|
|
if (bc && nbc) bc.innerHTML = nbc.innerHTML;
|
||
|
|
|
||
|
|
// Load the page's <head> vendor libs first, then swap main content and
|
||
|
|
// re-execute its inline scripts (now with those libs defined).
|
||
|
|
return _headAssets(doc).then(function() {
|
||
|
|
_m.innerHTML = nm.innerHTML;
|
||
|
|
_m.className = nm.className;
|
||
|
|
_exec(_m);
|
||
|
|
|
||
|
|
if (push !== false) history.pushState({ spa: 1 }, '', href);
|
||
|
|
window.scrollTo(0, 0);
|
||
|
|
});
|
||
|
|
})
|
||
|
|
.catch(function() { location.href = href; })
|
||
|
|
.finally(function() { _m.style.opacity = ''; });
|
||
|
|
};
|
||
|
|
|
||
|
|
document.addEventListener('click', function(e) {
|
||
|
|
var a = e.target.closest('a[href]');
|
||
|
|
if (!a || e.defaultPrevented) return;
|
||
|
|
var href = a.getAttribute('href');
|
||
|
|
if (!href || href[0] === '#') return;
|
||
|
|
if (a.target || a.hasAttribute('download')) return;
|
||
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
||
|
|
if (!_ok(href)) return;
|
||
|
|
e.preventDefault();
|
||
|
|
window._spaNavigate(href, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
window.addEventListener('popstate', function() {
|
||
|
|
if (_ok(location.href)) window._spaNavigate(location.href, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
history.replaceState({ spa: 1 }, '', location.href);
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<!-- Version footer -->
|
||
|
|
<div style="position:fixed;bottom:0.75rem;right:1rem;font-size:0.7rem;font-family:'JetBrains Mono','Fira Code',monospace;user-select:none;opacity:0.55;letter-spacing:0.03em;">
|
||
|
|
<span style="color:#94a3b8">provisioning</span>
|
||
|
|
<span style="color:#e8a838;font-weight:600"> v{{ daemon_version }}</span>
|
||
|
|
<span style="color:#475569;margin:0 0.25rem">|</span>
|
||
|
|
<span style="color:#64748b">2026</span>
|
||
|
|
{% if now_hms %}
|
||
|
|
<span style="color:#475569;margin:0 0.25rem">|</span>
|
||
|
|
<span style="color:#64748b" title="page loaded at this time (UTC)">{{ now_hms }} UTC</span>
|
||
|
|
{% endif %}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</body>
|
||
|
|
</html>
|