mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Update graph ui
This commit is contained in:
@@ -4,17 +4,86 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
#sigma-container {
|
||||
height: calc(100vh - 64px);
|
||||
width: 100%;
|
||||
background: #f8fafc;
|
||||
/* ------------------------------------------------------------------
|
||||
Full-bleed graph canvas that fills the viewport below the navbar.
|
||||
base.html wraps content in div.container.mx-auto.mt-20.p-2
|
||||
So the effective top offset is ~88px. We break out by using
|
||||
negative margins so the graph can fill edge-to-edge.
|
||||
------------------------------------------------------------------ */
|
||||
#kg-page-wrapper {
|
||||
margin: -8px calc(-50vw + 50%); /* cancel container padding & centering */
|
||||
height: calc(100vh - 80px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
#sigma-canvas-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #f1f5f9;
|
||||
/* The container background shows through transparent WebGL pixels.
|
||||
Do NOT set background on the individual canvas elements — sigma's
|
||||
label canvas is transparent by design so you can see the WebGL
|
||||
node/edge canvas beneath it. Making it opaque would hide everything. */
|
||||
}
|
||||
/* Side panel */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Top toolbar — single compact row, never wraps
|
||||
------------------------------------------------------------------ */
|
||||
#kg-toolbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* prevents toolbar from ever covering the node panel */
|
||||
max-width: calc(100% - 320px);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Bottom-left legend / filter bar
|
||||
------------------------------------------------------------------ */
|
||||
#kg-legend {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
left: 12px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
|
||||
max-width: calc(100% - 340px);
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid transparent;
|
||||
transition: opacity 0.15s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-badge.inactive { opacity: 0.3; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Right side panel
|
||||
------------------------------------------------------------------ */
|
||||
#node-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -26,95 +95,149 @@
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
z-index: 20;
|
||||
z-index: 30;
|
||||
}
|
||||
#node-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
/* Toolbar */
|
||||
#kg-toolbar {
|
||||
#node-panel.open { transform: translateX(0); }
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Layout-computing overlay (shown while ForceAtlas2 runs)
|
||||
------------------------------------------------------------------ */
|
||||
#graph-loading-overlay {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
inset: 0;
|
||||
background: rgba(241, 245, 249, 0.88);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
max-width: calc(100% - 330px);
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.filter-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Bottom-right zoom control
|
||||
------------------------------------------------------------------ */
|
||||
#kg-zoom-ctl {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
right: 14px;
|
||||
z-index: 30;
|
||||
transition: right 0.25s ease;
|
||||
}
|
||||
/* style the range thumb */
|
||||
#kg-zoom-ctl input[type=range] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #e2e8f0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: opacity 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.filter-badge.inactive { opacity: 0.4; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
#kg-zoom-ctl input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
#kg-zoom-ctl input[type=range]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="sigma-container" x-data="kgApp()" x-init="init()">
|
||||
<div id="kg-page-wrapper" x-data="kgApp()" x-init="init()">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<!-- ── Top toolbar (single row, never wraps) ───────────────────── -->
|
||||
<div id="kg-toolbar">
|
||||
<!-- Build button -->
|
||||
<button
|
||||
@click="showBuildModal = true"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium shadow transition duration-150 ease-in-out">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
class="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium shadow-sm transition flex-shrink-0">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
{% trans "Build Graph" %}
|
||||
</button>
|
||||
|
||||
<!-- Status badge -->
|
||||
<span class="px-2.5 py-1 rounded-lg bg-white border border-gray-200 text-xs text-gray-600 shadow-sm"
|
||||
<span class="px-2.5 py-1 rounded-full bg-white/90 border border-gray-200 text-xs text-gray-600 shadow-sm whitespace-nowrap flex-shrink-0"
|
||||
x-text="statusText"></span>
|
||||
|
||||
<!-- Last built timestamp -->
|
||||
{% if snapshot %}
|
||||
{% with built_at=snapshot.completed_at|default:snapshot.created_at %}
|
||||
{% if snapshot %}{% with built_at=snapshot.completed_at|default:snapshot.created_at %}
|
||||
<a href="{% url 'jobs' %}?tab=knowledge_graph"
|
||||
class="px-2.5 py-1 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700 shadow-sm hover:bg-amber-100 transition"
|
||||
class="px-2.5 py-1 rounded-full bg-amber-50/90 border border-amber-200 text-xs text-amber-700 shadow-sm hover:bg-amber-100 transition whitespace-nowrap flex-shrink-0 hidden sm:inline-flex"
|
||||
title="{{ built_at|date:'Y-m-d H:i:s' }}">
|
||||
{% trans "Last built" %}: {{ built_at|timesince }} {% trans "ago" %}
|
||||
{{ built_at|timesince }} {% trans "ago" %}
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endwith %}{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Node type filters -->
|
||||
<!-- ── Sigma canvas ──────────────────────────────────────────────── -->
|
||||
<div id="sigma-canvas-wrapper"></div>
|
||||
|
||||
<!-- ── Layout-computing overlay ──────────────────────────────────── -->
|
||||
<div id="graph-loading-overlay" x-show="graphComputing" x-cloak>
|
||||
<svg class="w-9 h-9 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
|
||||
</svg>
|
||||
<p class="text-sm font-semibold text-gray-700" x-text="statusText"></p>
|
||||
<p class="text-xs text-gray-400">{% trans "Computing force-directed layout…" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Zoom control (bottom-right) ─────────────────────────────────── -->
|
||||
<div id="kg-zoom-ctl"
|
||||
x-show="graphLoaded" x-cloak
|
||||
:style="selectedNode ? 'right:314px' : 'right:14px'"
|
||||
class="flex items-center gap-1 bg-white/95 backdrop-blur border border-gray-200 rounded-lg shadow-sm px-2 py-1.5">
|
||||
<button @click="zoomOut()"
|
||||
title="{% trans 'Zoom out' %}"
|
||||
class="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500 text-base font-semibold leading-none select-none">−</button>
|
||||
<input type="range" min="0" max="100" step="1"
|
||||
:value="zoomSlider"
|
||||
@input="setZoomFromSlider(+$event.target.value)"
|
||||
title="{% trans 'Zoom level' %}">
|
||||
<button @click="zoomIn()"
|
||||
title="{% trans 'Zoom in' %}"
|
||||
class="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-500 text-base font-semibold leading-none select-none">+</button>
|
||||
<span class="text-xs text-gray-500 tabular-nums w-11 text-right"
|
||||
x-text="Math.round(100 / (zoomRatio || 1)) + '%'"></span>
|
||||
</div>
|
||||
|
||||
<!-- ── Bottom-left legend / type filters ─────────────────────────── -->
|
||||
<div id="kg-legend" x-show="graphLoaded" x-cloak>
|
||||
<template x-for="f in filters" :key="f.type">
|
||||
<span class="filter-badge shadow-sm"
|
||||
<span class="filter-badge"
|
||||
:class="f.active ? '' : 'inactive'"
|
||||
:style="`background:${f.color}22; border-color:${f.color}; color:${f.color};`"
|
||||
:style="`background:${f.color}22; border-color:${f.color}; color:${f.color}`"
|
||||
@click="toggleFilter(f.type)">
|
||||
<span class="dot" :style="`background:${f.color}`"></span>
|
||||
<span x-text="f.label"></span>
|
||||
<span class="ml-0.5 font-normal opacity-70" x-text="'('+f.count+')'"></span>
|
||||
<span x-text="f.label + ' (' + f.count + ')'"></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Reset view -->
|
||||
<span class="h-4 w-px bg-gray-200 mx-0.5 flex-shrink-0"></span>
|
||||
<button @click="resetCamera()"
|
||||
class="px-2.5 py-1 rounded-lg bg-white border border-gray-200 text-xs text-gray-500 hover:text-gray-800 shadow-sm transition">
|
||||
{% trans "Reset View" %}
|
||||
class="px-2 py-0.5 text-xs text-gray-500 hover:text-gray-800 bg-white rounded border border-gray-200 transition flex-shrink-0">
|
||||
{% trans "Reset" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sigma canvas -->
|
||||
<div id="sigma-canvas-wrapper"></div>
|
||||
|
||||
<!-- Side panel -->
|
||||
<!-- ── Right side panel ──────────────────────────────────────────── -->
|
||||
<div id="node-panel" :class="selectedNode ? 'open' : ''">
|
||||
<div class="p-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-gray-800 text-sm">{% trans "Node Details" %}</h3>
|
||||
@@ -125,40 +248,29 @@
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="selectedNode" class="p-4 space-y-3">
|
||||
<!-- Type badge -->
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold"
|
||||
:style="`background:${selectedNode?.color}22; color:${selectedNode?.color};`">
|
||||
<span class="dot" :style="`background:${selectedNode?.color}`"></span>
|
||||
<span x-text="selectedNode?.node_type?.toUpperCase()"></span>
|
||||
</span>
|
||||
|
||||
<!-- Label -->
|
||||
<p class="font-semibold text-gray-900 text-sm break-words" x-text="selectedNode?.label"></p>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-xs text-gray-500 break-words" x-show="selectedNode?.description"
|
||||
x-text="selectedNode?.description?.slice(0,200) + (selectedNode?.description?.length > 200 ? '…' : '')"></p>
|
||||
|
||||
<!-- Original URL -->
|
||||
<div x-show="selectedNode?.original_url">
|
||||
<p class="text-xs text-gray-400 mb-0.5">{% trans "URL" %}</p>
|
||||
<a :href="selectedNode?.original_url" target="_blank" rel="noopener noreferrer"
|
||||
class="text-xs text-blue-500 hover:underline break-all"
|
||||
x-text="selectedNode?.original_url?.slice(0,80) + (selectedNode?.original_url?.length > 80 ? '…' : '')"></a>
|
||||
</div>
|
||||
|
||||
<!-- View item button -->
|
||||
<a :href="selectedNode?.item_url" target="_blank" rel="noopener noreferrer"
|
||||
x-show="selectedNode?.item_url"
|
||||
class="inline-flex items-center gap-1.5 w-full justify-center px-3 py-2 rounded bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium transition duration-150 ease-in-out">
|
||||
class="inline-flex items-center gap-1.5 w-full justify-center px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition">
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
{% trans "View Item" %}
|
||||
</a>
|
||||
|
||||
<!-- Connected nodes -->
|
||||
<div x-show="connectedNodes.length > 0">
|
||||
<p class="text-xs font-semibold text-gray-500 mb-1">
|
||||
{% trans "Connected" %} (<span x-text="connectedNodes.length"></span>)
|
||||
@@ -179,8 +291,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div x-show="!graphLoaded && !building"
|
||||
<!-- ── Empty state ───────────────────────────────────────────────── -->
|
||||
<div x-show="!graphLoaded && !building && !graphComputing"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-4 text-gray-400">
|
||||
<svg class="w-16 h-16 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
|
||||
@@ -188,17 +300,15 @@
|
||||
</svg>
|
||||
<p class="text-sm font-medium">{% trans "No knowledge graph yet." %}</p>
|
||||
<button @click="showBuildModal = true"
|
||||
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded transition duration-150 ease-in-out">
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition">
|
||||
{% trans "Build Graph Now" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Building progress panel -->
|
||||
<!-- ── Building progress panel ───────────────────────────────────── -->
|
||||
<div x-show="building"
|
||||
class="absolute inset-0 flex items-center justify-center p-6">
|
||||
class="absolute inset-0 flex items-center justify-center p-6 z-10">
|
||||
<div class="bg-white rounded-xl shadow-lg border border-gray-100 w-full max-w-lg p-6 space-y-4">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 animate-spin text-blue-500 flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
@@ -206,8 +316,6 @@
|
||||
</svg>
|
||||
<span class="font-semibold text-gray-800 text-sm">{% trans "Building Knowledge Graph" %}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span x-text="progress.step || '{% trans "Working…" %}'" class="truncate max-w-xs"></span>
|
||||
@@ -218,16 +326,12 @@
|
||||
:style="'width:' + (progress.pct || 0) + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timing row -->
|
||||
<div class="flex gap-4 text-xs text-gray-500">
|
||||
<span>⏱ {% trans "Elapsed" %}: <span class="font-medium text-gray-700" x-text="fmtSecs(elapsedSecs)"></span></span>
|
||||
<span x-show="progress.pct > 5 && progress.pct < 100">
|
||||
{% trans "Est. remaining" %}: <span class="font-medium text-gray-700" x-text="fmtSecs(estRemaining)"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Foldable logs -->
|
||||
<div>
|
||||
<button @click="logsExpanded = !logsExpanded"
|
||||
class="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-700 transition">
|
||||
@@ -241,24 +345,21 @@
|
||||
class="mt-2 max-h-40 overflow-y-auto rounded-lg bg-gray-900 p-3 space-y-0.5 font-mono">
|
||||
<template x-for="(log, i) in buildLogs" :key="i">
|
||||
<div class="flex gap-2 text-xs leading-5">
|
||||
<span class="text-gray-500 flex-shrink-0"
|
||||
x-text="'+' + log.elapsed_s.toFixed(1) + 's'"></span>
|
||||
<span class="text-gray-500 flex-shrink-0" x-text="'+' + log.elapsed_s.toFixed(1) + 's'"></span>
|
||||
<span class="text-green-400" x-text="log.msg"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="buildLogs.length === 0" class="text-xs text-gray-500">{% trans "No log entries yet…" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Build modal -->
|
||||
<!-- ── Build modal ──────────────────────────────────────────────── -->
|
||||
<div x-show="showBuildModal" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6 space-y-4">
|
||||
<h2 class="text-lg font-bold text-gray-900">{% trans "Build Knowledge Graph" %}</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" x-model="buildUseLLM" :disabled="!llmAvailable"
|
||||
@@ -276,14 +377,13 @@
|
||||
{% trans "Semantic edges (pink) connect items with similar meaning using embeddings. Without LLM, the graph uses tag and domain edges only." %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button @click="showBuildModal = false"
|
||||
class="flex-1 py-2 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 transition">
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
<button @click="startBuild()"
|
||||
class="flex-1 py-2 rounded bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium transition duration-150 ease-in-out">
|
||||
class="flex-1 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition">
|
||||
{% trans "Start Build" %}
|
||||
</button>
|
||||
</div>
|
||||
@@ -294,7 +394,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- graphology + sigma.js + ForceAtlas2 from CDN -->
|
||||
<!-- graphology + sigma + ForceAtlas2 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/graphology@0.25.4/dist/graphology.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sigma@2.4.0/build/sigma.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/graphology-layout-forceatlas2@0.10.1/build/graphology-layout-forceatlas2.umd.min.js"></script>
|
||||
@@ -302,10 +402,13 @@
|
||||
<script>
|
||||
function kgApp() {
|
||||
return {
|
||||
// ── state ──────────────────────────────────────────────────────────
|
||||
// ── state ──────────────────────────────────────────────────────
|
||||
sigmaInstance: null,
|
||||
graph: null,
|
||||
graphLoaded: false,
|
||||
graphComputing: false,
|
||||
zoomRatio: 1,
|
||||
zoomSlider: 50,
|
||||
building: {% if latest_building %}true{% else %}false{% endif %},
|
||||
showBuildModal: false,
|
||||
buildUseLLM: false,
|
||||
@@ -321,17 +424,25 @@ function kgApp() {
|
||||
estRemaining: 0,
|
||||
_buildStartMs: null,
|
||||
_elapsedTimer: null,
|
||||
_cameraRefreshTimer: null,
|
||||
filters: [
|
||||
{ type: 'link', label: '{% trans "Links" %}', color: '#4B9CD3', active: true, count: 0 },
|
||||
{ type: 'page', label: '{% trans "Pages" %}', color: '#22C55E', active: true, count: 0 },
|
||||
{ type: 'link', label: '{% trans "Links" %}', color: '#3B82F6', active: true, count: 0 },
|
||||
{ type: 'page', label: '{% trans "Pages" %}', color: '#10B981', active: true, count: 0 },
|
||||
{ type: 'post', label: '{% trans "Posts" %}', color: '#F97316', active: true, count: 0 },
|
||||
{ type: 'tag', label: '{% trans "Tags" %}', color: '#8B5CF6', active: true, count: 0 },
|
||||
],
|
||||
|
||||
// ── init ───────────────────────────────────────────────────────────
|
||||
// ── init ───────────────────────────────────────────────────────
|
||||
init() {
|
||||
// Ensure only one KG renderer instance is active on the page.
|
||||
// If Alpine re-initializes this block, tear down the old instance first.
|
||||
if (window.__kgActive && window.__kgActive !== this && typeof window.__kgActive.destroy === 'function') {
|
||||
window.__kgActive.destroy();
|
||||
}
|
||||
window.__kgActive = this;
|
||||
|
||||
this.llmAvailable = '{{ site_settings.llm_provider }}' !== 'none';
|
||||
this.buildUseLLM = this.llmAvailable;
|
||||
this.buildUseLLM = this.llmAvailable;
|
||||
{% if snapshot %}
|
||||
this.loadGraph();
|
||||
{% elif latest_building %}
|
||||
@@ -339,166 +450,311 @@ function kgApp() {
|
||||
{% endif %}
|
||||
},
|
||||
|
||||
// ── load & render graph ────────────────────────────────────────────
|
||||
async loadGraph() {
|
||||
try {
|
||||
const resp = await fetch('/api/knowledge-graph/data/');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
this.renderGraph(data);
|
||||
} catch(e) {
|
||||
console.error('KG load error:', e);
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
clearInterval(this.pollTimer);
|
||||
clearInterval(this._elapsedTimer);
|
||||
clearTimeout(this._cameraRefreshTimer);
|
||||
|
||||
renderGraph(data) {
|
||||
if (this.sigmaInstance) {
|
||||
this.sigmaInstance.kill();
|
||||
this.sigmaInstance = null;
|
||||
}
|
||||
|
||||
const container = document.getElementById('sigma-canvas-wrapper');
|
||||
if (container) container.innerHTML = '';
|
||||
|
||||
if (window.__kgActive === this) window.__kgActive = null;
|
||||
},
|
||||
|
||||
// ── load graph data ────────────────────────────────────────────
|
||||
async loadGraph() {
|
||||
try {
|
||||
const resp = await fetch('/api/knowledge-graph/data/');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
await this.renderGraph(data);
|
||||
} catch(e) {
|
||||
console.error('KG load error:', e);
|
||||
this.statusText = '{% trans "Load error" %}';
|
||||
this.graphComputing = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── render + layout (async so the UI stays responsive) ─────────
|
||||
async renderGraph(data) {
|
||||
// Kill previous instance and wipe all canvas/event elements sigma
|
||||
// may have left in the container — without this, the old graph
|
||||
// remains visible as a ghost underneath the new one.
|
||||
const container = document.getElementById('sigma-canvas-wrapper');
|
||||
if (this.sigmaInstance) {
|
||||
this.sigmaInstance.kill();
|
||||
this.sigmaInstance = null;
|
||||
}
|
||||
clearTimeout(this._cameraRefreshTimer);
|
||||
this._cameraRefreshTimer = null;
|
||||
container.innerHTML = '';
|
||||
|
||||
const g = new graphology.Graph({ multi: false });
|
||||
|
||||
// Add nodes
|
||||
for (const n of (data.nodes || [])) {
|
||||
if (!g.hasNode(n.key)) {
|
||||
g.addNode(n.key, n.attributes || {});
|
||||
}
|
||||
if (!g.hasNode(n.key)) g.addNode(n.key, n.attributes || {});
|
||||
}
|
||||
|
||||
// Add edges (skip if source/target missing)
|
||||
for (const e of (data.edges || [])) {
|
||||
if (g.hasNode(e.source) && g.hasNode(e.target) && !g.hasEdge(e.key)) {
|
||||
if (g.hasNode(e.source) && g.hasNode(e.target)) {
|
||||
try {
|
||||
g.addEdgeWithKey(e.key, e.source, e.target, e.attributes || {});
|
||||
if (!g.hasEdge(e.key)) g.addEdgeWithKey(e.key, e.source, e.target, e.attributes || {});
|
||||
} catch(_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Run ForceAtlas2 layout (in-browser, sync, 150 iterations)
|
||||
try {
|
||||
graphologyLayoutForceAtlas2.assign(g, {
|
||||
iterations: 150,
|
||||
settings: {
|
||||
gravity: 2,
|
||||
scalingRatio: 8,
|
||||
slowDown: 2,
|
||||
barnesHutOptimize: g.order > 200,
|
||||
},
|
||||
});
|
||||
} catch(e) {
|
||||
console.warn('ForceAtlas2 failed, using random layout:', e);
|
||||
}
|
||||
|
||||
this.graph = g;
|
||||
this.graphLoaded = true;
|
||||
this.building = false;
|
||||
|
||||
// Update filter counts
|
||||
// Count nodes by type for the legend
|
||||
const typeCounts = {};
|
||||
g.forEachNode((key, attrs) => {
|
||||
typeCounts[attrs.node_type] = (typeCounts[attrs.node_type] || 0) + 1;
|
||||
g.forEachNode((k, a) => {
|
||||
typeCounts[a.node_type] = (typeCounts[a.node_type] || 0) + 1;
|
||||
});
|
||||
this.filters.forEach(f => { f.count = typeCounts[f.type] || 0; });
|
||||
|
||||
// Update status text
|
||||
// Show the computing overlay while ForceAtlas2 runs
|
||||
const nodeCount = g.order;
|
||||
const edgeCount = g.size;
|
||||
this.statusText = `${nodeCount} {% trans "nodes" %} · ${edgeCount} {% trans "edges" %}`;
|
||||
this.statusText = `{% trans "Laying out" %} ${nodeCount.toLocaleString()} {% trans "nodes" %}…`;
|
||||
this.graphComputing = true;
|
||||
this.building = false;
|
||||
|
||||
// Yield to let Alpine re-render the overlay before blocking computation
|
||||
await new Promise(r => setTimeout(r, 80));
|
||||
|
||||
// Check whether nodes already have meaningful positions from the server
|
||||
let withPos = 0;
|
||||
g.forEachNode((k, a) => {
|
||||
if (typeof a.x === 'number' && typeof a.y === 'number' && Math.abs(a.x) + Math.abs(a.y) > 0.001) withPos++;
|
||||
});
|
||||
const hasMeaningfulPositions = withPos > nodeCount * 0.3;
|
||||
|
||||
if (!hasMeaningfulPositions) {
|
||||
// ── Step 1: pre-position in a circle for fast FA2 convergence ──
|
||||
// Starting from random/zero positions causes FA2 to produce a tight
|
||||
// blob. A circular seed gives every node room to "settle" outward.
|
||||
const nks = g.nodes();
|
||||
const r0 = Math.sqrt(nks.length) * 12;
|
||||
nks.forEach((nk, i) => {
|
||||
const θ = (2 * Math.PI * i) / nks.length;
|
||||
// small jitter breaks the rotational symmetry
|
||||
g.setNodeAttribute(nk, 'x', r0 * Math.cos(θ) + (Math.random() - 0.5) * 3);
|
||||
g.setNodeAttribute(nk, 'y', r0 * Math.sin(θ) + (Math.random() - 0.5) * 3);
|
||||
});
|
||||
|
||||
// ── Step 2: ForceAtlas2 with settings tuned for dense graphs ──
|
||||
// Key choices:
|
||||
// linLogMode=true → log-scale attraction; hubs don't collapse to a point
|
||||
// outboundAttractionDistrib → attraction force ÷ degree; prevents mass clustering
|
||||
// gravity=0.3 → gentle pull to center; nodes can spread freely
|
||||
// barnesHutOptimize=true → O(n log n) repulsion; essential for 1000+ nodes
|
||||
try {
|
||||
graphologyLayoutForceAtlas2.assign(g, {
|
||||
iterations: 250,
|
||||
settings: {
|
||||
gravity: 0.3,
|
||||
scalingRatio: 2,
|
||||
slowDown: 20,
|
||||
barnesHutOptimize: true,
|
||||
barnesHutTheta: 0.7,
|
||||
linLogMode: true,
|
||||
outboundAttractionDistribution:true,
|
||||
adjustSizes: false,
|
||||
strongGravityMode: false,
|
||||
},
|
||||
});
|
||||
} catch(e) {
|
||||
console.warn('ForceAtlas2 failed, continuing with circle layout:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.graph = g;
|
||||
this.graphLoaded = true;
|
||||
this.graphComputing = false;
|
||||
this.statusText = `${nodeCount.toLocaleString()} {% trans "nodes" %} · ${g.size.toLocaleString()} {% trans "edges" %}`;
|
||||
|
||||
// Init sigma
|
||||
const container = document.getElementById('sigma-canvas-wrapper');
|
||||
this.sigmaInstance = new Sigma(g, container, {
|
||||
labelRenderedSizeThreshold: 6,
|
||||
defaultEdgeColor: '#e2e8f0',
|
||||
labelColor: { color: '#374151' },
|
||||
// Hide edge/label layers while moving. We'll force-clear and repaint
|
||||
// once interaction settles to avoid stale ghost frames.
|
||||
hideEdgesOnMove: true,
|
||||
hideLabelsOnMove: true,
|
||||
|
||||
// Only show labels for nodes that appear >= 12px on screen.
|
||||
labelRenderedSizeThreshold: 12,
|
||||
labelColor: { color: '#1e293b' },
|
||||
labelSize: 11,
|
||||
|
||||
// ── Defaults ─────────────────────────────────────────────────
|
||||
defaultNodeColor: '#6366f1',
|
||||
renderEdgeLabels: false,
|
||||
edgeReducer: (edge, data) => {
|
||||
return { ...data, hidden: data._hidden || false };
|
||||
|
||||
// ── Node reducer ─────────────────────────────────────────────
|
||||
// Dim nodes that are NOT connected to the currently selected node.
|
||||
// "Dim" = render in a near-transparent gray so the selected node's
|
||||
// neighbourhood stands out clearly.
|
||||
nodeReducer: (node, attrs) => {
|
||||
const res = { ...attrs, hidden: attrs._hidden || false };
|
||||
if (attrs._dim) {
|
||||
res.color = '#cbd5e1'; // light slate
|
||||
res.size = Math.max(1.5, (attrs.size || 4) * 0.55);
|
||||
res.label = null; // suppress label for dimmed nodes
|
||||
}
|
||||
return res;
|
||||
},
|
||||
nodeReducer: (node, data) => {
|
||||
return { ...data, hidden: data._hidden || false };
|
||||
|
||||
// ── Edge reducer ─────────────────────────────────────────────
|
||||
// Default: edges are almost invisible (dense graph = unreadable otherwise).
|
||||
// Highlighted edges (connected to selected node) are shown at full opacity.
|
||||
edgeReducer: (edge, attrs) => {
|
||||
const res = { ...attrs, hidden: attrs._hidden || false };
|
||||
if (attrs._highlighted) {
|
||||
res.color = attrs.color || '#64748b';
|
||||
res.size = Math.max(1, (attrs.size || 0.5) * 2);
|
||||
} else {
|
||||
// Sigma's floatColor() only parses 3/6-digit hex. Use a light
|
||||
// slate-400 hex so edges are visible but don't overwhelm nodes.
|
||||
res.color = '#94a3b8';
|
||||
res.size = 0.5;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
});
|
||||
|
||||
// Click node
|
||||
this.sigmaInstance.on('clickNode', ({ node }) => {
|
||||
this.selectNodeByKey(node);
|
||||
});
|
||||
this.sigmaInstance.on('clickNode', ({ node }) => this.selectNodeByKey(node));
|
||||
this.sigmaInstance.on('clickStage', () => this.deselectNode());
|
||||
|
||||
// Click background to deselect
|
||||
this.sigmaInstance.on('clickStage', () => {
|
||||
this.deselectNode();
|
||||
// Sync zoom slider with touchpad / programmatic camera changes.
|
||||
// Throttled to ~30fps to avoid flooding Alpine with reactive updates
|
||||
// (which would trigger DOM reconciliation and potentially interfere
|
||||
// with sigma's canvas clearing between frames).
|
||||
let _lastZoomSync = 0;
|
||||
this.sigmaInstance.getCamera().on('updated', (state) => {
|
||||
const now = Date.now();
|
||||
if (now - _lastZoomSync > 33) {
|
||||
_lastZoomSync = now;
|
||||
this._syncZoomFromCamera(state.ratio);
|
||||
}
|
||||
|
||||
// Force a clean repaint when zoom/pan settles.
|
||||
clearTimeout(this._cameraRefreshTimer);
|
||||
this._cameraRefreshTimer = setTimeout(() => {
|
||||
this.forceCanvasClear();
|
||||
if (this.sigmaInstance) this.sigmaInstance.refresh();
|
||||
}, 80);
|
||||
});
|
||||
this._syncZoomFromCamera(this.sigmaInstance.getCamera().ratio);
|
||||
},
|
||||
|
||||
forceCanvasClear() {
|
||||
const wrapper = document.getElementById('sigma-canvas-wrapper');
|
||||
if (!wrapper) return;
|
||||
const canvases = wrapper.querySelectorAll('canvas');
|
||||
|
||||
canvases.forEach((canvas) => {
|
||||
const ctx2d = canvas.getContext('2d');
|
||||
if (ctx2d) {
|
||||
ctx2d.clearRect(0, 0, canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
if (gl) {
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── node selection & highlight ─────────────────────────────────────
|
||||
// ── node selection ─────────────────────────────────────────────
|
||||
selectNodeByKey(nodeKey) {
|
||||
if (!this.graph || !this.sigmaInstance) return;
|
||||
if (!this.graph.hasNode(nodeKey)) return;
|
||||
if (!this.graph || !this.sigmaInstance || !this.graph.hasNode(nodeKey)) return;
|
||||
|
||||
const attrs = this.graph.getNodeAttributes(nodeKey);
|
||||
this.selectedNode = { key: nodeKey, ...attrs };
|
||||
|
||||
// Find neighbors
|
||||
const neighborKeys = new Set();
|
||||
this.graph.forEachNeighbor(nodeKey, (nbr) => neighborKeys.add(nbr));
|
||||
this.graph.forEachNeighbor(nodeKey, nbr => neighborKeys.add(nbr));
|
||||
|
||||
this.connectedNodes = Array.from(neighborKeys).map(k => ({
|
||||
key: k,
|
||||
...this.graph.getNodeAttributes(k),
|
||||
}));
|
||||
key: k, ...this.graph.getNodeAttributes(k),
|
||||
})).sort((a, b) => (b.size || 4) - (a.size || 4)); // largest first
|
||||
|
||||
// Dim non-connected nodes/edges
|
||||
this.graph.forEachNode((n, a) => {
|
||||
const isRelevant = n === nodeKey || neighborKeys.has(n);
|
||||
this.graph.setNodeAttribute(n, '_hidden', !isRelevant);
|
||||
this.graph.forEachNode((n) => {
|
||||
const relevant = n === nodeKey || neighborKeys.has(n);
|
||||
this.graph.setNodeAttribute(n, '_dim', !relevant);
|
||||
this.graph.setNodeAttribute(n, '_hidden', false);
|
||||
});
|
||||
this.graph.forEachEdge((e, a, s, t) => {
|
||||
const isRelevant = s === nodeKey || t === nodeKey;
|
||||
this.graph.setEdgeAttribute(e, '_hidden', !isRelevant);
|
||||
const isConn = s === nodeKey || t === nodeKey;
|
||||
this.graph.setEdgeAttribute(e, '_highlighted', isConn);
|
||||
this.graph.setEdgeAttribute(e, '_hidden', false);
|
||||
});
|
||||
|
||||
this.sigmaInstance.refresh();
|
||||
},
|
||||
|
||||
deselectNode() {
|
||||
this.selectedNode = null;
|
||||
this.selectedNode = null;
|
||||
this.connectedNodes = [];
|
||||
if (!this.graph || !this.sigmaInstance) return;
|
||||
this.graph.forEachNode((n) => this.graph.setNodeAttribute(n, '_hidden', false));
|
||||
this.graph.forEachEdge((e) => this.graph.setEdgeAttribute(e, '_hidden', false));
|
||||
this.graph.forEachNode((n) => {
|
||||
this.graph.setNodeAttribute(n, '_dim', false);
|
||||
this.graph.setNodeAttribute(n, '_hidden', false);
|
||||
});
|
||||
this.graph.forEachEdge((e) => {
|
||||
this.graph.setEdgeAttribute(e, '_highlighted', false);
|
||||
this.graph.setEdgeAttribute(e, '_hidden', false);
|
||||
});
|
||||
this.sigmaInstance.refresh();
|
||||
},
|
||||
|
||||
// ── type filter toggle ─────────────────────────────────────────────
|
||||
// ── type filter toggle ─────────────────────────────────────────
|
||||
toggleFilter(type) {
|
||||
const f = this.filters.find(f => f.type === type);
|
||||
if (!f) return;
|
||||
if (!f || !this.graph || !this.sigmaInstance) return;
|
||||
f.active = !f.active;
|
||||
const activeTypes = new Set(this.filters.filter(f => f.active).map(f => f.type));
|
||||
if (!this.graph || !this.sigmaInstance) return;
|
||||
this.graph.forEachNode((n, a) => {
|
||||
this.graph.setNodeAttribute(n, '_hidden', !activeTypes.has(a.node_type));
|
||||
});
|
||||
this.graph.forEachEdge((e, a, s, t) => {
|
||||
const sa = this.graph.getNodeAttributes(s);
|
||||
const ta = this.graph.getNodeAttributes(t);
|
||||
const hidden = !activeTypes.has(sa.node_type) || !activeTypes.has(ta.node_type);
|
||||
this.graph.setEdgeAttribute(e, '_hidden', hidden);
|
||||
this.graph.setEdgeAttribute(e, '_hidden',
|
||||
!activeTypes.has(sa.node_type) || !activeTypes.has(ta.node_type));
|
||||
});
|
||||
this.sigmaInstance.refresh();
|
||||
},
|
||||
|
||||
// ── camera reset ───────────────────────────────────────────────────
|
||||
// ── camera reset ───────────────────────────────────────────────
|
||||
resetCamera() {
|
||||
if (this.sigmaInstance) {
|
||||
this.sigmaInstance.getCamera().animatedReset();
|
||||
}
|
||||
if (this.sigmaInstance) this.sigmaInstance.getCamera().animatedReset();
|
||||
},
|
||||
|
||||
// ── build ──────────────────────────────────────────────────────────
|
||||
// ── zoom controls ──────────────────────────────────────────────
|
||||
zoomIn() { if (this.sigmaInstance) this.sigmaInstance.getCamera().animatedZoom({ duration: 200, factor: 1.5 }); },
|
||||
zoomOut() { if (this.sigmaInstance) this.sigmaInstance.getCamera().animatedUnzoom({ duration: 200, factor: 1.5 }); },
|
||||
|
||||
// Slider 0..100 → camera ratio on a log scale
|
||||
// val=0 → ratio=4 → 25% zoom (very zoomed out)
|
||||
// val=50 → ratio=1 → 100% zoom (default)
|
||||
// val=100 → ratio=0.25 → 400% zoom (very zoomed in)
|
||||
setZoomFromSlider(val) {
|
||||
this.zoomSlider = val;
|
||||
const ratio = Math.exp(Math.log(4) + (Math.log(0.25) - Math.log(4)) * val / 100);
|
||||
if (this.sigmaInstance) this.sigmaInstance.getCamera().setState({ ratio });
|
||||
},
|
||||
|
||||
_syncZoomFromCamera(ratio) {
|
||||
this.zoomRatio = ratio;
|
||||
const logMin = Math.log(4); // most zoomed out
|
||||
const logMax = Math.log(0.25); // most zoomed in
|
||||
const raw = 100 * (Math.log(Math.max(ratio, 0.001)) - logMin) / (logMax - logMin);
|
||||
this.zoomSlider = Math.max(0, Math.min(100, Math.round(raw)));
|
||||
},
|
||||
|
||||
// ── build ──────────────────────────────────────────────────────
|
||||
async startBuild() {
|
||||
this.showBuildModal = false;
|
||||
this.building = true;
|
||||
@@ -515,9 +771,7 @@ function kgApp() {
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCsrf() },
|
||||
body: JSON.stringify({ use_llm: this.buildUseLLM }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.pollStatus();
|
||||
}
|
||||
if (resp.ok) this.pollStatus();
|
||||
} catch(e) {
|
||||
this.building = false;
|
||||
clearInterval(this._elapsedTimer);
|
||||
@@ -525,7 +779,7 @@ function kgApp() {
|
||||
}
|
||||
},
|
||||
|
||||
// ── progress helpers ───────────────────────────────────────────────
|
||||
// ── elapsed timer ──────────────────────────────────────────────
|
||||
fmtSecs(s) {
|
||||
if (s < 60) return Math.round(s) + 's';
|
||||
return Math.floor(s / 60) + 'm ' + (Math.round(s) % 60) + 's';
|
||||
@@ -537,13 +791,11 @@ function kgApp() {
|
||||
this._elapsedTimer = setInterval(() => {
|
||||
this.elapsedSecs = (Date.now() - this._buildStartMs) / 1000;
|
||||
const pct = this.progress.pct || 0;
|
||||
if (pct > 5 && pct < 100) {
|
||||
this.estRemaining = (this.elapsedSecs / pct) * (100 - pct);
|
||||
}
|
||||
if (pct > 5 && pct < 100) this.estRemaining = (this.elapsedSecs / pct) * (100 - pct);
|
||||
}, 500);
|
||||
},
|
||||
|
||||
// ── status polling ─────────────────────────────────────────────────
|
||||
// ── status polling ─────────────────────────────────────────────
|
||||
pollStatus() {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = setInterval(async () => {
|
||||
@@ -551,9 +803,8 @@ function kgApp() {
|
||||
const resp = await fetch('/api/knowledge-graph/status/');
|
||||
const data = await resp.json();
|
||||
|
||||
// Update live progress
|
||||
if (data.progress_data && data.progress_data.pct !== undefined) {
|
||||
this.progress = data.progress_data;
|
||||
if (data.progress_data?.pct !== undefined) {
|
||||
this.progress = data.progress_data;
|
||||
this.buildLogs = data.progress_data.logs || [];
|
||||
if (this.logsExpanded && this.buildLogs.length) {
|
||||
this.$nextTick(() => {
|
||||
@@ -563,7 +814,6 @@ function kgApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Start elapsed timer from server-reported created_at
|
||||
if (data.status === 'building' && data.created_at && !this._buildStartMs) {
|
||||
this._startElapsedTimer(new Date(data.created_at).getTime());
|
||||
}
|
||||
@@ -586,10 +836,10 @@ function kgApp() {
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
// ── CSRF helper ────────────────────────────────────────────────────
|
||||
// ── CSRF helper ────────────────────────────────────────────────
|
||||
getCsrf() {
|
||||
const cookie = document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='));
|
||||
return cookie ? cookie.trim().split('=')[1] : '';
|
||||
const c = document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='));
|
||||
return c ? c.trim().split('=')[1] : '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user