|
1
|
{% extends "base.html" %} |
|
2
|
{% block title %}Explorer — {{ project.name }} — Fossilrepo{% endblock %} |
|
3
|
|
|
4
|
{% block content %} |
|
5
|
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
|
6
|
{% include "fossil/_project_nav.html" %} |
|
7
|
|
|
8
|
<div class="flex items-center justify-between mb-6"> |
|
9
|
<div> |
|
10
|
<h2 class="text-xl font-bold text-gray-100">SQLite Schema Explorer</h2> |
|
11
|
<p class="text-sm text-gray-500 mt-1"> |
|
12
|
Explore the .fossil SQLite database structure. {{ tables|length }} table{{ tables|length|pluralize }} found. |
|
13
|
</p> |
|
14
|
</div> |
|
15
|
<a href="{% url 'fossil:explorer_query' slug=project.slug %}" |
|
16
|
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md bg-brand text-white hover:bg-brand-hover"> |
|
17
|
Run Query |
|
18
|
</a> |
|
19
|
</div> |
|
20
|
|
|
21
|
<!-- Schema Relationship Visualization --> |
|
22
|
<div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6" x-data="{ showViz: true }"> |
|
23
|
<div class="flex items-center justify-between mb-3"> |
|
24
|
<h3 class="text-sm font-medium text-gray-300">Schema Map</h3> |
|
25
|
<button @click="showViz = !showViz" class="text-xs text-gray-500 hover:text-gray-300"> |
|
26
|
<span x-text="showViz ? 'Hide' : 'Show'"></span> |
|
27
|
</button> |
|
28
|
</div> |
|
29
|
<div x-show="showViz" x-transition> |
|
30
|
<!-- Category legend --> |
|
31
|
<div class="flex flex-wrap gap-3 mb-4 text-xs"> |
|
32
|
<span class="flex items-center gap-1.5"> |
|
33
|
<span class="w-2.5 h-2.5 rounded-full bg-blue-500"></span> |
|
34
|
<span class="text-gray-400">VCS Core</span> |
|
35
|
</span> |
|
36
|
<span class="flex items-center gap-1.5"> |
|
37
|
<span class="w-2.5 h-2.5 rounded-full bg-indigo-500"></span> |
|
38
|
<span class="text-gray-400">Tags / Branches</span> |
|
39
|
</span> |
|
40
|
<span class="flex items-center gap-1.5"> |
|
41
|
<span class="w-2.5 h-2.5 rounded-full bg-green-500"></span> |
|
42
|
<span class="text-gray-400">Tickets</span> |
|
43
|
</span> |
|
44
|
<span class="flex items-center gap-1.5"> |
|
45
|
<span class="w-2.5 h-2.5 rounded-full bg-purple-500"></span> |
|
46
|
<span class="text-gray-400">Wiki</span> |
|
47
|
</span> |
|
48
|
<span class="flex items-center gap-1.5"> |
|
49
|
<span class="w-2.5 h-2.5 rounded-full bg-orange-500"></span> |
|
50
|
<span class="text-gray-400">Forum</span> |
|
51
|
</span> |
|
52
|
<span class="flex items-center gap-1.5"> |
|
53
|
<span class="w-2.5 h-2.5 rounded-full bg-gray-500"></span> |
|
54
|
<span class="text-gray-400">Other</span> |
|
55
|
</span> |
|
56
|
</div> |
|
57
|
|
|
58
|
<!-- SVG relationship diagram --> |
|
59
|
<div class="overflow-x-auto"> |
|
60
|
<svg id="schema-viz" class="w-full" style="min-height: 280px;"></svg> |
|
61
|
</div> |
|
62
|
</div> |
|
63
|
</div> |
|
64
|
|
|
65
|
<!-- Table grid --> |
|
66
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 mb-6" x-data="{ selected: '' }"> |
|
67
|
{% for table in tables %} |
|
68
|
<button |
|
69
|
@click="selected = selected === '{{ table.name }}' ? '' : '{{ table.name }}'" |
|
70
|
hx-get="{% url 'fossil:explorer_table' slug=project.slug table_name=table.name %}" |
|
71
|
hx-target="#table-detail" |
|
72
|
hx-swap="innerHTML" |
|
73
|
class="text-left rounded-lg border p-3 transition-colors cursor-pointer |
|
74
|
{% if table.category == 'blue' %}border-blue-800/50 hover:border-blue-600{% elif table.category == 'indigo' %}border-indigo-800/50 hover:border-indigo-600{% elif table.category == 'green' %}border-green-800/50 hover:border-green-600{% elif table.category == 'purple' %}border-purple-800/50 hover:border-purple-600{% elif table.category == 'orange' %}border-orange-800/50 hover:border-orange-600{% else %}border-gray-700 hover:border-gray-500{% endif %}" |
|
75
|
:class="selected === '{{ table.name }}' ? 'bg-gray-700 ring-1 ring-brand' : 'bg-gray-800'"> |
|
76
|
<div class="flex items-center justify-between"> |
|
77
|
<span class="text-sm font-mono font-medium |
|
78
|
{% if table.category == 'blue' %}text-blue-300{% elif table.category == 'indigo' %}text-indigo-300{% elif table.category == 'green' %}text-green-300{% elif table.category == 'purple' %}text-purple-300{% elif table.category == 'orange' %}text-orange-300{% else %}text-gray-300{% endif %}"> |
|
79
|
{{ table.name }} |
|
80
|
</span> |
|
81
|
<span class="text-xs text-gray-500 font-mono">{{ table.count }}</span> |
|
82
|
</div> |
|
83
|
</button> |
|
84
|
{% endfor %} |
|
85
|
</div> |
|
86
|
|
|
87
|
<!-- Table detail panel (populated by HTMX) --> |
|
88
|
<div id="table-detail"></div> |
|
89
|
|
|
90
|
<script> |
|
91
|
(function() { |
|
92
|
var tables = {{ tables_json|safe }}; |
|
93
|
var rels = {{ relationships_json|safe }}; |
|
94
|
|
|
95
|
var categoryColor = { |
|
96
|
blue: '#3b82f6', indigo: '#6366f1', green: '#22c55e', |
|
97
|
purple: '#a855f7', orange: '#f97316', gray: '#6b7280' |
|
98
|
}; |
|
99
|
|
|
100
|
var svg = document.getElementById('schema-viz'); |
|
101
|
if (!svg || tables.length === 0) return; |
|
102
|
|
|
103
|
// Layout: arrange tables in a grid, center the important ones. |
|
104
|
var cols = Math.min(tables.length, 8); |
|
105
|
var cellW = 130, cellH = 50, padX = 20, padY = 30; |
|
106
|
var totalW = cols * cellW + (cols - 1) * padX; |
|
107
|
var rows = Math.ceil(tables.length / cols); |
|
108
|
var totalH = rows * cellH + (rows - 1) * padY; |
|
109
|
|
|
110
|
svg.setAttribute('viewBox', '0 0 ' + (totalW + 40) + ' ' + (totalH + 40)); |
|
111
|
svg.style.minHeight = Math.max(200, totalH + 40) + 'px'; |
|
112
|
|
|
113
|
// Position each table. |
|
114
|
var positions = {}; |
|
115
|
tables.forEach(function(t, i) { |
|
116
|
var col = i % cols; |
|
117
|
var row = Math.floor(i / cols); |
|
118
|
var x = 20 + col * (cellW + padX); |
|
119
|
var y = 20 + row * (cellH + padY); |
|
120
|
positions[t.name] = { x: x + cellW / 2, y: y + cellH / 2 }; |
|
121
|
|
|
122
|
// Draw table box. |
|
123
|
var color = categoryColor[t.category] || categoryColor.gray; |
|
124
|
var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); |
|
125
|
rect.setAttribute('x', x); |
|
126
|
rect.setAttribute('y', y); |
|
127
|
rect.setAttribute('width', cellW); |
|
128
|
rect.setAttribute('height', cellH); |
|
129
|
rect.setAttribute('rx', 6); |
|
130
|
rect.setAttribute('fill', '#1f2937'); |
|
131
|
rect.setAttribute('stroke', color); |
|
132
|
rect.setAttribute('stroke-width', 1.5); |
|
133
|
rect.setAttribute('stroke-opacity', 0.6); |
|
134
|
svg.appendChild(rect); |
|
135
|
|
|
136
|
// Table name. |
|
137
|
var text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); |
|
138
|
text.setAttribute('x', x + cellW / 2); |
|
139
|
text.setAttribute('y', y + 22); |
|
140
|
text.setAttribute('text-anchor', 'middle'); |
|
141
|
text.setAttribute('fill', color); |
|
142
|
text.setAttribute('font-size', 11); |
|
143
|
text.setAttribute('font-family', 'ui-monospace, monospace'); |
|
144
|
text.setAttribute('font-weight', 600); |
|
145
|
text.textContent = t.name; |
|
146
|
svg.appendChild(text); |
|
147
|
|
|
148
|
// Row count. |
|
149
|
var cnt = document.createElementNS('http://www.w3.org/2000/svg', 'text'); |
|
150
|
cnt.setAttribute('x', x + cellW / 2); |
|
151
|
cnt.setAttribute('y', y + 38); |
|
152
|
cnt.setAttribute('text-anchor', 'middle'); |
|
153
|
cnt.setAttribute('fill', '#6b7280'); |
|
154
|
cnt.setAttribute('font-size', 9); |
|
155
|
cnt.setAttribute('font-family', 'ui-monospace, monospace'); |
|
156
|
cnt.textContent = t.count.toLocaleString() + ' rows'; |
|
157
|
svg.appendChild(cnt); |
|
158
|
}); |
|
159
|
|
|
160
|
// Draw relationship lines. |
|
161
|
rels.forEach(function(r) { |
|
162
|
var from = positions[r[0]]; |
|
163
|
var to = positions[r[1]]; |
|
164
|
if (!from || !to) return; |
|
165
|
|
|
166
|
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); |
|
167
|
line.setAttribute('x1', from.x); |
|
168
|
line.setAttribute('y1', from.y); |
|
169
|
line.setAttribute('x2', to.x); |
|
170
|
line.setAttribute('y2', to.y); |
|
171
|
line.setAttribute('stroke', '#4b5563'); |
|
172
|
line.setAttribute('stroke-width', 1); |
|
173
|
line.setAttribute('stroke-opacity', 0.5); |
|
174
|
line.setAttribute('stroke-dasharray', '4,3'); |
|
175
|
svg.insertBefore(line, svg.firstChild); |
|
176
|
|
|
177
|
// Midpoint label. |
|
178
|
var mx = (from.x + to.x) / 2; |
|
179
|
var my = (from.y + to.y) / 2; |
|
180
|
var label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); |
|
181
|
label.setAttribute('x', mx); |
|
182
|
label.setAttribute('y', my - 4); |
|
183
|
label.setAttribute('text-anchor', 'middle'); |
|
184
|
label.setAttribute('fill', '#6b7280'); |
|
185
|
label.setAttribute('font-size', 8); |
|
186
|
label.setAttribute('font-family', 'ui-monospace, monospace'); |
|
187
|
label.textContent = r[2]; |
|
188
|
svg.insertBefore(label, svg.firstChild); |
|
189
|
}); |
|
190
|
})(); |
|
191
|
</script> |
|
192
|
{% endblock %} |
|
193
|
|