Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 48 additions & 21 deletions api/routes/graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from fastapi import APIRouter, Request, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from redis import ResponseError

from api.agents import AnalysisAgent, RelevancyAgent, ResponseFormatterAgent
from api.auth.user_management import token_required
Expand Down Expand Up @@ -84,10 +85,25 @@ def sanitize_query(query: str) -> str:
return query.replace('\n', ' ').replace('\r', ' ')[:500]

def sanitize_log_input(value: str) -> str:
"""Sanitize input for safe logging (remove newlines and carriage returns)."""
"""
Sanitize input for safe logging—remove newlines,
carriage returns, tabs, and wrap in repr().
"""
if not isinstance(value, str):
return str(value)
return value.replace('\n', ' ').replace('\r', ' ')
value = str(value)

return value.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ')

def _graph_name(request: Request, graph_id:str) -> str:
if not graph_id or not isinstance(graph_id, str):
raise HTTPException(status_code=400, detail="Invalid graph_id")

graph_id = graph_id.strip()[:200]
if not graph_id:
raise HTTPException(status_code=400,
detail="Invalid graph_id, must be less than 200 characters.")

return f"{request.state.user_id}_{graph_id}"

@graphs_router.get("")
@token_required
Expand All @@ -112,12 +128,7 @@ async def get_graph_data(request: Request, graph_id: str):
Nodes contain a minimal set of properties (id, name, labels, props).
Edges contain source and target node names (or internal ids), type and props.
"""
if not graph_id or not isinstance(graph_id, str):
return JSONResponse(content={"error": "Invalid graph_id"}, status_code=400)

graph_id = graph_id.strip()[:200]
namespaced = f"{request.state.user_id}_{graph_id}"

namespaced = _graph_name(request, graph_id)
try:
graph = db.select_graph(namespaced)
except Exception as e:
Expand Down Expand Up @@ -269,16 +280,7 @@ async def query_graph(request: Request, graph_id: str, chat_data: ChatRequest):
"""
text2sql
"""
# Input validation
if not graph_id or not isinstance(graph_id, str):
raise HTTPException(status_code=400, detail="Invalid graph_id")

# Sanitize graph_id to prevent injection
graph_id = graph_id.strip()[:100] # Limit length and strip whitespace
if not graph_id:
raise HTTPException(status_code=400, detail="Invalid graph_id")

graph_id = f"{request.state.user_id}_{graph_id}"
graph_id = _graph_name(request, graph_id)

queries_history = chat_data.chat if hasattr(chat_data, 'chat') else None
result_history = chat_data.result if hasattr(chat_data, 'result') else None
Expand Down Expand Up @@ -553,7 +555,8 @@ async def confirm_destructive_operation(
"""
Handle user confirmation for destructive SQL operations
"""
graph_id = f"{request.state.user_id}_{graph_id.strip()}"

graph_id = _graph_name(request, graph_id)

if hasattr(confirm_data, 'confirmation'):
confirmation = confirm_data.confirmation.strip().upper()
Expand Down Expand Up @@ -674,7 +677,7 @@ async def refresh_graph_schema(request: Request, graph_id: str):
This endpoint allows users to manually trigger a schema refresh
if they suspect the graph is out of sync with the database.
"""
graph_id = f"{request.state.user_id}_{graph_id.strip()}"
graph_id = _graph_name(request, graph_id)

try:
# Get database connection details
Expand Down Expand Up @@ -716,3 +719,27 @@ async def refresh_graph_schema(request: Request, graph_id: str):
"success": False,
"error": "Error refreshing schema"
}, status_code=500)

@graphs_router.delete("/{graph_id}")
@token_required
async def delete_graph(request: Request, graph_id: str):
"""Delete the specified graph (namespaced to the user).

This will attempt to delete the FalkorDB graph belonging to the
authenticated user. The graph id used by the client is stripped of
namespace and will be namespaced using the user's id from the request
state.
"""
namespaced = _graph_name(request, graph_id)

try:
# Select and delete the graph using the FalkorDB client API
graph = db.select_graph(namespaced)
await graph.delete()
return JSONResponse(content={"success": True, "graph": graph_id})
except ResponseError:
return JSONResponse(content={"error": "Failed to delete graph, Graph not found"},
status_code=404)
except Exception as e:
logging.exception("Failed to delete graph %s: %s", sanitize_log_input(namespaced), e)
Comment thread Fixed
return JSONResponse(content={"error": "Failed to delete graph"}, status_code=500)
88 changes: 88 additions & 0 deletions app/public/css/menu.css
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,91 @@
height: 16px;
flex-shrink: 0;
}

/* Graph custom dropdown (moved from chat_header.j2 inline styles) */
.graph-custom-dropdown {
position: relative;
display: inline-block;
width: 180px;
margin-left: 8px;
}

.graph-selected {
padding: 8px 14px;
border-radius: 6px;
background: var(--falkor-quaternary);
color: var(--text-primary);
cursor: pointer;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 160px;
box-sizing: border-box;
font-size: 14px;
}

.graph-options {
position: absolute;
top: calc(100%);
left: 0;
right: 0;
background: var(--falkor-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
border-top-left-radius: 0;
border-top-right-radius: 0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-height: 260px;
overflow: auto;
display: none;
z-index: 50;
}

.dropdown-option {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 8px 12px;
gap: 8px;
color: var(--text-primary);
cursor: pointer;
}

.dropdown-option:hover {
background: var(--bg-tertiary);
}

.dropdown-option span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.dropdown-option .delete-btn {
background: transparent;
border: none;
color: #ff6b6b;
opacity: 0;
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
}

.dropdown-option:hover .delete-btn {
opacity: 1;
}

.dropdown-option .delete-btn svg {
width: 16px;
height: 16px;
}

.graph-options.open {
display: block;
}
13 changes: 10 additions & 3 deletions app/templates/components/chat_header.j2
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
<img src="/static/icons/queryweaver.webp" alt="Chat Logo" class="logo">
<h1>Natural Language to SQL Generator</h1>
<div class="button-container">
<select title="Select Database" id="graph-select">
<option value="">Loading...</option>
</select>
<!-- Custom dropdown to show per-item delete action on hover -->
<div id="graph-custom-dropdown" class="graph-custom-dropdown">
<div id="graph-selected" class="graph-selected dropdown-selected" title="Select Database">
<span class="dropdown-text">Select Database</span>
<span class="dropdown-arrow">▼</span>
</div>
<div id="graph-options" class="graph-options dropdown-options" aria-hidden="true"></div>
</div>

<!-- styles for the graph dropdown moved to app/public/css/menu.css -->
<div class="vertical-separator"></div>
<input title="Upload Schema" id="schema-upload" type="file" accept=".json" style="display: none;" tabindex="-1" disabled/>
<label for="schema-upload" id="custom-file-upload">
Expand Down
8 changes: 5 additions & 3 deletions app/ts/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DOM } from './modules/config';
import { initChat } from './modules/messages';
import { sendMessage, pauseRequest } from './modules/chat';
import { loadGraphs, handleFileUpload, onGraphChange } from './modules/graphs';
import { getSelectedGraph } from './modules/graph_select';
import {
toggleContainer,
showResetConfirmation,
Expand Down Expand Up @@ -64,7 +65,7 @@ function setupEventListeners() {

DOM.schemaButton?.addEventListener('click', () => {
toggleContainer(DOM.schemaContainer as HTMLElement, async () => {
const selected = DOM.graphSelect?.value;
const selected = getSelectedGraph();
if (!selected) return;
await loadAndShowGraph(selected);
});
Expand All @@ -84,9 +85,10 @@ function setupEventListeners() {
}
});

DOM.graphSelect?.addEventListener('change', async () => {
// Legacy select is hidden; custom UI will trigger load via graph_select helper
document.getElementById('graph-options')?.addEventListener('click', async () => {
onGraphChange();
const selected = DOM.graphSelect?.value;
const selected = getSelectedGraph();
if (!selected) return;
if (DOM.schemaContainer && DOM.schemaContainer.classList.contains('open')) {
await loadAndShowGraph(selected);
Expand Down
5 changes: 3 additions & 2 deletions app/ts/modules/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@

import { DOM, state, MESSAGE_DELIMITER } from './config';
import { addMessage, removeLoadingMessage, moveLoadingMessageToBottom } from './messages';
import { getSelectedGraph } from './graph_select';

export async function sendMessage() {
const message = (DOM.messageInput?.value || '').trim();
if (!message) return;

const selectedValue = DOM.graphSelect?.value || '';
const selectedValue = getSelectedGraph() || '';
if (!selectedValue) {
addMessage('Please select a graph from the dropdown before sending a message.', false, true);
return;
Expand Down Expand Up @@ -257,7 +258,7 @@ export async function handleDestructiveConfirmation(confirmation: string, sqlQue
}

try {
const selectedValue = DOM.graphSelect?.value || '';
const selectedValue = getSelectedGraph() || '';

const response = await fetch('/graphs/' + encodeURIComponent(selectedValue) + '/confirm', {
method: 'POST',
Expand Down
2 changes: 0 additions & 2 deletions app/ts/modules/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export const SELECTORS = {
leftToolbarInner: '#left-toolbar-inner',
expInstructions: '#instructions-textarea',
inputContainer: '#input-container',
graphSelect: '#graph-select',
resetConfirmationModal: '#reset-confirmation-modal',
resetConfirmBtn: '#reset-confirm-btn',
resetCancelBtn: '#reset-cancel-btn'
Expand Down Expand Up @@ -59,7 +58,6 @@ export const DOM = {
leftToolbarInner: getElement<HTMLElement | null>('left-toolbar-inner'),
expInstructions: getElement<HTMLTextAreaElement | null>('instructions-textarea'),
inputContainer: getElement<HTMLElement | null>('input-container'),
graphSelect: getElement<HTMLSelectElement | null>('graph-select'),
resetConfirmationModal: getElement<HTMLElement | null>('reset-confirmation-modal'),
resetConfirmBtn: getElement<HTMLButtonElement | null>('reset-confirm-btn'),
resetCancelBtn: getElement<HTMLButtonElement | null>('reset-cancel-btn')
Expand Down
62 changes: 62 additions & 0 deletions app/ts/modules/graph_select.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Helper to manage the custom graph selector UI.
* Exposes functions to get/set the selected graph and render the list.
*/
import { DOM } from './config';

export function getSelectedGraph(): string | null {
const selectedLabel = document.getElementById('graph-selected');
const text = selectedLabel?.querySelector('.dropdown-text')?.textContent;
if (text) return text;
return null;
}

export function setSelectedGraph(name: string) {
const selectedLabel = document.getElementById('graph-selected');
const textNode = selectedLabel?.querySelector('.dropdown-text');
if (textNode) textNode.textContent = name;
}

export function clearGraphOptions() {
const optionsContainer = document.getElementById('graph-options');
if (optionsContainer) optionsContainer.innerHTML = '';
}

export function addGraphOption(name: string, onSelect: (n: string) => void, onDelete: (n: string) => void) {
const optionsContainer = document.getElementById('graph-options');
if (!optionsContainer) return;
const row = document.createElement('div');
row.className = 'dropdown-option';
row.setAttribute('data-value', name);
const icon = document.createElement('span');
icon.className = 'db-icon';
// optional: could add icons later
const text = document.createElement('span');
text.textContent = name;
row.appendChild(icon);
row.appendChild(text);

const delBtn = document.createElement('button');
delBtn.className = 'delete-btn';
delBtn.title = `Delete ${name}`;
delBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg>`;
row.appendChild(delBtn);

row.addEventListener('click', () => {
setSelectedGraph(name);
onSelect(name);
optionsContainer.classList.remove('open');
});

delBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
onDelete(name);
});

optionsContainer.appendChild(row);
}

export function toggleOptions() {
const optionsContainer = document.getElementById('graph-options');
if (optionsContainer) optionsContainer.classList.toggle('open');
}
Loading
Loading