Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ MCP_SERVER_VERSION=0.1.0

# Logging
LOG_LEVEL=INFO

# ── API ───────────────────────────────────────
OPENCRAB_API_KEY=test-key-12345
101 changes: 87 additions & 14 deletions apps/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import json
import os
import sqlite3
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -366,6 +367,11 @@ def require_auth(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
x_user_id: str | None = Header(default=None, alias="X-User-Id"),
) -> AuthContext:
require_auth_env = os.getenv("OPENCRAB_REQUIRE_AUTH", "true").strip().lower()
require_auth_enabled = require_auth_env not in {"0", "false", "no", "off"}
if not require_auth_enabled:
return AuthContext(user_id=x_user_id or "anonymous", tier=_tier())

expected_api_key = os.getenv("OPENCRAB_API_KEY", "").strip()
if not expected_api_key:
raise HTTPException(
Expand Down Expand Up @@ -756,19 +762,57 @@ def list_nodes(
) -> dict[str, Any]:
"""Return all nodes for graph visualization."""
try:
raw = ctx.graph.run_query(
"MATCH (n) OPTIONAL MATCH (n)-[r]-() "
"RETURN n.id AS id, n.space AS space, n.node_type AS node_type, "
"properties(n) AS props, count(r) AS degree "
"LIMIT 500"
)
raw: list[dict[str, Any]] = []
local_data_dir = Path(os.getenv("LOCAL_DATA_DIR", "./opencrab_data"))
if not local_data_dir.is_absolute():
local_data_dir = (REPO_ROOT / local_data_dir).resolve()
local_graph_db = local_data_dir / "graph.db"

if local_graph_db.exists():
with sqlite3.connect(str(local_graph_db)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
"""
SELECT
n.node_id AS id,
COALESCE(n.space_id, 'concept') AS space,
n.node_type AS node_type,
n.properties AS props,
(
SELECT COUNT(*)
FROM graph_edges e
WHERE e.from_id = n.node_id OR e.to_id = n.node_id
) AS degree
FROM graph_nodes n
LIMIT 500
"""
)
raw = [dict(row) for row in cur.fetchall()]
elif hasattr(ctx.graph, "run_query"):
raw = ctx.graph.run_query(
"MATCH (n) OPTIONAL MATCH (n)-[r]-() "
"RETURN n.id AS id, n.space AS space, n.node_type AS node_type, "
"properties(n) AS props, count(r) AS degree "
"LIMIT 500"
)

nodes = []
for row in (raw or []):
nid = row.get("id")
if not nid:
continue
props = {k: v for k, v in (row.get("props") or {}).items()
if k not in ("id", "space", "node_type")}
props_raw = row.get("props") or {}
if isinstance(props_raw, str):
try:
props_raw = json.loads(props_raw)
except Exception:
props_raw = {}
props = {
k: v
for k, v in (props_raw or {}).items()
if k not in ("id", "space", "node_type")
}
nodes.append({
"id": nid,
"space": row.get("space", "concept"),
Expand All @@ -789,12 +833,41 @@ def list_edges(
) -> dict[str, Any]:
"""Return all edges for graph visualization."""
try:
raw = ctx.graph.run_query(
"MATCH (a)-[r]->(b) "
"RETURN a.id AS from_id, b.id AS to_id, type(r) AS relation, "
"a.space AS from_space, b.space AS to_space "
"LIMIT 2000"
)
raw: list[dict[str, Any]] = []
local_data_dir = Path(os.getenv("LOCAL_DATA_DIR", "./opencrab_data"))
if not local_data_dir.is_absolute():
local_data_dir = (REPO_ROOT / local_data_dir).resolve()
local_graph_db = local_data_dir / "graph.db"

if local_graph_db.exists():
with sqlite3.connect(str(local_graph_db)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
"""
SELECT
e.from_id AS from_id,
e.to_id AS to_id,
e.relation AS relation,
COALESCE(fn.space_id, 'concept') AS from_space,
COALESCE(tn.space_id, 'concept') AS to_space
FROM graph_edges e
LEFT JOIN graph_nodes fn
ON fn.node_type = e.from_type AND fn.node_id = e.from_id
LEFT JOIN graph_nodes tn
ON tn.node_type = e.to_type AND tn.node_id = e.to_id
LIMIT 2000
"""
)
raw = [dict(row) for row in cur.fetchall()]
elif hasattr(ctx.graph, "run_query"):
raw = ctx.graph.run_query(
"MATCH (a)-[r]->(b) "
"RETURN a.id AS from_id, b.id AS to_id, type(r) AS relation, "
"a.space AS from_space, b.space AS to_space "
"LIMIT 2000"
)

edges = []
for row in (raw or []):
if not row.get("from_id") or not row.get("to_id"):
Expand Down
3 changes: 3 additions & 0 deletions apps/web/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
118 changes: 107 additions & 11 deletions apps/web/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import dynamic from 'next/dynamic'
import FileExplorer from '../../components/FileExplorer'
import RightPanel from '../../components/RightPanel'
import type { OcNode, OcEdge } from '../../lib/api'
import { getNodes, getEdges, getStatus } from '../../lib/api'
import LayerPanel from '../../components/LayerPanel'
import QueryInput from '../../components/QueryInput'
import type { OcNode, OcEdge, LayerMetadata, LayerData, LayerNode, LayerEdge } from '../../lib/api'
import { getNodes, getEdges, getStatus, getQueryLayers, getLayerData } from '../../lib/api'
import { searchLocalSubgraph } from '../../lib/queryLayerSearch'

const GraphView = dynamic(() => import('../../components/GraphView'), { ssr: false })

Expand Down Expand Up @@ -35,6 +38,13 @@ export default function DashboardPage() {
const [showIngest, setShowIngest] = useState(false)
const refreshTimer = useRef<ReturnType<typeof setInterval> | null>(null)

// Query layer state
const [layers, setLayers] = useState<LayerMetadata[]>([])
const [enabledLayerIds, setEnabledLayerIds] = useState<string[]>([])
const [layerDataMap, setLayerDataMap] = useState<Map<string, LayerData>>(new Map())
const [queryLayerNodes, setQueryLayerNodes] = useState<OcNode[]>([])
const [queryLayerEdges, setQueryLayerEdges] = useState<OcEdge[]>([])

// Load API key from localStorage
useEffect(() => {
const saved = localStorage.getItem('oc_api_key') || ''
Expand All @@ -49,17 +59,22 @@ export default function DashboardPage() {
const fetchData = useCallback(async () => {
const ok = await getStatus()
setConnected(ok.ok)
if (!apiKey) return
const [n, e] = await Promise.all([getNodes(apiKey), getEdges(apiKey)])
setNodes(n.filter(node => !controls.hiddenSpaces.includes(node.space)))
setEdges(e)
}, [apiKey, controls.hiddenSpaces])

const fetchLayers = useCallback(async () => {
const index = await getQueryLayers()
setLayers(index.layers)
}, [])

useEffect(() => {
fetchData()
fetchLayers()
refreshTimer.current = setInterval(fetchData, 30000)
return () => { if (refreshTimer.current) clearInterval(refreshTimer.current) }
}, [fetchData])
}, [fetchData, fetchLayers])

const selectedNode = nodes.find(n => n.id === selectedId) ?? null

Expand All @@ -71,6 +86,73 @@ export default function DashboardPage() {
setControls(p => ({ ...p, ...partial }))
}

async function handleLayerToggle(layerId: string) {
if (enabledLayerIds.includes(layerId)) {
// Disable layer
setEnabledLayerIds(ids => ids.filter(id => id !== layerId))
setLayerDataMap(map => {
const newMap = new Map(map)
newMap.delete(layerId)
return newMap
})
} else {
// Enable layer - fetch data
setEnabledLayerIds(ids => [...ids, layerId])
const data = await getLayerData(layerId)
if (data) {
setLayerDataMap(map => new Map(map).set(layerId, data))
}
}
}

function handleQueryRun(query: string) {
// Collect all nodes and edges from enabled layers
const allLayerNodes: LayerNode[] = []
const allLayerEdges: LayerEdge[] = []

for (const layerId of enabledLayerIds) {
const data = layerDataMap.get(layerId)
if (data) {
allLayerNodes.push(...data.nodes)
allLayerEdges.push(...data.edges)
}
}

// Run local subgraph search
const result = searchLocalSubgraph(allLayerNodes, allLayerEdges, query, 2)

// Convert LayerNode to OcNode and LayerEdge to OcEdge for rendering
// Collect all node IDs referenced by result edges plus seeds
const overlayNodeIds = new Set<string>(result.seeds.map(n => n.id))
for (const edge of result.edges) {
overlayNodeIds.add(edge.from)
overlayNodeIds.add(edge.to)
}
// Map allLayerNodes by id for fast lookup
const nodeMap = new Map(allLayerNodes.map(n => [n.id, n]))
const ocNodes: OcNode[] = Array.from(overlayNodeIds)
.map(id => nodeMap.get(id))
.filter((ln): ln is LayerNode => Boolean(ln))
.map(ln => ({
id: ln.id,
space: 'query_layer',
node_type: 'layer_node',
properties: { name: ln.label, ...ln.metadata },
degree: 0,
}))

const ocEdges: OcEdge[] = result.edges.map(le => ({
from_id: le.from,
to_id: le.to,
relation: le.relation || 'related',
from_space: 'query_layer',
to_space: 'query_layer',
}))

setQueryLayerNodes(ocNodes)
setQueryLayerEdges(ocEdges)
}

return (
<div style={{
display: 'flex', height: '100vh', width: '100vw',
Expand Down Expand Up @@ -101,6 +183,7 @@ export default function DashboardPage() {
<span style={{ fontSize: 11, color: '#3a3a3a' }}>|</span>
<span style={{ fontSize: 11, color: '#7c6f64' }}>
{nodes.length} nodes · {edges.length} edges
{queryLayerNodes.length > 0 && ` · ${queryLayerNodes.length} layer nodes`}
</span>
<div style={{ flex: 1 }} />
<input
Expand All @@ -127,6 +210,8 @@ export default function DashboardPage() {
centerForce={controls.centerForce}
repelForce={controls.repelForce}
onNodeClick={handleNodeClick}
layerNodes={queryLayerNodes}
layerEdges={queryLayerEdges}
/>
</div>

Expand All @@ -152,13 +237,24 @@ export default function DashboardPage() {
</div>

{/* Right — Controls & Detail */}
<RightPanel
selectedNode={selectedNode}
controls={controls}
onControlChange={handleControlChange}
apiKey={apiKey}
onRefresh={fetchData}
/>
<div style={{ width: 340, background: '#0f0f0f', borderLeft: '1px solid rgba(248,197,55,0.12)', overflowY: 'auto' }}>
<div style={{ padding: 14 }}>
<LayerPanel
layers={layers}
enabledLayerIds={enabledLayerIds}
onToggle={handleLayerToggle}
onRefresh={fetchLayers}
/>
<QueryInput onRun={handleQueryRun} />
</div>
<RightPanel
selectedNode={selectedNode}
controls={controls}
onControlChange={handleControlChange}
apiKey={apiKey}
onRefresh={fetchData}
/>
</div>

{/* Ingest Modal */}
{showIngest && (
Expand Down
26 changes: 21 additions & 5 deletions apps/web/components/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ interface Props {
centerForce: number
repelForce: number
onNodeClick: (node: OcNode) => void
layerNodes?: OcNode[]
layerEdges?: OcEdge[]
}

export default function GraphView({
Expand All @@ -160,6 +162,8 @@ export default function GraphView({
centerForce,
repelForce,
onNodeClick,
layerNodes = [],
layerEdges = [],
}: Props) {
const svgRef = useRef<SVGSVGElement>(null)
const tooltipRef = useRef<HTMLDivElement>(null)
Expand All @@ -184,15 +188,27 @@ export default function GraphView({
.on('zoom', (event) => g.attr('transform', event.transform)),
)

if (nodes.length === 0) return
// Merge base nodes with layer nodes
const mergedNodes = [...nodes]
const baseNodeIds = new Set(nodes.map(n => n.id))
for (const layerNode of layerNodes) {
if (!baseNodeIds.has(layerNode.id)) {
mergedNodes.push(layerNode)
}
}

// Merge base edges with layer edges
const mergedEdges = [...edges, ...layerEdges]

if (mergedNodes.length === 0) return

const idMap = new Map(nodes.map((node, index) => [node.id, index]))
const idMap = new Map(mergedNodes.map((node, index) => [node.id, index]))

type SimNode = OcNode & d3.SimulationNodeDatum
type SimLink = { source: SimNode; target: SimNode; relation: string }

const simNodes: SimNode[] = nodes.map((node) => ({ ...node }))
const simLinks: SimLink[] = edges
const simNodes: SimNode[] = mergedNodes.map((node) => ({ ...node }))
const simLinks: SimLink[] = mergedEdges
.filter((edge) => idMap.has(edge.from_id) && idMap.has(edge.to_id))
.map((edge) => ({
source: simNodes[idMap.get(edge.from_id)!],
Expand Down Expand Up @@ -313,7 +329,7 @@ export default function GraphView({
node.attr('cx', (d) => d.x!).attr('cy', (d) => d.y!)
label.attr('x', (d) => d.x!).attr('y', (d) => d.y!)
})
}, [nodes, edges, selectedId, searchTerm, nodeSize, linkStrength, centerForce, repelForce, onNodeClick])
}, [nodes, edges, selectedId, searchTerm, nodeSize, linkStrength, centerForce, repelForce, onNodeClick, layerNodes, layerEdges])

useEffect(() => {
draw()
Expand Down
Loading