Skip to content

Commit cf86baf

Browse files
aksOpsclaude
andcommitted
feat: add Phase 5 — Thymeleaf + HTMX web UI for graph exploration
Server-rendered explorer UI at /ui with dark/light theme, HTMX-powered fragment loading, search, pagination, and responsive card grid design. Active only under the "serving" Spring profile. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f464364 commit cf86baf

13 files changed

Lines changed: 1282 additions & 0 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package io.github.randomcodespace.iq.web;
2+
3+
import io.github.randomcodespace.iq.query.QueryService;
4+
import org.springframework.context.annotation.Profile;
5+
import org.springframework.stereotype.Controller;
6+
import org.springframework.ui.Model;
7+
import org.springframework.web.bind.annotation.GetMapping;
8+
import org.springframework.web.bind.annotation.PathVariable;
9+
import org.springframework.web.bind.annotation.RequestMapping;
10+
import org.springframework.web.bind.annotation.RequestParam;
11+
12+
import java.util.List;
13+
import java.util.Map;
14+
15+
/**
16+
* Thymeleaf-based web UI controller for exploring the code knowledge graph.
17+
* Only active when the "serving" profile is enabled (i.e. during {@code osscodeiq serve}).
18+
*
19+
* <p>Full-page routes live under {@code /ui}, HTMX fragment routes under {@code /ui/fragments}.
20+
*/
21+
@Controller
22+
@Profile("serving")
23+
@RequestMapping("/ui")
24+
public class ExplorerController {
25+
26+
private final QueryService queryService;
27+
28+
public ExplorerController(QueryService queryService) {
29+
this.queryService = queryService;
30+
}
31+
32+
// ---- Full-page routes ----
33+
34+
@GetMapping({"", "/"})
35+
public String index(Model model) {
36+
model.addAttribute("stats", queryService.getStats());
37+
model.addAttribute("kinds", queryService.listKinds());
38+
return "explorer/index";
39+
}
40+
41+
@GetMapping("/kinds/{kind}")
42+
public String nodesByKind(
43+
@PathVariable String kind,
44+
@RequestParam(defaultValue = "50") int limit,
45+
@RequestParam(defaultValue = "0") int offset,
46+
Model model) {
47+
model.addAttribute("result", queryService.nodesByKind(kind, limit, offset));
48+
model.addAttribute("kind", kind);
49+
return "explorer/nodes";
50+
}
51+
52+
@GetMapping("/node/{nodeId}")
53+
public String nodeDetail(@PathVariable String nodeId, Model model) {
54+
Map<String, Object> detail = queryService.nodeDetailWithEdges(nodeId);
55+
model.addAttribute("detail", detail);
56+
return "explorer/detail";
57+
}
58+
59+
// ---- HTMX fragment routes ----
60+
61+
@GetMapping("/fragments/kinds")
62+
public String kindsFragment(Model model) {
63+
model.addAttribute("kinds", queryService.listKinds());
64+
return "explorer/fragments/kinds-grid";
65+
}
66+
67+
@GetMapping("/fragments/nodes/{kind}")
68+
public String nodesFragment(
69+
@PathVariable String kind,
70+
@RequestParam(defaultValue = "50") int limit,
71+
@RequestParam(defaultValue = "0") int offset,
72+
Model model) {
73+
model.addAttribute("result", queryService.nodesByKind(kind, limit, offset));
74+
model.addAttribute("kind", kind);
75+
return "explorer/fragments/nodes-grid";
76+
}
77+
78+
@GetMapping("/fragments/detail/{nodeId}")
79+
public String detailFragment(@PathVariable String nodeId, Model model) {
80+
Map<String, Object> detail = queryService.nodeDetailWithEdges(nodeId);
81+
model.addAttribute("detail", detail);
82+
return "explorer/fragments/detail-panel";
83+
}
84+
85+
@GetMapping("/fragments/search")
86+
public String searchFragment(
87+
@RequestParam String q,
88+
@RequestParam(defaultValue = "50") int limit,
89+
Model model) {
90+
List<Map<String, Object>> results = queryService.searchGraph(q, limit);
91+
model.addAttribute("results", results);
92+
model.addAttribute("query", q);
93+
return "explorer/fragments/search-results";
94+
}
95+
96+
@GetMapping("/fragments/breadcrumb")
97+
public String breadcrumbFragment(
98+
@RequestParam(required = false) String kind,
99+
@RequestParam(required = false) String nodeId,
100+
@RequestParam(required = false) String nodeLabel,
101+
Model model) {
102+
model.addAttribute("kind", kind);
103+
model.addAttribute("nodeId", nodeId);
104+
model.addAttribute("nodeLabel", nodeLabel);
105+
return "explorer/fragments/breadcrumb";
106+
}
107+
}

src/main/resources/static/js/alpine.min.js

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/resources/static/js/htmx.min.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<!DOCTYPE html>
2+
<html xmlns:th="http://www.thymeleaf.org" lang="en"
3+
x-data="themeManager()" :class="{ 'dark': dark }">
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1">
7+
<title th:text="${detail != null ? detail['label'] + ' | OSSCodeIQ' : 'Node Detail | OSSCodeIQ'}">Node Detail | OSSCodeIQ</title>
8+
9+
<script src="https://cdn.tailwindcss.com"></script>
10+
<script>
11+
tailwind.config = {
12+
darkMode: 'class',
13+
theme: {
14+
extend: {
15+
colors: {
16+
brand: { 50: '#eff6ff', 100: '#dbeafe', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 900: '#1e3a5f' },
17+
surface: { DEFAULT: '#f8fafc', dark: '#0f172a' },
18+
card: { DEFAULT: '#ffffff', dark: '#1e293b' },
19+
muted: { DEFAULT: '#64748b', dark: '#94a3b8' }
20+
}
21+
}
22+
}
23+
}
24+
</script>
25+
<script th:src="@{/js/htmx.min.js}"></script>
26+
<script defer th:src="@{/js/alpine.min.js}"></script>
27+
<style>[x-cloak] { display: none !important; }</style>
28+
<script>
29+
function themeManager() {
30+
return {
31+
dark: localStorage.getItem('codeiq-theme') === 'dark' ||
32+
(!localStorage.getItem('codeiq-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches),
33+
toggle() { this.dark = !this.dark; localStorage.setItem('codeiq-theme', this.dark ? 'dark' : 'light'); }
34+
}
35+
}
36+
</script>
37+
</head>
38+
<body class="bg-surface dark:bg-surface-dark text-slate-900 dark:text-slate-100 min-h-screen transition-colors duration-200">
39+
40+
<!-- Header -->
41+
<header class="sticky top-0 z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border-b border-slate-200 dark:border-slate-700">
42+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
43+
<div class="flex items-center justify-between h-14">
44+
<a th:href="@{/ui}" class="flex items-center gap-2 text-lg font-bold text-brand-600 dark:text-brand-400 transition-colors">
45+
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/></svg>
46+
OSSCodeIQ
47+
</a>
48+
<button @click="toggle()" class="p-2 rounded-lg text-muted dark:text-muted-dark hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" aria-label="Toggle theme">
49+
<svg x-show="!dark" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
50+
<svg x-show="dark" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
51+
</button>
52+
</div>
53+
</div>
54+
</header>
55+
56+
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
57+
58+
<!-- Breadcrumb -->
59+
<nav class="mb-6" aria-label="Breadcrumb">
60+
<ol class="flex items-center gap-1 text-sm text-muted dark:text-muted-dark">
61+
<li><a th:href="@{/ui}" class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">Explorer</a></li>
62+
<li class="mx-1">/</li>
63+
<li th:if="${detail != null}">
64+
<a th:href="@{/ui/kinds/{kind}(kind=${detail['kind']})}"
65+
class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors capitalize"
66+
th:text="${detail['kind']}">kind</a>
67+
</li>
68+
<li class="mx-1" th:if="${detail != null}">/</li>
69+
<li class="font-medium text-slate-900 dark:text-slate-100"
70+
th:text="${detail != null ? detail['label'] : 'Not Found'}">Node</li>
71+
</ol>
72+
</nav>
73+
74+
<div th:if="${detail == null}" class="text-center py-16">
75+
<p class="text-muted dark:text-muted-dark text-lg">Node not found.</p>
76+
<a th:href="@{/ui}" class="mt-4 inline-block text-brand-600 dark:text-brand-400 hover:underline">Back to Explorer</a>
77+
</div>
78+
79+
<div th:if="${detail != null}">
80+
<div th:replace="~{explorer/fragments/detail-panel :: content}"></div>
81+
</div>
82+
83+
</main>
84+
</body>
85+
</html>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<!DOCTYPE html>
2+
<html xmlns:th="http://www.thymeleaf.org">
3+
<body>
4+
<nav th:fragment="content" class="mb-4" aria-label="Breadcrumb">
5+
<ol class="flex items-center gap-1 text-sm text-muted dark:text-muted-dark">
6+
<li>
7+
<a th:href="@{/ui}" hx-get="/ui/fragments/kinds" hx-target="#content" hx-swap="innerHTML"
8+
class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">Explorer</a>
9+
</li>
10+
<li th:if="${kind != null}" class="mx-1">/</li>
11+
<li th:if="${kind != null}">
12+
<a th:if="${nodeId != null}"
13+
th:href="@{/ui/kinds/{k}(k=${kind})}"
14+
th:attr="hx-get='/ui/fragments/nodes/' + ${kind}"
15+
hx-target="#content" hx-swap="innerHTML"
16+
class="hover:text-brand-600 dark:hover:text-brand-400 transition-colors capitalize"
17+
th:text="${kind}">kind</a>
18+
<span th:if="${nodeId == null}" class="font-medium text-slate-900 dark:text-slate-100 capitalize"
19+
th:text="${kind}">kind</span>
20+
</li>
21+
<li th:if="${nodeId != null}" class="mx-1">/</li>
22+
<li th:if="${nodeId != null}" class="font-medium text-slate-900 dark:text-slate-100"
23+
th:text="${nodeLabel != null ? nodeLabel : nodeId}">node</li>
24+
</ol>
25+
</nav>
26+
</body>
27+
</html>
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<!DOCTYPE html>
2+
<html xmlns:th="http://www.thymeleaf.org">
3+
<body>
4+
<div th:fragment="content">
5+
6+
<!-- Back navigation (for HTMX fragment loads) -->
7+
<div class="mb-4">
8+
<button hx-get="/ui/fragments/kinds" hx-target="#content" hx-swap="innerHTML"
9+
class="text-sm text-brand-600 dark:text-brand-400 hover:underline flex items-center gap-1">
10+
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg>
11+
Back
12+
</button>
13+
</div>
14+
15+
<div th:if="${detail == null}" class="text-center py-12">
16+
<p class="text-muted dark:text-muted-dark">Node not found.</p>
17+
</div>
18+
19+
<div th:if="${detail != null}" class="space-y-6">
20+
21+
<!-- Node header card -->
22+
<div class="bg-card dark:bg-card-dark rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
23+
<div class="h-1 bg-gradient-to-r from-brand-400 to-brand-600"></div>
24+
<div class="p-6">
25+
<div class="flex items-start justify-between gap-4">
26+
<div>
27+
<h1 class="text-xl font-bold mb-1" th:text="${detail['label']}">Label</h1>
28+
<div class="flex items-center gap-2 text-sm text-muted dark:text-muted-dark">
29+
<span class="capitalize font-medium" th:text="${detail['kind']}">kind</span>
30+
<span th:if="${detail['layer'] != null}"
31+
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium uppercase tracking-wide bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300"
32+
th:text="${detail['layer']}">layer</span>
33+
</div>
34+
</div>
35+
<div class="text-right text-xs text-muted dark:text-muted-dark">
36+
<div class="font-mono select-all break-all max-w-xs" th:text="${detail['id']}">id</div>
37+
</div>
38+
</div>
39+
</div>
40+
</div>
41+
42+
<!-- Properties table -->
43+
<div class="bg-card dark:bg-card-dark rounded-lg border border-slate-200 dark:border-slate-700 p-6">
44+
<h2 class="font-semibold mb-4">Properties</h2>
45+
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
46+
<div th:if="${detail['fqn'] != null}">
47+
<dt class="text-muted dark:text-muted-dark">FQN</dt>
48+
<dd class="font-mono text-xs select-all break-all" th:text="${detail['fqn']}">fqn</dd>
49+
</div>
50+
<div th:if="${detail['module'] != null}">
51+
<dt class="text-muted dark:text-muted-dark">Module</dt>
52+
<dd th:text="${detail['module']}">module</dd>
53+
</div>
54+
<div th:if="${detail['file_path'] != null}">
55+
<dt class="text-muted dark:text-muted-dark">File</dt>
56+
<dd class="font-mono text-xs select-all break-all" th:text="${detail['file_path']}">file</dd>
57+
</div>
58+
<div th:if="${detail['line_start'] != null}">
59+
<dt class="text-muted dark:text-muted-dark">Lines</dt>
60+
<dd th:text="${detail['line_start'] + (detail['line_end'] != null ? ' - ' + detail['line_end'] : '')}">1-10</dd>
61+
</div>
62+
</dl>
63+
64+
<!-- Annotations -->
65+
<div th:if="${detail['annotations'] != null and !detail['annotations'].isEmpty()}" class="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
66+
<h3 class="text-sm font-medium text-muted dark:text-muted-dark mb-2">Annotations</h3>
67+
<div class="flex flex-wrap gap-1.5">
68+
<span th:each="ann : ${detail['annotations']}"
69+
class="text-xs px-2 py-0.5 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 font-mono select-all"
70+
th:text="${ann}">@annotation</span>
71+
</div>
72+
</div>
73+
74+
<!-- Extra properties -->
75+
<div th:if="${detail['properties'] != null and !detail['properties'].isEmpty()}" class="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
76+
<h3 class="text-sm font-medium text-muted dark:text-muted-dark mb-2">Extra Properties</h3>
77+
<dl class="space-y-1 text-sm">
78+
<div th:each="prop : ${detail['properties']}" class="flex gap-2">
79+
<dt class="text-muted dark:text-muted-dark font-mono text-xs min-w-[120px]" th:text="${prop.key}">key</dt>
80+
<dd class="font-mono text-xs select-all break-all" th:text="${prop.value}">value</dd>
81+
</div>
82+
</dl>
83+
</div>
84+
</div>
85+
86+
<!-- Outgoing edges -->
87+
<div class="bg-card dark:bg-card-dark rounded-lg border border-slate-200 dark:border-slate-700 p-6">
88+
<h2 class="font-semibold mb-4">
89+
Outgoing Edges
90+
<span th:if="${detail['outgoing_edges'] != null}"
91+
class="text-sm font-normal text-muted dark:text-muted-dark"
92+
th:text="'(' + ${#lists.size(detail['outgoing_edges'])} + ')'">
93+
(0)
94+
</span>
95+
</h2>
96+
<div th:if="${detail['outgoing_edges'] == null or detail['outgoing_edges'].isEmpty()}"
97+
class="text-sm text-muted dark:text-muted-dark italic">
98+
No outgoing edges.
99+
</div>
100+
<div th:if="${detail['outgoing_edges'] != null and !detail['outgoing_edges'].isEmpty()}" class="space-y-2">
101+
<div th:each="edge : ${detail['outgoing_edges']}"
102+
class="flex items-center gap-3 text-sm p-2 rounded hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
103+
<span class="text-xs font-mono px-2 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 uppercase"
104+
th:text="${edge['kind']}">CALLS</span>
105+
<svg class="w-4 h-4 text-muted dark:text-muted-dark shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
106+
<a th:if="${edge['target'] != null}"
107+
th:href="@{/ui/node/{id}(id=${edge['target']})}"
108+
class="text-brand-600 dark:text-brand-400 hover:underline font-mono text-xs truncate"
109+
th:text="${edge['target']}">target</a>
110+
<span th:if="${edge['target'] == null}" class="text-muted dark:text-muted-dark text-xs italic">unknown target</span>
111+
</div>
112+
</div>
113+
</div>
114+
115+
<!-- Incoming nodes -->
116+
<div class="bg-card dark:bg-card-dark rounded-lg border border-slate-200 dark:border-slate-700 p-6">
117+
<h2 class="font-semibold mb-4">
118+
Incoming Nodes
119+
<span th:if="${detail['incoming_nodes'] != null}"
120+
class="text-sm font-normal text-muted dark:text-muted-dark"
121+
th:text="'(' + ${#lists.size(detail['incoming_nodes'])} + ')'">
122+
(0)
123+
</span>
124+
</h2>
125+
<div th:if="${detail['incoming_nodes'] == null or detail['incoming_nodes'].isEmpty()}"
126+
class="text-sm text-muted dark:text-muted-dark italic">
127+
No incoming nodes.
128+
</div>
129+
<div th:if="${detail['incoming_nodes'] != null and !detail['incoming_nodes'].isEmpty()}" class="space-y-2">
130+
<div th:each="node : ${detail['incoming_nodes']}"
131+
class="flex items-center gap-3 text-sm p-2 rounded hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
132+
<span class="text-xs font-mono px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 capitalize"
133+
th:text="${node['kind']}">class</span>
134+
<svg class="w-4 h-4 text-muted dark:text-muted-dark shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
135+
<a th:href="@{/ui/node/{id}(id=${node['id']})}"
136+
class="text-brand-600 dark:text-brand-400 hover:underline truncate"
137+
th:text="${node['label']}">label</a>
138+
</div>
139+
</div>
140+
</div>
141+
</div>
142+
</div>
143+
</body>
144+
</html>

0 commit comments

Comments
 (0)