bootstrap datagen and graphgen

This commit is contained in:
2026-04-16 18:59:30 -03:00
parent a80b72a9b1
commit ec3391fe72
9 changed files with 1728 additions and 1 deletions

View File

@@ -519,6 +519,18 @@ try:
except ImportError as e: except ImportError as e:
print(f"Warning: Could not load tester router: {e}") print(f"Warning: Could not load tester router: {e}")
try:
from station.tools.graphgen.api import router as graphgen_router
app.include_router(graphgen_router, prefix="/station")
except ImportError as e:
print(f"Warning: Could not load graphgen router: {e}")
try:
from station.tools.datagen.api import router as datagen_router
app.include_router(datagen_router, prefix="/station")
except ImportError as e:
print(f"Warning: Could not load datagen router: {e}")
@app.get("/station/{path:path}") @app.get("/station/{path:path}")
def station_route(path: str): def station_route(path: str):

View File

@@ -1 +1,5 @@
"""Datagen - Test data generator for Amar domain models.""" """Datagen — test data generator. Room generators extend BaseDataGenerator."""
from .base import BaseDataGenerator
__all__ = ["BaseDataGenerator"]

View File

@@ -0,0 +1,180 @@
"""FastAPI router for datagen — test data generator."""
import importlib.util
import logging
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
log = logging.getLogger(__name__)
router = APIRouter(prefix="/tools/datagen", tags=["datagen"])
# soleprint/ root
SPR_ROOT = Path(__file__).parents[3]
# Loaded generators: name → instance
_generators: dict[str, Any] = {}
# ─────────────────────────────────────────────────────
# Generator loading
# ─────────────────────────────────────────────────────
def _load_generators() -> dict[str, Any]:
"""
Scan station/tools/datagen/ for generator classes and instantiate them.
A generator file should define a class whose name ends in "Generator".
"""
datagen_dir = SPR_ROOT / "station" / "tools" / "datagen"
result = {}
for py_file in sorted(datagen_dir.glob("*.py")):
if py_file.name.startswith("_") or py_file.stem in ("base", "api"):
continue
try:
spec = importlib.util.spec_from_file_location(py_file.stem, py_file)
if not spec or not spec.loader:
continue
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# Find generator class (ends with Generator, not the base)
for name, obj in vars(mod).items():
if (
isinstance(obj, type)
and name.endswith("Generator")
and name != "BaseDataGenerator"
):
result[py_file.stem] = obj()
log.info(f"datagen: loaded {name} from {py_file.name}")
break
except Exception as e:
log.warning(f"datagen: could not load {py_file.name}: {e}")
return result
def _get_generators() -> dict[str, Any]:
"""Return cached generators, loading on first call."""
if not _generators:
_generators.update(_load_generators())
return _generators
def _pick_generator(name: str | None) -> tuple[str, Any]:
"""
Pick a generator by name or use the only available one.
Returns (name, instance). Raises HTTPException if not found.
"""
gens = _get_generators()
if not gens:
raise HTTPException(status_code=503, detail="No generators loaded")
if name:
if name not in gens:
raise HTTPException(
status_code=404,
detail=f"Generator '{name}' not found. Available: {list(gens)}"
)
return name, gens[name]
# Default: first (usually only) generator
key = next(iter(gens))
return key, gens[key]
# ─────────────────────────────────────────────────────
# Pydantic models
# ─────────────────────────────────────────────────────
class GenerateRequest(BaseModel):
model: str
count: int = 1
generator: str | None = None
kwargs: dict = {}
# ─────────────────────────────────────────────────────
# Routes
# ─────────────────────────────────────────────────────
@router.get("/", response_class=HTMLResponse)
def index():
html = Path(__file__).parent / "templates" / "index.html"
return HTMLResponse(html.read_text() if html.exists() else "<h1>datagen</h1>")
@router.get("/health")
def health():
return {"status": "ok", "tool": "datagen"}
@router.get("/api/generators")
def list_generators():
"""List loaded generator files."""
gens = _get_generators()
return {
"generators": [
{
"name": k,
"models": v.available_models() if hasattr(v, "available_models") else [],
}
for k, v in gens.items()
]
}
@router.get("/api/models")
def list_models(generator: str | None = None):
"""List available model generators."""
_, gen = _pick_generator(generator)
if hasattr(gen, "available_models"):
models = gen.available_models()
else:
models = [
n for n in dir(gen)
if not n.startswith("_") and callable(getattr(gen, n))
]
return {"models": models}
@router.post("/api/generate")
def generate(req: GenerateRequest):
"""Generate test data for a model."""
gen_name, gen = _pick_generator(req.generator)
try:
if hasattr(gen, "generate"):
items = gen.generate(req.model, req.count, **req.kwargs)
else:
method = getattr(gen, req.model.lower(), None)
if not method:
raise ValueError(f"No method '{req.model.lower()}' on generator")
items = [method(**req.kwargs) for _ in range(req.count)]
except (ValueError, TypeError) as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
log.exception(f"datagen: generate error")
raise HTTPException(status_code=500, detail=str(e))
return {
"generator": gen_name,
"model": req.model,
"count": len(items),
"items": items,
}
@router.get("/api/schema")
def get_schema(generator: str | None = None):
"""Return graphgen-compatible schema from the generator (if it provides one)."""
_, gen = _pick_generator(generator)
if hasattr(gen, "schema"):
s = gen.schema()
if s:
return s
return {"models": {}, "note": "This generator does not expose a schema."}

View File

@@ -0,0 +1,80 @@
"""
BaseDataGenerator — base class for room-specific data generators.
Room generators extend this class and define methods named after their models:
class MyGenerator(BaseDataGenerator):
def user(self, **kwargs):
return {"id": uuid4(), "name": fake.name(), **kwargs}
def product(self, category=None, **kwargs):
return {"id": uuid4(), "name": fake.word(), "category": category, **kwargs}
The base class provides:
- generate(model, count, **kwargs) — call the right method N times
- available_models() — list of method names (= model names)
- schema() — override to return graphgen-compatible schema
"""
class BaseDataGenerator:
"""Base class for room-specific data generators."""
# Attribute names that are NOT model generators
_RESERVED = frozenset({"generate", "available_models", "schema"})
def generate(self, model: str, count: int = 1, **kwargs) -> list[dict]:
"""
Generate `count` instances of the given model.
Args:
model: Model name (case-insensitive, matches method name).
count: Number of items to generate.
**kwargs: Passed through to the model method on every call.
Returns:
List of generated dicts.
Raises:
ValueError if no generator exists for the model.
"""
method = getattr(self, model.lower(), None)
if method is None or not callable(method):
available = self.available_models()
raise ValueError(
f"No generator for '{model}'. "
f"Available: {available if available else '(none)'}"
)
return [method(**kwargs) for _ in range(count)]
def available_models(self) -> list[str]:
"""Return sorted list of model names this generator supports."""
return sorted(
name for name in dir(self)
if not name.startswith("_")
and name not in self._RESERVED
and callable(getattr(self, name))
)
def schema(self) -> dict | None:
"""
Optional: return a graphgen-compatible schema dict.
Override to integrate with graphgen's /api/schema — datagen will
surface this via /tools/datagen/api/schema.
Expected format:
{
"models": {
"User": {
"doc": "...",
"fields": {
"id": {"type": "UUID", "pk": true},
"email": {"type": "str"},
"org_id": {"type": "FK:Organization"}
}
}
}
}
"""
return None

View File

@@ -0,0 +1,497 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>datagen — Test Data Generator</title>
<style>
:root {
--bg: #0a0a0a;
--surface: #1a1a1a;
--border: #333;
--text: #e5e5e5;
--muted: #a3a3a3;
--dim: #666;
--amber: #d4a574;
--amber-dim: #b8956a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Top bar ── */
#topbar {
height: 44px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 12px;
flex-shrink: 0;
}
#topbar h1 { font-size: 14px; font-weight: 600; color: var(--amber); letter-spacing: .05em; }
#topbar-info { color: var(--dim); font-size: 12px; }
/* ── Main ── */
#main {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Left panel: controls ── */
#controls {
width: 280px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
flex-shrink: 0;
}
.panel-section {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.panel-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--dim);
margin-bottom: 8px;
}
select, input[type="number"], input[type="text"] {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 7px 10px;
border-radius: 6px;
font-size: 13px;
outline: none;
transition: border-color .15s;
}
select:focus, input:focus { border-color: var(--amber); }
select option { background: var(--surface); }
.count-row {
display: flex;
gap: 8px;
align-items: center;
}
.count-row input { flex: 1; }
.quick-btns {
display: flex;
gap: 6px;
margin-top: 8px;
}
.quick-btn {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
color: var(--muted);
padding: 5px 0;
border-radius: 5px;
cursor: pointer;
font-size: 11px;
text-align: center;
transition: border-color .12s, color .12s;
}
.quick-btn:hover { border-color: var(--amber); color: var(--amber); }
.quick-btn.active { border-color: var(--amber); color: var(--amber); background: #d4a57411; }
/* kwargs editor */
#kwargs-list { display: flex; flex-direction: column; gap: 6px; }
.kwarg-row {
display: flex;
gap: 6px;
}
.kwarg-row input {
padding: 5px 8px;
font-size: 12px;
}
.kwarg-key { width: 90px; flex-shrink: 0; }
.kwarg-val { flex: 1; }
.kwarg-remove {
background: transparent;
border: 1px solid var(--border);
color: var(--dim);
padding: 0 7px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
flex-shrink: 0;
}
.kwarg-remove:hover { border-color: #b91c1c; color: #fca5a5; }
.add-kwarg-btn {
background: transparent;
border: 1px dashed var(--border);
color: var(--dim);
padding: 5px;
border-radius: 5px;
cursor: pointer;
font-size: 11px;
width: 100%;
margin-top: 4px;
transition: border-color .12s, color .12s;
}
.add-kwarg-btn:hover { border-color: var(--amber); color: var(--amber); }
/* Generate button */
#generate-btn {
margin: 14px 16px;
width: calc(100% - 32px);
padding: 9px;
background: linear-gradient(135deg, var(--amber), var(--amber-dim));
border: none;
border-radius: 7px;
color: #0a0a0a;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: opacity .15s;
}
#generate-btn:hover { opacity: .9; }
#generate-btn:disabled { opacity: .4; cursor: not-allowed; }
/* ── Right panel: output ── */
#output-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#output-header {
padding: 10px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
#output-meta { color: var(--dim); font-size: 12px; flex: 1; }
#copy-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 4px 10px;
border-radius: 5px;
cursor: pointer;
font-size: 11px;
transition: border-color .12s, color .12s;
}
#copy-btn:hover { border-color: var(--amber); color: var(--amber); }
#copy-btn.copied { border-color: #15803d; color: #86efac; }
#output-area {
flex: 1;
overflow: auto;
padding: 16px;
}
#output-json {
font-family: monospace;
font-size: 12px;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
word-break: break-all;
}
/* Syntax-ish coloring */
.j-key { color: #93c5fd; }
.j-str { color: #86efac; }
.j-num { color: #fca5a5; }
.j-bool { color: var(--amber); }
.j-null { color: var(--dim); }
.j-punct { color: var(--dim); }
#placeholder {
color: var(--dim);
text-align: center;
margin-top: 80px;
font-size: 13px;
}
#placeholder .hint {
font-size: 11px;
margin-top: 6px;
}
#error-msg {
color: #fca5a5;
background: #b91c1c18;
border: 1px solid #b91c1c44;
border-radius: 6px;
padding: 10px 14px;
margin: 16px;
font-size: 12px;
display: none;
}
</style>
</head>
<body>
<div id="topbar">
<h1>datagen</h1>
<span></span>
<span id="topbar-info">loading…</span>
</div>
<div id="main">
<div id="controls">
<!-- Model select -->
<div class="panel-section">
<div class="panel-label">Model</div>
<select id="model-select">
<option value="">— select a model —</option>
</select>
</div>
<!-- Count -->
<div class="panel-section">
<div class="panel-label">Count</div>
<div class="count-row">
<input type="number" id="count-input" value="1" min="1" max="100" />
</div>
<div class="quick-btns">
<div class="quick-btn" onclick="setCount(1)">1</div>
<div class="quick-btn" onclick="setCount(5)">5</div>
<div class="quick-btn" onclick="setCount(10)">10</div>
<div class="quick-btn" onclick="setCount(25)">25</div>
</div>
</div>
<!-- kwargs -->
<div class="panel-section">
<div class="panel-label">Override fields (optional)</div>
<div id="kwargs-list"></div>
<button class="add-kwarg-btn" onclick="addKwarg()">+ add field override</button>
</div>
<button id="generate-btn" onclick="generate()">Generate</button>
</div>
<!-- Output -->
<div id="output-panel">
<div id="output-header">
<span id="output-meta"></span>
<button id="copy-btn" onclick="copyOutput()">Copy</button>
</div>
<div id="error-msg"></div>
<div id="output-area">
<div id="placeholder">
Select a model and click Generate
<div class="hint">Results appear here as formatted JSON</div>
</div>
<pre id="output-json" style="display:none"></pre>
</div>
</div>
</div>
<script>
let currentOutput = null;
// ─────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────
async function init() {
try {
const r = await fetch('/station/tools/datagen/api/generators');
const data = await r.json();
const gens = data.generators || [];
if (!gens.length) {
document.getElementById('topbar-info').textContent = 'no generators loaded';
return;
}
// Populate model select from first generator
const models = gens[0].models || [];
const sel = document.getElementById('model-select');
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
sel.appendChild(opt);
});
const name = gens[0].name;
document.getElementById('topbar-info').textContent =
`${name} · ${models.length} model${models.length !== 1 ? 's' : ''}`;
} catch (e) {
document.getElementById('topbar-info').textContent = 'error loading generators';
}
}
// ─────────────────────────────────────────────
// UI helpers
// ─────────────────────────────────────────────
function setCount(n) {
document.getElementById('count-input').value = n;
document.querySelectorAll('.quick-btn').forEach(b => {
b.classList.toggle('active', b.textContent === String(n));
});
}
function addKwarg() {
const list = document.getElementById('kwargs-list');
const row = document.createElement('div');
row.className = 'kwarg-row';
row.innerHTML = `
<input class="kwarg-key" type="text" placeholder="field" />
<input class="kwarg-val" type="text" placeholder="value" />
<button class="kwarg-remove" onclick="this.parentElement.remove()">×</button>
`;
list.appendChild(row);
row.querySelector('.kwarg-key').focus();
}
function getKwargs() {
const kwargs = {};
document.querySelectorAll('.kwarg-row').forEach(row => {
const k = row.querySelector('.kwarg-key').value.trim();
const v = row.querySelector('.kwarg-val').value.trim();
if (k) {
// Try to parse as JSON (numbers, booleans, arrays, objects)
try { kwargs[k] = JSON.parse(v); }
catch { kwargs[k] = v; }
}
});
return kwargs;
}
// ─────────────────────────────────────────────
// Generate
// ─────────────────────────────────────────────
async function generate() {
const model = document.getElementById('model-select').value;
if (!model) { showError('Select a model first'); return; }
const count = parseInt(document.getElementById('count-input').value) || 1;
const kwargs = getKwargs();
hideError();
const btn = document.getElementById('generate-btn');
btn.disabled = true;
btn.textContent = 'Generating…';
try {
const r = await fetch('/station/tools/datagen/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, count, kwargs }),
});
const data = await r.json();
if (!r.ok) {
showError(data.detail || 'Generation failed');
return;
}
currentOutput = data;
renderOutput(data);
} catch (e) {
showError('Request failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Generate';
}
}
function renderOutput(data) {
const { model, count, items } = data;
document.getElementById('output-meta').textContent =
`${count} ${model}${count !== 1 ? 's' : ''} generated`;
const jsonStr = JSON.stringify(items, null, 2);
const highlighted = syntaxHighlight(jsonStr);
document.getElementById('placeholder').style.display = 'none';
const pre = document.getElementById('output-json');
pre.style.display = 'block';
pre.innerHTML = highlighted;
}
// ─────────────────────────────────────────────
// Syntax highlight (minimal)
// ─────────────────────────────────────────────
function syntaxHighlight(json) {
return json
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
match => {
if (/^"/.test(match)) {
if (/:$/.test(match)) return `<span class="j-key">${match}</span>`;
return `<span class="j-str">${match}</span>`;
}
if (/true|false/.test(match)) return `<span class="j-bool">${match}</span>`;
if (/null/.test(match)) return `<span class="j-null">${match}</span>`;
return `<span class="j-num">${match}</span>`;
}
);
}
// ─────────────────────────────────────────────
// Copy
// ─────────────────────────────────────────────
async function copyOutput() {
if (!currentOutput) return;
const text = JSON.stringify(currentOutput.items, null, 2);
await navigator.clipboard.writeText(text);
const btn = document.getElementById('copy-btn');
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
}
// ─────────────────────────────────────────────
// Error
// ─────────────────────────────────────────────
function showError(msg) {
const el = document.getElementById('error-msg');
el.textContent = msg;
el.style.display = 'block';
}
function hideError() {
document.getElementById('error-msg').style.display = 'none';
}
// ─────────────────────────────────────────────
// Keyboard shortcut
// ─────────────────────────────────────────────
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') generate();
});
init();
</script>
</body>
</html>

View File

@@ -0,0 +1 @@
"""Graphgen — interactive DB schema visualization."""

View File

@@ -0,0 +1,50 @@
"""FastAPI router for graphgen — schema graph explorer."""
import logging
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from .schema import load_graph_schema
log = logging.getLogger(__name__)
router = APIRouter(prefix="/tools/graphgen", tags=["graphgen"])
# soleprint/ root (this file: soleprint/station/tools/graphgen/api.py)
SPR_ROOT = Path(__file__).parents[3]
def _schema_paths() -> list[Path]:
"""Return ordered list of directories to search for schema."""
paths = []
# cfg/<room>/station/tools/graphgen/ (room-specific, highest priority)
cfg_dir = SPR_ROOT / "cfg"
if cfg_dir.exists():
for room in sorted(cfg_dir.iterdir()):
if room.is_dir() and not room.name.startswith(("_", ".")):
paths.append(room / "station" / "tools" / "graphgen")
# station/tools/modelgen/ (shared modelgen schema/)
paths.append(SPR_ROOT / "station" / "tools" / "modelgen")
return paths
@router.get("/", response_class=HTMLResponse)
def index():
"""Serve the graph viewer UI."""
html = Path(__file__).parent / "templates" / "index.html"
return HTMLResponse(html.read_text() if html.exists() else "<h1>graphgen</h1>")
@router.get("/health")
def health():
return {"status": "ok", "tool": "graphgen"}
@router.get("/api/schema")
def get_schema():
"""Return graph-ready schema: {models, relationships, source}."""
return load_graph_schema(_schema_paths())

View File

@@ -0,0 +1,174 @@
"""
Schema reader for graphgen.
Loads schema from multiple sources (priority order):
1. schema.json in a cfg/*/station/tools/graphgen/ directory
2. modelgen schema/ folder (Python dataclasses)
Output format:
{
"models": [{"id", "name", "doc", "fields": [{"name", "type", "pk", "fk", "m2m", "nullable"}]}],
"relationships": [{"from_model", "from_field", "to_model", "type"}],
"source": "path or description"
}
"""
import json
import logging
from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
def load_graph_schema(search_paths: list[Path]) -> dict:
"""Search for schema in given paths and return graph-ready data."""
for base in search_paths:
if not base.exists():
continue
# schema.json (JSON format)
json_path = base / "schema.json"
if json_path.exists():
log.info(f"graphgen: loading schema from {json_path}")
return _load_json_schema(json_path)
# modelgen schema/ folder
py_schema = base / "schema"
if (py_schema / "__init__.py").exists():
log.info(f"graphgen: loading Python schema from {py_schema}")
return _load_py_schema(py_schema)
return {"models": [], "relationships": [], "source": "empty"}
def _load_json_schema(path: Path) -> dict:
"""Load from schema.json — simple declarative format."""
try:
data = json.loads(path.read_text())
except Exception as e:
log.warning(f"graphgen: could not parse {path}: {e}")
return {"models": [], "relationships": [], "source": str(path), "error": str(e)}
models = []
relationships = []
for model_name, model_data in data.get("models", {}).items():
fields = []
for field_name, field_data in model_data.get("fields", {}).items():
field_type = field_data.get("type", "str")
fk_target = None
m2m = False
if field_type.startswith("FK:"):
fk_target = field_type[3:]
field_type = "FK"
relationships.append({
"from_model": model_name,
"from_field": field_name,
"to_model": fk_target,
"type": "FK",
})
elif field_type.startswith("M2M:"):
fk_target = field_type[4:]
field_type = "M2M"
m2m = True
relationships.append({
"from_model": model_name,
"from_field": field_name,
"to_model": fk_target,
"type": "M2M",
})
fields.append({
"name": field_name,
"type": field_type,
"pk": field_data.get("pk", False),
"fk": fk_target,
"m2m": m2m,
"nullable": field_data.get("nullable", False),
})
models.append({
"id": model_name,
"name": model_name,
"doc": model_data.get("doc", ""),
"fields": fields,
})
return {
"models": models,
"relationships": relationships,
"source": str(path),
}
def _load_py_schema(schema_dir: Path) -> dict:
"""Load from modelgen Python dataclasses schema/ folder."""
try:
from ..modelgen.loader.schema import SchemaLoader
loader = SchemaLoader(schema_dir).load()
return _convert_modelgen(loader, str(schema_dir))
except Exception as e:
log.warning(f"graphgen: Python schema load failed: {e}")
return {
"models": [],
"relationships": [],
"source": str(schema_dir),
"error": str(e),
}
def _convert_modelgen(loader: Any, source: str) -> dict:
"""Convert modelgen SchemaLoader output to graph data."""
all_names = {m.name for m in loader.models}
models = []
relationships = []
for model_def in loader.models:
fields = []
for field in model_def.fields:
type_str = _type_str(field.type_hint)
fk_target = None
# FK: type name that matches another model
if type_str in all_names:
fk_target = type_str
relationships.append({
"from_model": model_def.name,
"from_field": field.name,
"to_model": fk_target,
"type": "FK",
})
# Django extractor produces "FK" / "M2M" strings
elif type_str == "FK":
fk_target = None # target unknown from extractor
fields.append({
"name": field.name,
"type": type_str,
"pk": field.name == "id",
"fk": fk_target,
"m2m": type_str == "M2M",
"nullable": field.optional,
})
models.append({
"id": model_def.name,
"name": model_def.name,
"doc": model_def.docstring or "",
"fields": fields,
})
return {"models": models, "relationships": relationships, "source": source}
def _type_str(t: Any) -> str:
if t is None:
return "Any"
if isinstance(t, str):
return t
if hasattr(t, "__name__"):
return t.__name__
return str(t)

View File

@@ -0,0 +1,729 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>graphgen — Schema Explorer</title>
<style>
:root {
--bg: #0a0a0a;
--surface: #1a1a1a;
--border: #333;
--text: #e5e5e5;
--muted: #a3a3a3;
--dim: #666;
--amber: #d4a574;
--amber-dim: #b8956a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Top bar ── */
#topbar {
height: 44px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
flex-shrink: 0;
z-index: 100;
}
#topbar h1 { font-size: 14px; font-weight: 600; color: var(--amber); letter-spacing: .05em; }
.topbar-sep { color: var(--dim); }
#schema-info { color: var(--dim); font-size: 12px; }
.topbar-actions { margin-left: auto; display: flex; gap: 6px; }
button {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 11px;
transition: border-color .15s, color .15s;
}
button:hover { border-color: var(--amber); color: var(--amber); }
/* ── Layout ── */
#main { display: flex; flex: 1; overflow: hidden; }
/* ── Canvas ── */
#canvas {
flex: 1;
position: relative;
overflow: hidden;
cursor: grab;
background:
radial-gradient(circle at 1px 1px, #1e1e1e 1px, transparent 0) 0 0 / 24px 24px;
}
#canvas.panning { cursor: grabbing; }
#viewport {
position: absolute;
top: 0; left: 0;
transform-origin: 0 0;
will-change: transform;
}
/* SVG for relationship lines — inside viewport */
#rels-svg {
position: absolute;
top: 0; left: 0;
width: 1px; height: 1px;
overflow: visible;
pointer-events: none;
z-index: 0;
}
/* ── Model cards ── */
.model-card {
position: absolute;
width: 210px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
z-index: 1;
transition: border-color .12s, box-shadow .12s;
}
.model-card:hover { border-color: #555; }
.model-card.selected {
border-color: var(--amber);
box-shadow: 0 0 0 1px var(--amber)22;
z-index: 3;
}
.model-card.dim { opacity: .35; }
.model-header {
background: linear-gradient(135deg, var(--amber), var(--amber-dim));
padding: 7px 10px 6px;
cursor: move;
user-select: none;
}
.model-header-name {
font-weight: 600;
font-size: 12px;
color: #0a0a0a;
letter-spacing: .02em;
}
.model-header-doc {
font-size: 10px;
color: #0a0a0a;
opacity: .65;
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fields { border-top: 1px solid var(--border); }
.field-row {
display: flex;
align-items: center;
padding: 4px 8px;
border-bottom: 1px solid #111;
gap: 5px;
font-size: 11px;
min-height: 26px;
}
.field-row:last-child { border-bottom: none; }
.field-row:hover { background: rgba(255,255,255,.025); }
.fbadge {
font-size: 8px;
font-weight: 600;
padding: 1px 4px;
border-radius: 3px;
flex-shrink: 0;
font-family: monospace;
letter-spacing: .03em;
}
.fbadge-pk { background: #7c3aed18; border: 1px solid #7c3aed66; color: #a78bfa; }
.fbadge-fk { background: #15803d18; border: 1px solid #15803d66; color: #86efac; }
.fbadge-m2m { background: #1d4ed818; border: 1px solid #1d4ed866; color: #93c5fd; }
.fbadge-type { background: #22222288; border: 1px solid #44444466; color: var(--dim); }
.fname {
flex: 1;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fname.nullable { color: var(--muted); }
.ftype {
color: var(--dim);
font-size: 10px;
font-family: monospace;
flex-shrink: 0;
}
.ftype.fk-target { color: #86efac; }
/* ── Relationship lines ── */
.rel-path { fill: none; stroke: var(--dim); stroke-width: 1.5; }
.rel-path.hl { stroke: var(--amber); stroke-width: 2; }
/* ── Sidebar ── */
#sidebar {
width: 200px;
background: var(--surface);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
#sidebar-header {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
font-size: 10px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--dim);
flex-shrink: 0;
}
#sidebar-list { overflow-y: auto; flex: 1; }
.sb-model {
padding: 7px 12px;
border-bottom: 1px solid #111;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-size: 12px;
transition: background .1s;
}
.sb-model:hover { background: rgba(255,255,255,.03); }
.sb-model.active { color: var(--amber); }
.sb-model-name { flex: 1; }
.sb-model-count { font-size: 10px; color: var(--dim); }
/* ── Zoom indicator ── */
#zoom-badge {
position: absolute;
bottom: 10px;
left: 10px;
font-size: 10px;
color: var(--dim);
background: var(--surface);
border: 1px solid var(--border);
padding: 3px 7px;
border-radius: 4px;
pointer-events: none;
}
/* ── Empty state ── */
#empty-state {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--dim);
}
#empty-state h2 { font-size: 15px; margin-bottom: 6px; color: var(--muted); }
#empty-state p { font-size: 12px; line-height: 1.6; }
#empty-state code {
background: var(--surface);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
}
</style>
</head>
<body>
<div id="topbar">
<h1>graphgen</h1>
<span class="topbar-sep"></span>
<span id="schema-info">loading…</span>
<div class="topbar-actions">
<button onclick="autoLayout()">Auto Layout</button>
<button onclick="fitView()">Fit View</button>
</div>
</div>
<div id="main">
<div id="canvas">
<div id="viewport">
<svg id="rels-svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arr" markerWidth="7" markerHeight="7" refX="5" refY="3.5" orient="auto">
<path d="M0,1 L7,3.5 L0,6 Z" fill="#666"/>
</marker>
<marker id="arr-hl" markerWidth="7" markerHeight="7" refX="5" refY="3.5" orient="auto">
<path d="M0,1 L7,3.5 L0,6 Z" fill="#d4a574"/>
</marker>
</defs>
</svg>
</div>
<div id="empty-state">
<h2>No schema found</h2>
<p>Add a <code>schema.json</code> to<br><code>cfg/&lt;room&gt;/station/tools/graphgen/</code></p>
</div>
<div id="zoom-badge">100%</div>
</div>
<div id="sidebar">
<div id="sidebar-header">Models</div>
<div id="sidebar-list"></div>
</div>
</div>
<script>
// ─────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────
const CARD_W = 210;
const HDR_H = 36; // compact header (no doc)
const HDR_H_DOC = 50; // header with doc line
const FIELD_H = 26;
const COL_GAP = 80;
const ROW_GAP = 60;
const SVG_NS = 'http://www.w3.org/2000/svg';
// ─────────────────────────────────────────────
// State
// ─────────────────────────────────────────────
let schema = null;
let positions = {}; // id → {x, y}
let selectedId = null;
let vx = 60, vy = 60, vz = 1; // viewport translate + scale
// Pan
let panning = false, panSX = 0, panSY = 0, panVX0 = 0, panVY0 = 0;
// Drag
let dragging = false, dragId = null, dragSX = 0, dragSY = 0, dragPX0 = 0, dragPY0 = 0;
// ─────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────
async function init() {
try {
const r = await fetch('/station/tools/graphgen/api/schema');
schema = await r.json();
} catch (e) {
document.getElementById('schema-info').textContent = 'fetch error';
return;
}
if (!schema.models.length) {
document.getElementById('schema-info').textContent = 'no schema';
document.getElementById('empty-state').style.display = 'block';
return;
}
const m = schema.models.length;
const r2 = schema.relationships.length;
document.getElementById('schema-info').textContent =
`${m} model${m !== 1 ? 's' : ''} · ${r2} relationship${r2 !== 1 ? 's' : ''}`;
autoLayout(false);
renderSidebar();
}
// ─────────────────────────────────────────────
// Layout
// ─────────────────────────────────────────────
function cardHeight(model) {
const hdrH = model.doc ? HDR_H_DOC : HDR_H;
return hdrH + model.fields.length * FIELD_H;
}
function autoLayout(redraw = true) {
if (!schema) return;
const { models, relationships } = schema;
// Models referenced by FKs go first (left-side "root" tables)
const isTarget = new Set(relationships.map(r => r.to_model));
const sorted = [...models].sort((a, b) =>
(isTarget.has(b.id) ? 1 : 0) - (isTarget.has(a.id) ? 1 : 0)
);
const cols = Math.max(2, Math.ceil(Math.sqrt(sorted.length * 1.2)));
const colW = CARD_W + COL_GAP;
// Track per-column cursor Y
const colY = Array(cols).fill(40);
sorted.forEach((model, i) => {
const col = i % cols;
const x = col * colW + 40;
const y = colY[col];
positions[model.id] = { x, y };
colY[col] += cardHeight(model) + ROW_GAP;
});
if (redraw) { renderCards(); renderRels(); }
else { renderCards(); renderRels(); }
}
// ─────────────────────────────────────────────
// Render cards
// ─────────────────────────────────────────────
function renderCards() {
const vp = document.getElementById('viewport');
vp.querySelectorAll('.model-card').forEach(c => c.remove());
schema.models.forEach(m => vp.appendChild(buildCard(m)));
}
function buildCard(model) {
const { x, y } = positions[model.id] || { x: 0, y: 0 };
const card = el('div', 'model-card');
card.id = 'card-' + model.id;
card.style.left = x + 'px';
card.style.top = y + 'px';
card.dataset.model = model.id;
// Header
const hdr = el('div', 'model-header');
hdr.innerHTML = `<div class="model-header-name">${model.name}</div>` +
(model.doc ? `<div class="model-header-doc">${model.doc}</div>` : '');
card.appendChild(hdr);
// Fields
const fieldsDiv = el('div', 'fields');
model.fields.forEach(f => {
const row = el('div', 'field-row');
row.id = `field-${model.id}-${f.name}`;
let badgeCls, badgeTxt;
if (f.pk) { badgeCls = 'fbadge-pk'; badgeTxt = 'PK'; }
else if (f.m2m) { badgeCls = 'fbadge-m2m'; badgeTxt = 'M2M'; }
else if (f.fk) { badgeCls = 'fbadge-fk'; badgeTxt = 'FK'; }
else { badgeCls = 'fbadge-type'; badgeTxt = shortType(f.type); }
const nameCls = 'fname' + (f.nullable ? ' nullable' : '');
const typeTxt = f.fk ? f.fk : f.type;
const typeCls = 'ftype' + (f.fk ? ' fk-target' : '');
row.innerHTML =
`<span class="fbadge ${badgeCls}">${badgeTxt}</span>` +
`<span class="${nameCls}">${f.name}</span>` +
`<span class="${typeCls}">${typeTxt}</span>`;
fieldsDiv.appendChild(row);
});
card.appendChild(fieldsDiv);
// Drag header
hdr.addEventListener('mousedown', e => { e.stopPropagation(); startDrag(e, model.id); });
// Select
card.addEventListener('click', e => { e.stopPropagation(); selectModel(model.id); });
return card;
}
function shortType(t) {
const m = { 'datetime': 'dt', 'bigint': 'big', 'float': 'f64', 'bool': 'bool', 'dict': '{}', 'list': '[]' };
return m[t] || t;
}
// ─────────────────────────────────────────────
// Render relationships (SVG inside viewport)
// ─────────────────────────────────────────────
function renderRels() {
const svg = document.getElementById('rels-svg');
// Remove existing lines
[...svg.querySelectorAll('.rel-group')].forEach(g => g.remove());
if (!schema) return;
schema.relationships.forEach(rel => renderRel(rel, false));
}
function renderRel(rel, highlighted) {
const svg = document.getElementById('rels-svg');
const fromM = schema.models.find(m => m.id === rel.from_model);
const toM = schema.models.find(m => m.id === rel.to_model);
if (!fromM || !toM) return;
const fromPos = positions[rel.from_model];
const toPos = positions[rel.to_model];
if (!fromPos || !toPos) return;
const fIdx = fromM.fields.findIndex(f => f.name === rel.from_field);
const tIdx = toM.fields.findIndex(f => f.pk) || 0;
const fromHdrH = fromM.doc ? HDR_H_DOC : HDR_H;
const toHdrH = toM.doc ? HDR_H_DOC : HDR_H;
const x1 = fromPos.x + CARD_W;
const y1 = fromPos.y + fromHdrH + (Math.max(fIdx, 0) + 0.5) * FIELD_H;
const x2 = toPos.x;
const y2 = toPos.y + toHdrH + (Math.max(tIdx, 0) + 0.5) * FIELD_H;
const dx = Math.abs(x2 - x1);
const ctrl = Math.min(Math.max(dx * 0.5, 50), 180);
const d = `M ${x1},${y1} C ${x1 + ctrl},${y1} ${x2 - ctrl},${y2} ${x2},${y2}`;
const g = svgEl('g');
g.className = 'rel-group';
g.dataset.from = rel.from_model;
g.dataset.to = rel.to_model;
const path = svgEl('path');
path.setAttribute('d', d);
path.setAttribute('class', highlighted ? 'rel-path hl' : 'rel-path');
path.setAttribute('marker-end', highlighted ? 'url(#arr-hl)' : 'url(#arr)');
g.appendChild(path);
svg.appendChild(g);
}
function updateRelLine(fromId) {
const svg = document.getElementById('rels-svg');
// Remove and redraw all lines touching fromId
[...svg.querySelectorAll('.rel-group')].forEach(g => {
if (g.dataset.from === fromId || g.dataset.to === fromId) g.remove();
});
schema.relationships.forEach(rel => {
if (rel.from_model === fromId || rel.to_model === fromId) {
renderRel(rel, selectedId && (rel.from_model === selectedId || rel.to_model === selectedId));
}
});
}
// ─────────────────────────────────────────────
// Selection
// ─────────────────────────────────────────────
function selectModel(id) {
// Deselect previous
document.querySelectorAll('.model-card.selected').forEach(c => c.classList.remove('selected'));
document.querySelectorAll('.sb-model.active').forEach(s => s.classList.remove('active'));
if (selectedId === id) {
// Toggle off
selectedId = null;
document.querySelectorAll('.model-card.dim').forEach(c => c.classList.remove('dim'));
renderRels();
return;
}
selectedId = id;
const connectedIds = getConnected(id);
document.querySelectorAll('.model-card').forEach(c => {
const mid = c.dataset.model;
if (mid === id) {
c.classList.add('selected');
c.classList.remove('dim');
} else if (connectedIds.has(mid)) {
c.classList.remove('dim');
} else {
c.classList.add('dim');
}
});
// Sidebar
const sbEl = document.querySelector(`.sb-model[data-model="${id}"]`);
if (sbEl) sbEl.classList.add('active');
// Redraw rels with highlight
renderRels();
}
function getConnected(id) {
const s = new Set([id]);
schema.relationships.forEach(r => {
if (r.from_model === id) s.add(r.to_model);
if (r.to_model === id) s.add(r.from_model);
});
return s;
}
// ─────────────────────────────────────────────
// Sidebar
// ─────────────────────────────────────────────
function renderSidebar() {
const list = document.getElementById('sidebar-list');
list.innerHTML = '';
schema.models.forEach(m => {
const fkCount = m.fields.filter(f => f.fk || f.m2m).length;
const div = el('div', 'sb-model');
div.dataset.model = m.id;
div.innerHTML =
`<span class="sb-model-name">${m.name}</span>` +
`<span class="sb-model-count">${m.fields.length}f${fkCount ? ' · ' + fkCount + 'r' : ''}</span>`;
div.addEventListener('click', () => {
selectModel(m.id);
scrollToModel(m.id);
});
list.appendChild(div);
});
}
function scrollToModel(id) {
const pos = positions[id];
if (!pos) return;
const canvas = document.getElementById('canvas');
const cw = canvas.clientWidth, ch = canvas.clientHeight;
vx = cw / 2 - (pos.x + CARD_W / 2) * vz;
vy = ch / 2 - (pos.y + 100) * vz;
applyViewport();
}
// ─────────────────────────────────────────────
// Fit view
// ─────────────────────────────────────────────
function fitView() {
if (!schema || !schema.models.length) return;
const canvas = document.getElementById('canvas');
const cw = canvas.clientWidth - 40, ch = canvas.clientHeight - 40;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
schema.models.forEach(m => {
const { x, y } = positions[m.id] || { x: 0, y: 0 };
const h = cardHeight(m);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + CARD_W);
maxY = Math.max(maxY, y + h);
});
const sceneW = maxX - minX;
const sceneH = maxY - minY;
vz = Math.min(1, Math.min(cw / sceneW, ch / sceneH)) * 0.9;
vx = (cw - sceneW * vz) / 2 + 20 - minX * vz;
vy = (ch - sceneH * vz) / 2 + 20 - minY * vz;
applyViewport();
}
// ─────────────────────────────────────────────
// Viewport transform
// ─────────────────────────────────────────────
function applyViewport() {
document.getElementById('viewport').style.transform =
`translate(${vx}px, ${vy}px) scale(${vz})`;
document.getElementById('zoom-badge').textContent = Math.round(vz * 100) + '%';
}
// ─────────────────────────────────────────────
// Pan
// ─────────────────────────────────────────────
const canvas = document.getElementById('canvas');
canvas.addEventListener('mousedown', e => {
if (e.target !== canvas && !e.target.closest('#viewport') || e.target.closest('.model-card')) return;
// only pan when clicking the background (viewport or canvas, not a card)
if (e.target.closest('.model-card')) return;
e.preventDefault();
panning = true;
panSX = e.clientX; panSY = e.clientY;
panVX0 = vx; panVY0 = vy;
canvas.classList.add('panning');
});
window.addEventListener('mousemove', e => {
if (panning) {
vx = panVX0 + (e.clientX - panSX);
vy = panVY0 + (e.clientY - panSY);
applyViewport();
}
if (dragging && dragId) {
const dx = (e.clientX - dragSX) / vz;
const dy = (e.clientY - dragSY) / vz;
positions[dragId].x = dragPX0 + dx;
positions[dragId].y = dragPY0 + dy;
const card = document.getElementById('card-' + dragId);
if (card) {
card.style.left = positions[dragId].x + 'px';
card.style.top = positions[dragId].y + 'px';
}
updateRelLine(dragId);
}
});
window.addEventListener('mouseup', () => {
panning = false;
dragging = false;
dragId = null;
canvas.classList.remove('panning');
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.1 : 0.9;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
vx = mx - (mx - vx) * factor;
vy = my - (my - vy) * factor;
vz = Math.max(0.2, Math.min(3, vz * factor));
applyViewport();
}, { passive: false });
canvas.addEventListener('click', e => {
if (e.target === canvas || e.target === document.getElementById('viewport')) {
if (selectedId) selectModel(selectedId); // deselect
}
});
// ─────────────────────────────────────────────
// Drag cards
// ─────────────────────────────────────────────
function startDrag(e, id) {
e.preventDefault();
dragging = true;
dragId = id;
dragSX = e.clientX;
dragSY = e.clientY;
dragPX0 = positions[id].x;
dragPY0 = positions[id].y;
const card = document.getElementById('card-' + id);
if (card) card.style.zIndex = 10;
}
// ─────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────
function el(tag, cls) {
const d = document.createElement(tag);
if (cls) d.className = cls;
return d;
}
function svgEl(tag) {
return document.createElementNS(SVG_NS, tag);
}
// ─────────────────────────────────────────────
applyViewport();
init();
</script>
</body>
</html>