diff --git a/apps/aparavi-ui/package.json b/apps/aparavi-ui/package.json new file mode 100644 index 000000000..cf0c77111 --- /dev/null +++ b/apps/aparavi-ui/package.json @@ -0,0 +1,62 @@ +{ + "name": "@rocketride/aparavi-ui", + "version": "1.0.0", + "private": true, + "description": "Aparavi AQL Chat — natural-language query interface for Aparavi data", + "license": "MIT", + "appManifest": { + "id": "rocketride.aparavi", + "publisher": "Aparavi Software AG", + "name": "Aparavi AQL", + "description": "Chat with your Aparavi data using natural language", + "icon": "./src/icon.svg", + "categories": ["tools"], + "mode": "free", + "authenticated": true, + "showStatusBar": true, + "settings": [ + { + "key": "ROCKETRIDE_APARAVI_URL", + "label": "Aparavi Server URL", + "description": "Base URL of the Aparavi platform API (e.g. https://app.aparavi.com)", + "type": "text", + "required": true + }, + { + "key": "ROCKETRIDE_APARAVI_USER", + "label": "Aparavi User ID", + "description": "User ID for authenticating with the Aparavi API", + "type": "text", + "required": true + }, + { + "key": "ROCKETRIDE_APARAVI_PASSWORD", + "label": "Aparavi Password", + "description": "Password for authenticating with the Aparavi API", + "type": "envkey", + "required": true + } + ] + }, + "scripts": { + "build": "pnpm rsbuild build" + }, + "browserslist": [ + "chrome >= 81", + "edge >= 83", + "firefox >= 76", + "safari >= 13" + ], + "dependencies": { + "@module-federation/rsbuild-plugin": "^2.5.1", + "shell-ui": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "shared": "workspace:*" + }, + "devDependencies": { + "@rsbuild/core": "~2.0.11", + "@rsbuild/plugin-react": "~2.0.1", + "typescript": "^5.3.0" + } +} diff --git a/apps/aparavi-ui/rsbuild.config.mts b/apps/aparavi-ui/rsbuild.config.mts new file mode 100644 index 000000000..a349b5bc9 --- /dev/null +++ b/apps/aparavi-ui/rsbuild.config.mts @@ -0,0 +1,77 @@ +// ============================================================================= +// APARAVI-UI — Module Federation Remote (Aparavi AQL Chat) +// ============================================================================= +// Builds remoteEntry.js + AppDescriptor chunk only. +// NOT a standalone app. Run shell-ui:dev for development. +// ============================================================================= + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8')); +const moduleId = (pkg.appManifest?.id ?? 'unknown').replace(/[^a-zA-Z0-9_$]/g, '_'); + +export default defineConfig(() => { + return { + plugins: [ + pluginReact(), + pluginModuleFederation({ + name: moduleId, + filename: 'remoteEntry.js', + exposes: { + './AppDescriptor': './src/AppDescriptor.ts', + }, + dts: false, + // runtime: false — the host (shell-ui) provides the MF runtime; + // remotes don't embed their own copy, keeping remoteEntry.js + // stable across app-code-only rebuilds. + runtime: false, + shared: { + // eager: true makes shared-scope negotiation synchronous on + // both host and remote, eliminating the async deadlock that + // hangs the browser when only one remote is recompiled. + react: { singleton: true, eager: true, requiredVersion: '^18.2.0' }, + 'react-dom': { singleton: true, eager: true, requiredVersion: '^18.2.0' }, + // import: false tells MF to NOT bundle a fallback copy — + // the host (shell-ui) always provides these at runtime. + 'shell-ui': { singleton: true, requiredVersion: false, import: false }, + 'shared': { singleton: true, requiredVersion: false, import: false }, + }, + }), + ], + // No resolve aliases — all shared modules (shell-ui, shared, react) + // resolve through node_modules (pnpm workspace link) and MF provides + // the host's singleton at runtime. + resolve: {}, + tools: { + // Treat .pipe files as JSON so the pipeline definition can be imported. + rspack: { + module: { + rules: [{ test: /\.pipe$/, type: 'json' }], + }, + }, + }, + server: { port: 3018 }, + source: { + entry: { + index: './src/index.ts', + }, + }, + output: { + distPath: { + root: path.join(process.env.ROCKETRIDE_BUILD_ROOT ?? '../../build', 'apps', 'aparavi-ui'), + }, + assetPrefix: 'auto', + cleanDistPath: true, + sourceMap: { + js: 'source-map', + css: true, + }, + }, + }; +}); diff --git a/apps/aparavi-ui/scripts/tasks.js b/apps/aparavi-ui/scripts/tasks.js new file mode 100644 index 000000000..1993da96a --- /dev/null +++ b/apps/aparavi-ui/scripts/tasks.js @@ -0,0 +1,36 @@ +// MIT License +// +// Copyright (c) 2026 Aparavi Software AG +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/** + * Aparavi UI Build Module + * + * Aparavi AQL Chat — natural-language query interface for Aparavi data. + */ +const path = require('path'); +const { createAppModule } = require('../../../scripts/lib/appModule'); + +module.exports = createAppModule({ + name: 'aparavi-ui', + description: 'Aparavi AQL Chat Application', + appRoot: path.join(__dirname, '..'), + dev: true, +}); diff --git a/apps/aparavi-ui/src/AparaviApp.tsx b/apps/aparavi-ui/src/AparaviApp.tsx new file mode 100644 index 000000000..b2c386b40 --- /dev/null +++ b/apps/aparavi-ui/src/AparaviApp.tsx @@ -0,0 +1,340 @@ +// MIT License +// +// Copyright (c) 2026 Aparavi Software AG +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ============================================================================= +// APARAVI APP — multi-tab persistent chat for Aparavi AQL queries +// ============================================================================= +// +// Uses the Documents library for multi-tab support. Each tab is an independent +// chat session persisted as a .chat JSON file via the workspace VFS. +// +// On connect, starts the Aparavi pipeline via client.use() and obtains a +// pipeline token shared across all chat tabs. +// +// Pipeline: Chat → CrewAI Agent → Aparavi AQL tool + LLM → Response +// ============================================================================= + +import React, { useCallback, useMemo, useRef, useEffect, useState } from 'react'; +import type { CSSProperties } from 'react'; +import type { ShellAppProps } from 'shell-ui'; +import type { IVirtualFileSystem } from 'shared/modules/explorer/types'; +import { commonStyles } from 'shared/themes/styles'; +import { useShellConnection, useAuthUser, useWorkspace, DocTabs, DocSplitLayout } from 'shell-ui'; +import type { Documents } from 'shell-ui'; +import { ChatView, useChatMessages } from 'shared'; +import type { ChatMessage } from 'shared'; +import { createDocs, destroyDocs, getDocs } from './docs'; +import { loadChat, saveChat, listChatDir, renameChat, deleteChat } from './chatStore'; +import pipeline from './aparavi.pipe'; + +// ============================================================================= +// STYLES +// ============================================================================= + +const styles = { + container: { + display: 'flex', + flexDirection: 'column', + height: '100%', + overflow: 'hidden', + } as CSSProperties, + groupPane: { + ...commonStyles.columnFill, + minWidth: 0, + overflow: 'hidden', + } as CSSProperties, + content: { + flex: 1, + display: 'flex', + minHeight: 0, + overflow: 'hidden', + } as CSSProperties, + welcome: { + display: 'flex', + flex: 1, + alignItems: 'center', + justifyContent: 'center', + color: 'var(--rr-text-secondary)', + fontFamily: 'var(--rr-font-family)', + fontSize: 14, + flexDirection: 'column', + gap: 12, + } as CSSProperties, +}; + +// ============================================================================= +// MAIN COMPONENT +// ============================================================================= + +/** + * Top-level Aparavi AQL app component mounted by the shell. + * + * Initialises Documents with a VFS backed by the .chats/ workspace directory, + * starts the Aparavi pipeline on connect, and creates an initial chat tab if + * no saved chats exist. + */ +const AparaviApp: React.FC = () => { + // Shell connection — provides the RocketRide WebSocket client + const { client, isConnected } = useShellConnection(); + + // Auth identity — needed to confirm user is authenticated + const identity = useAuthUser(); + + // Workspace binding — persists tab layout across sessions + const { loaded, appState, updateAppState } = useWorkspace(); + + // Pipeline token from client.use() — shared across all chat tabs + const [pipelineToken, setPipelineToken] = useState(null); + + // Track pipeline startup to avoid duplicate .use() calls + const startedRef = useRef(false); + + // Documents ready flag + const [ready, setReady] = useState(false); + + // --- Initialise Documents on mount --------------------------------------- + + useEffect(() => { + if (!client || !loaded) return; + + /** VFS backed by the .chats/ workspace directory. */ + const vfs: IVirtualFileSystem = { + list: async (dir: string) => { + try { + const result = await listChatDir(client, dir); + return (result.entries ?? []).map((e: any) => ({ name: e.name, type: e.type ?? 'file' })); + } catch { return []; } + }, + read: async (uri: string) => { + try { return await loadChat(client, uri); } + catch { return null; } + }, + write: async (uri: string, content: unknown) => { + if (!content) return; + try { await saveChat(client, uri, content); } + catch (err) { console.error('[AparaviApp] Failed to save chat:', err); } + }, + rename: async (oldPath: string, newPath: string) => { + await renameChat(client, oldPath, newPath); + }, + delete: async (path: string) => { + await deleteChat(client, path); + }, + }; + + // Create the Documents instance — both App and Sidebar share it + createDocs(vfs, { appState, updateAppState }); + setReady(true); + + return () => { destroyDocs(); setReady(false); }; + }, [client, loaded]); + + // --- Start pipeline on connect ------------------------------------------- + + useEffect(() => { + if (!isConnected || !client || !identity || startedRef.current) return; + startedRef.current = true; + + client + .use({ pipeline, useExisting: true, name: 'Aparavi AQL Chat', pipelineTraceLevel: 'full' }) + .then((result) => { + setPipelineToken(result.token); + }) + .catch((err) => { + startedRef.current = false; + console.error('[AparaviApp] Failed to start pipeline:', err); + }); + }, [isConnected, client, identity]); + + if (!ready) return
Initialising...
; + return ; +}; + +// ============================================================================= +// INNER COMPONENT — renders once Documents is ready +// ============================================================================= + +/** + * Inner component that renders the tab layout and editor panes. + * Separated so useStore() is called unconditionally. + */ +const AparaviAppReady: React.FC<{ + docs: Documents; + pipelineToken: string | null; +}> = ({ docs, pipelineToken }) => { + const state = docs.useStore(); + const { client, isConnected } = useShellConnection(); + + // Whether there are multiple groups (controls close-group button visibility) + const canCloseGroups = state.rootNode.type === 'split'; + + // --- Ctrl+S handler ------------------------------------------------------- + + useEffect(() => { + /** Saves the active document on Ctrl+S. */ + const handler = () => { + const s = docs.getState(); + const group = s.groups[s.activeGroupId]; + if (!group) return; + const editorId = group.editorIds[group.activeEditorIndex]; + if (!editorId) return; + const editor = s.editors[editorId]; + if (!editor) return; + docs.saveDocument(editor.documentUri); + }; + window.addEventListener('tab:save', handler); + return () => window.removeEventListener('tab:save', handler); + }, []); + + return ( +
+ { + const group = state.groups[groupId]; + if (!group) return null; + + return ( +
docs.setActiveGroup(groupId)} + > + {/* Tab bar for this group */} + docs.splitGroupWithDocument(gid, dir)} + onCloseGroup={(gid) => docs.closeGroup(gid)} + /> + + {/* Editor content — each chat tab is independently mounted + so chat history is preserved across tab switches. */} +
+ {group.editorIds.length === 0 ? ( +
+
Aparavi AQL
+
Create a new chat from the sidebar.
+
+ ) : ( + group.editorIds.map((editorId, idx) => { + const editor = state.editors[editorId]; + if (!editor) return null; + const isActive = idx === group.activeEditorIndex; + return ( +
+ +
+ ); + }) + )} +
+
+ ); + }} + /> +
+ ); +}; + +// ============================================================================= +// CHAT TAB — independent persistent chat session per editor tab +// ============================================================================= + +/** + * A single chat tab. Loads initial messages from the Document's content, + * and persists messages back to Documents on every change so VFS auto-save + * keeps the .chat file up to date. + */ +const ChatTab: React.FC<{ + uri: string; + client: any; + isConnected: boolean; + pipelineToken: string | null; +}> = ({ uri, client, isConnected, pipelineToken }) => { + // Read initial messages from the document content (if loaded from disk) + const docs = getDocs(); + const savedMessages = useMemo(() => { + if (!docs) return []; + const doc = docs.getState().documents[uri]; + const content = doc?.content as { messages?: ChatMessage[] } | null; + return content?.messages ?? []; + }, [docs, uri]); + + const { messages, isTyping, sendMessage } = useChatMessages({ + initialMessages: savedMessages, + }); + + // Persist messages back to Documents + disk whenever they change + const saveTimer = useRef>(); + useEffect(() => { + if (!docs || messages.length === 0) return; + // Only persist user and bot messages (not status/system ephemeral ones) + const persistable = messages.filter((m) => m.sender === 'user' || m.sender === 'bot'); + if (persistable.length === 0) return; + const content = { messages: persistable }; + docs.updateContent(uri, content); + // Debounce the disk write to avoid excessive I/O on rapid message updates + clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + if (client) saveChat(client, uri, content).catch((err) => + console.error('[AparaviApp] Failed to persist chat to disk:', err) + ); + }, 500); + }, [messages, uri, docs, client]); + useEffect(() => () => clearTimeout(saveTimer.current), []); + + /** Send a message through the shared pipeline. */ + const handleSend = useCallback( + (text: string) => { + if (!client || !pipelineToken) return; + sendMessage(text, client, pipelineToken); + }, + [client, pipelineToken, sendMessage] + ); + + return ( + + ); +}; + +export default AparaviApp; diff --git a/apps/aparavi-ui/src/AparaviSidebar.tsx b/apps/aparavi-ui/src/AparaviSidebar.tsx new file mode 100644 index 000000000..5b16c5494 --- /dev/null +++ b/apps/aparavi-ui/src/AparaviSidebar.tsx @@ -0,0 +1,225 @@ +// MIT License +// +// Copyright (c) 2026 Aparavi Software AG +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ============================================================================= +// APARAVI SIDEBAR — chat file list using shared Explorer component +// ============================================================================= + +import React, { useCallback, useEffect, useState } from 'react'; +import type { ShellSidebarProps } from 'shell-ui'; +import { useShellConnection, NavButton, BxPlus } from 'shell-ui'; +import { Explorer } from 'shared'; +import type { ExplorerEntry, ExplorerConfig, IVirtualFileSystem } from 'shared'; +import { getDocs } from './docs'; +import { listChatDir, saveChat, deleteChat, renameChat } from './chatStore'; + +// ============================================================================= +// CONFIG +// ============================================================================= + +/** Explorer configuration for chat files. */ +const CHAT_CONFIG: ExplorerConfig = { + title: 'Chats', + extensions: ['.chat'], + displayName: (name: string) => name.replace(/\.chat$/, '') || name, + createPlaceholder: 'chat name', + emptyMessage: 'No saved chats', + allowFolders: false, +}; + +/** + * No-op VFS for the chat Explorer. + * + * The Aparavi sidebar manages file operations via onFileManage callbacks + * (which use the chatStore helpers) rather than through the VFS interface. + * This stub satisfies the Explorer contract without exposing raw VFS access. + */ +const NOOP_VFS: IVirtualFileSystem = { + list: async () => [], + read: async () => null, + write: async () => {}, + rename: async () => {}, + delete: async () => {}, + mkdir: async () => {}, +}; + +// ============================================================================= +// COMPONENT +// ============================================================================= + +/** + * Sidebar for the Aparavi AQL Chat app. + * + * Uses the shared Explorer component for the chat file list with built-in + * rename, delete, and create support. New chats are created as .chat files + * in the .chats/ workspace directory. + */ +const AparaviSidebar: React.FC = ({ collapsed }) => { + const { client, isConnected } = useShellConnection(); + const [entries, setEntries] = useState([]); + + // Active file path from Documents (for highlighting) + const [activeFile, setActiveFile] = useState(''); + useEffect(() => { + const docs = getDocs(); + if (!docs) return; + /** Read the active editor's document URI. */ + const readActive = (): string => { + const s = docs.getState(); + const group = s.groups[s.activeGroupId]; + if (!group) return ''; + const editorId = group.editorIds[group.activeEditorIndex]; + return editorId ? (s.editors[editorId]?.documentUri ?? '') : ''; + }; + setActiveFile(readActive()); + return docs.subscribe(() => setActiveFile(readActive())); + }, []); + + // --- Refresh file list ---------------------------------------------------- + + const refresh = useCallback(async () => { + if (!client || !isConnected) { setEntries([]); return; } + try { + const result = await listChatDir(client, ''); + const chatFiles: ExplorerEntry[] = (result.entries ?? []) + .filter((e: any) => e.name?.endsWith('.chat')) + .map((e: any) => ({ path: e.name, type: 'file' as const })); + setEntries(chatFiles); + } catch { + setEntries([]); + } + }, [client, isConnected]); + + // Refresh on mount and when connection changes + useEffect(() => { refresh(); }, [refresh]); + + // --- Create new chat ------------------------------------------------------ + + const handleNewChat = useCallback(async () => { + if (!client) return; + // Generate a unique name + const existing = new Set(entries.map((e) => e.path)); + let n = 1; + while (existing.has(`Chat ${n}.chat`)) n++; + const fileName = `Chat ${n}.chat`; + + // Create the file on disk with empty messages + try { + await saveChat(client, fileName, { messages: [] }); + await refresh(); + // Open it in the editor + getDocs()?.openDocument(fileName); + } catch (err) { + console.error('[AparaviSidebar] Failed to create chat:', err); + } + }, [client, entries, refresh]); + + // --- Open a chat file ----------------------------------------------------- + + const handleOpenFile = useCallback((path: string) => { + getDocs()?.openDocument(path); + }, []); + + // --- File management (rename, delete, createFile) ------------------------- + + const handleFileManage = useCallback(async ( + action: 'rename' | 'delete' | 'createFolder' | 'createFile', + path: string, + newName?: string, + ) => { + if (!client) return; + try { + switch (action) { + case 'rename': { + if (!newName) break; + const dir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : ''; + const newPath = dir + ? `${dir}/${newName}.chat` + : `${newName}.chat`; + if (newPath === path) break; + await renameChat(client, path, newPath); + // Update open editor tabs: close old, reopen at new path + const docs = getDocs(); + if (docs) { + const s = docs.getState(); + const editorIds = Object.entries(s.editors) + .filter(([, ed]) => ed.documentUri === path) + .map(([id]) => id); + for (const eid of editorIds) docs.closeEditor(eid); + if (editorIds.length > 0) await docs.openDocument(newPath); + } + break; + } + case 'delete': { + await deleteChat(client, path); + // Force-remove the document and all editors regardless of dirty state + // so stale content doesn't linger if a new chat reuses the same name + getDocs()?.discardDocument(path); + break; + } + case 'createFile': { + await saveChat(client, path, { messages: [] }); + getDocs()?.openDocument(path); + break; + } + } + await refresh(); + } catch (err) { + console.error(`[AparaviSidebar] ${action} failed:`, err); + } + }, [client, refresh]); + + // --- Collapsed mode ------------------------------------------------------- + + if (collapsed) { + return ( +
+ +
+ ); + } + + // --- Expanded mode -------------------------------------------------------- + + return ( +
+ {/* New Chat button */} +
+ +
+ + {/* Chat file tree (shared Explorer component) */} + +
+ ); +}; + +export default AparaviSidebar; diff --git a/apps/aparavi-ui/src/AppDescriptor.ts b/apps/aparavi-ui/src/AppDescriptor.ts new file mode 100644 index 000000000..b886d43a7 --- /dev/null +++ b/apps/aparavi-ui/src/AppDescriptor.ts @@ -0,0 +1,51 @@ +// MIT License +// +// Copyright (c) 2026 Aparavi Software AG +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ============================================================================= +// APP DESCRIPTOR — aparavi-ui MF remote entry point (Aparavi AQL Chat) +// ============================================================================= + +import type { AppDescriptor } from 'shell-ui'; +import AparaviApp from './AparaviApp'; +import AparaviSidebar from './AparaviSidebar'; + +/** + * AppDescriptor for the Aparavi AQL Chat application. + * + * Chat interface for querying Aparavi data via natural language. + * Multi-tab support via Documents library — each tab is an independent chat. + * Sidebar with "New Chat" button; status bar enabled in manifest. + * Requires authentication (authenticated: true in manifest). + */ +const APARAVI_APP: AppDescriptor = { + id: 'rocketride.aparavi', + name: 'Aparavi AQL', + branding: { + appName: 'Aparavi AQL', + }, + components: { + App: AparaviApp, + Sidebar: AparaviSidebar, + }, +}; + +export default APARAVI_APP; diff --git a/apps/aparavi-ui/src/aparavi.pipe b/apps/aparavi-ui/src/aparavi.pipe new file mode 100644 index 000000000..e20d86bdd --- /dev/null +++ b/apps/aparavi-ui/src/aparavi.pipe @@ -0,0 +1,183 @@ +{ + "components": [ + { + "id": "chat_1", + "provider": "chat", + "name": "Chat", + "config": { + "hideForm": true, + "mode": "Source", + "parameters": {}, + "type": "chat" + }, + "ui": { + "position": { + "x": 12, + "y": 34 + }, + "nodeType": "default", + "formDataValid": true + } + }, + { + "id": "agent_crewai_1", + "provider": "agent_crewai", + "name": "Aparavi Agent", + "config": { + "default": { + "advanced_mode": false, + "role": "Aparavi Data Analyst", + "goal": "Answer questions about data stored in the Aparavi platform by querying it via the Aparavi AQL tool.", + "backstory": "You are a data analyst with deep expertise in the Aparavi data governance platform. You help users explore their file metadata — sizes, types, dates, classifications, paths, and more — by translating natural-language questions into queries against the Aparavi STORE table.", + "task_description": "", + "expected_output": "A clear, concise answer to the user's question, including relevant data rows or summaries from the query results. Format tables as markdown when appropriate.", + "instructions": [ + "Always use the get_data tool for data retrieval — it handles AQL generation internally.", + "Only use get_aql if the user explicitly asks to see the query without executing it.", + "Only use get_schema as a fallback if get_data returns unexpected results.", + "When presenting results, summarize key findings and format data as markdown tables.", + "If no results are returned, suggest how the user might refine their question.", + "When using the chart tool, include its output verbatim in your answer." + ] + } + }, + "ui": { + "position": { + "x": 242, + "y": 24 + }, + "nodeType": "default", + "formDataValid": true + }, + "input": [ + { + "lane": "questions", + "from": "chat_1" + } + ] + }, + { + "id": "llm_openai_1", + "provider": "llm_openai", + "name": "LLM", + "config": { + "profile": "openai-4o", + "openai-4o": { + "apikey": "${ROCKETRIDE_OPENAI_KEY}" + }, + "parameters": { + "google": {} + } + }, + "ui": { + "position": { + "x": 130, + "y": 360 + }, + "nodeType": "default", + "formDataValid": true + }, + "control": [ + { + "classType": "llm", + "from": "agent_crewai_1" + }, + { + "classType": "llm", + "from": "aparavi_aql_1" + }, + { + "classType": "llm", + "from": "tool_chartjs_1" + } + ] + }, + { + "id": "aparavi_aql_1", + "provider": "aparavi_aql", + "name": "Aparavi AQL", + "config": { + "profile": "default", + "default": { + "url": "${ROCKETRIDE_APARAVI_URL}", + "user": "${ROCKETRIDE_APARAVI_USER}", + "password": "${ROCKETRIDE_APARAVI_PASSWORD}", + "db_description": "Enterprise file metadata — documents, emails, images, and other files from connected data sources." + } + }, + "ui": { + "position": { + "x": 290, + "y": 200 + }, + "nodeType": "default", + "formDataValid": true + }, + "control": [ + { + "classType": "tool", + "from": "agent_crewai_1" + }, + { + "classType": "llm", + "from": "llm_openai_1" + } + ] + }, + { + "id": "response_answers_1", + "provider": "response_answers", + "name": "Return Answers", + "config": { + "laneName": "answers" + }, + "ui": { + "position": { + "x": 472, + "y": 12 + }, + "nodeType": "default", + "formDataValid": true + }, + "input": [ + { + "lane": "answers", + "from": "agent_crewai_1" + } + ] + }, + { + "id": "tool_chartjs_1", + "provider": "tool_chartjs", + "name": "Chart (Chart.js)", + "config": { + "type": "tool_chartjs", + "name": "Chart (Chart.js)" + }, + "ui": { + "position": { + "x": 500, + "y": 210 + }, + "nodeType": "default", + "formDataValid": true + }, + "control": [ + { + "classType": "tool", + "from": "agent_crewai_1" + } + ] + } + ], + "source": "chat_1", + "project_id": "f1ca1b37-78bf-4ab0-a2f1-015446f5f9a2", + "version": 1, + "isLocked": false, + "snapToGrid": true, + "snapGridSize": [ + 10, + 10 + ], + "docRevision": 20 +} \ No newline at end of file diff --git a/apps/aparavi-ui/src/chatStore.ts b/apps/aparavi-ui/src/chatStore.ts new file mode 100644 index 000000000..c3c3700c9 --- /dev/null +++ b/apps/aparavi-ui/src/chatStore.ts @@ -0,0 +1,66 @@ +// MIT License +// +// Copyright (c) 2026 Aparavi Software AG +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ============================================================================= +// CHAT STORE — file operations for persistent chat sessions +// ============================================================================= +// +// All paths are relative to CHAT_DIR (e.g. "My Chat.chat"). +// Files are JSON with { messages: ChatMessage[] }. +// ============================================================================= + +import type { RocketRideClient } from 'rocketride'; + +/** Server-side directory where chat files are stored. */ +const CHAT_DIR = '.chats'; + +/** Read a chat file. Path is relative, e.g. "My Chat.chat". */ +export function loadChat(client: RocketRideClient, path: string): Promise { + return client.fsReadJson(`${CHAT_DIR}/${path}`); +} + +/** Write a chat file. Path is relative, e.g. "My Chat.chat". */ +export function saveChat(client: RocketRideClient, path: string, data: any): Promise { + return client.fsWriteJson(`${CHAT_DIR}/${path}`, data); +} + +/** Delete a chat file. */ +export function deleteChat(client: RocketRideClient, path: string): Promise { + return client.fsDelete(`${CHAT_DIR}/${path}`); +} + +/** Rename a chat file. Both paths are relative. */ +export function renameChat(client: RocketRideClient, oldPath: string, newPath: string): Promise { + return client.fsRename(`${CHAT_DIR}/${oldPath}`, `${CHAT_DIR}/${newPath}`); +} + +/** List files in the chat directory. Path is relative ("" for root). */ +export function listChatDir(client: RocketRideClient, path: string): Promise { + const storePath = path ? `${CHAT_DIR}/${path}` : CHAT_DIR; + return client.fsListDir(storePath); +} + +/** Strip .chat extension for display. */ +export function displayName(path: string): string { + const name = path.split('/').pop() ?? path; + return name.endsWith('.chat') ? name.slice(0, -5) : name; +} diff --git a/apps/aparavi-ui/src/docs.ts b/apps/aparavi-ui/src/docs.ts new file mode 100644 index 000000000..7ab58874e --- /dev/null +++ b/apps/aparavi-ui/src/docs.ts @@ -0,0 +1,67 @@ +// MIT License +// +// Copyright (c) 2026 Aparavi Software AG +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ============================================================================= +// APARAVI-UI DOCUMENTS INSTANCE +// ============================================================================= +// +// App-owned Documents instance shared between AparaviApp and AparaviSidebar. +// Chat sessions are persisted as .chat JSON files via the RocketRide client's +// workspace file API. +// ============================================================================= + +import { Documents } from 'shell-ui'; +import type { IVirtualFileSystem } from 'shared/modules/explorer/types'; + +/** The app's Documents instance. Set by AparaviApp on mount. */ +let _docs: Documents | null = null; + +/** + * Returns the app's Documents instance, or null if not yet initialised. + */ +export function getDocs(): Documents | null { + return _docs; +} + +/** + * Creates and stores the app's Documents instance. + * Called once by AparaviApp on mount. + * + * @param vfs - Virtual file system for reading/writing chat files. + * @param workspace - Optional workspace binding for tab layout persistence. + */ +export function createDocs( + vfs: IVirtualFileSystem, + workspace?: import('shell-ui').WorkspaceBinding, +): Documents { + _docs = new Documents(vfs, workspace); + return _docs; +} + +/** + * Destroys the app's Documents instance. + * Called by AparaviApp on unmount. + */ +export function destroyDocs(): void { + _docs?.destroy(); + _docs = null; +} diff --git a/apps/aparavi-ui/src/icon.svg b/apps/aparavi-ui/src/icon.svg new file mode 100644 index 000000000..a0a42b30e --- /dev/null +++ b/apps/aparavi-ui/src/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/vscode/src/providers/views/Auth/index.tsx b/apps/aparavi-ui/src/index.ts similarity index 83% rename from apps/vscode/src/providers/views/Auth/index.tsx rename to apps/aparavi-ui/src/index.ts index aca9e9ab5..f2d648a41 100644 --- a/apps/vscode/src/providers/views/Auth/index.tsx +++ b/apps/aparavi-ui/src/index.ts @@ -1,5 +1,5 @@ -// ============================================================================= // MIT License +// // Copyright (c) 2026 Aparavi Software AG // // Permission is hereby granted, free of charge, to any person obtaining a copy @@ -9,8 +9,8 @@ // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, @@ -19,12 +19,9 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -// ============================================================================= -import { Auth } from './AuthWebview'; -import { mountComponent } from '../../../shared/util/mount'; - -// Mount the Auth component -mountComponent(Auth, 'Auth'); +// ============================================================================= +// APARAVI-UI — async boundary for Module Federation +// ============================================================================= -export default Auth; +import('./AppDescriptor'); diff --git a/apps/aparavi-ui/src/pipe.d.ts b/apps/aparavi-ui/src/pipe.d.ts new file mode 100644 index 000000000..a79d8a8a1 --- /dev/null +++ b/apps/aparavi-ui/src/pipe.d.ts @@ -0,0 +1,8 @@ +// MIT License +// Copyright (c) 2026 Aparavi Software AG + +/** Allow importing .pipe files as JSON modules. */ +declare module '*.pipe' { + const value: Record; + export default value; +} diff --git a/apps/aparavi-ui/tsconfig.json b/apps/aparavi-ui/tsconfig.json new file mode 100644 index 000000000..8954439e9 --- /dev/null +++ b/apps/aparavi-ui/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "paths": { + "shared/*": ["../../packages/shared-ui/src/*"], + "shared": ["../../packages/shared-ui/src"], + "shell-ui": ["../shell-ui/src/index.ts"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/shell-ui/src/components/layout/Shell.tsx b/apps/shell-ui/src/components/layout/Shell.tsx index 4d4ed87f2..9d6fd4009 100644 --- a/apps/shell-ui/src/components/layout/Shell.tsx +++ b/apps/shell-ui/src/components/layout/Shell.tsx @@ -218,7 +218,7 @@ const Shell: React.FC = ({ config }) => { // Initialise the client singleton (idempotent) cm.init({ uri: RR_APIKEY ? undefined : ROCKETRIDE_URI, - clientName: config.apps[0]?.id ?? 'shell-ui', + clientName: 'Cloud Shell-UI', authProvider, zitadelUrl: RR_ZITADEL_URL, zitadelClientId: RR_ZITADEL_CLIENT_ID, diff --git a/apps/shell-ui/src/lib/Documents.tsx b/apps/shell-ui/src/lib/Documents.tsx index 3c8e9fe87..830deac63 100644 --- a/apps/shell-ui/src/lib/Documents.tsx +++ b/apps/shell-ui/src/lib/Documents.tsx @@ -498,6 +498,19 @@ export class Documents { return this._state.documents[uri]; } + // --- Subscription -------------------------------------------------------- + + /** + * Register a listener that fires on every state change. + * + * @param listener - Callback invoked after each state update. + * @returns An unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + } + // --- React hook ---------------------------------------------------------- /** @@ -739,6 +752,46 @@ export class Documents { }); } + /** + * Force-remove a document and all its editors from state, regardless of + * dirty status. Used when the backing file has been deleted from disk — + * any unsaved content is discarded. + * + * @param uri - The document URI to discard. + */ + discardDocument(uri: string): void { + this._update((prev) => { + const doc = prev.documents[uri]; + if (!doc) return prev; + + // Find and remove all editors for this document + const editorIdsToRemove = Object.entries(prev.editors) + .filter(([, ed]) => ed.documentUri === uri) + .map(([id]) => id); + + const { [uri]: _, ...remainingDocs } = prev.documents; + let remainingEditors = prev.editors; + for (const eid of editorIdsToRemove) { + const { [eid]: __, ...rest } = remainingEditors; + remainingEditors = rest; + } + + // Remove editor IDs from groups + let newGroups = prev.groups; + for (const gid of Object.keys(newGroups)) { + const group = newGroups[gid]!; + const filtered = group.editorIds.filter((id) => !editorIdsToRemove.includes(id)); + if (filtered.length !== group.editorIds.length) { + let newActiveIdx = group.activeEditorIndex; + if (newActiveIdx >= filtered.length) newActiveIdx = Math.max(0, filtered.length - 1); + newGroups = { ...newGroups, [gid]: { ...group, editorIds: filtered, activeEditorIndex: newActiveIdx } }; + } + } + + return { ...prev, documents: remainingDocs, editors: remainingEditors, groups: newGroups }; + }); + } + /** * Updates the in-memory content of a document and marks it dirty. * No-op if content hasn't changed (prevents infinite render loops). diff --git a/apps/shell-ui/src/workspace/useWorkspaceState.ts b/apps/shell-ui/src/workspace/useWorkspaceState.ts index 7a0a58e4b..288765c3e 100644 --- a/apps/shell-ui/src/workspace/useWorkspaceState.ts +++ b/apps/shell-ui/src/workspace/useWorkspaceState.ts @@ -410,6 +410,18 @@ export function useWorkspaceState( const currentState = appsRef.current[activeAppIdRef.current]; if (currentState) writeAppStateNow(activeAppIdRef.current, currentState); + // 1b. Clear all server-side monitor subscriptions from the outgoing app + // so the next app starts with a clean slate, and update the connection + // display name so the server monitor shows which app is active + if (client) { + try { + await client.clearAllMonitors(); + } catch (err) { + console.error('[Workspace] Failed to clear monitors on app switch:', err); + } + client.identify(`Cloud Shell-UI \u2014 ${newAppId}`).catch(() => {}); + } + // 2. Load new app state if not already in memory let newState = appsRef.current[newAppId]; if (!newState && client && isConnected && hasStoreApi(client)) { diff --git a/apps/vscode/rsbuild.config.mjs b/apps/vscode/rsbuild.config.mjs index 8aeec143e..9700fcf5f 100644 --- a/apps/vscode/rsbuild.config.mjs +++ b/apps/vscode/rsbuild.config.mjs @@ -56,7 +56,6 @@ export default defineConfig({ 'page-monitor': './src/providers/views/Monitor/index.tsx', 'page-account': './src/providers/views/Account/index.tsx', 'page-environment': './src/providers/views/Environment/index.tsx', - 'page-auth': './src/providers/views/Auth/index.tsx', }, }, diff --git a/apps/vscode/src/connection/connection.ts b/apps/vscode/src/connection/connection.ts index 35da60fc2..25577105d 100644 --- a/apps/vscode/src/connection/connection.ts +++ b/apps/vscode/src/connection/connection.ts @@ -445,6 +445,12 @@ export class ConnectionManager extends EventEmitter { this.logger.output(`${icons.error} Authentication failed: ${error.message}`); const mode = this.connectionStatus.connectionMode; + // Stop the client's auto-reconnect loop so it doesn't keep + // retrying with stale credentials. The reconcile path will + // call connectToEngine() with fresh credentials after the + // user fixes them in Settings and saves. + this.client.disconnect().catch(() => { /* best effort */ }); + // Only clear the cloud token — on-prem/docker/service keys // live in config, not SecretStorage. if (connectionModeUsesOAuth(mode)) { @@ -457,8 +463,8 @@ export class ConnectionManager extends EventEmitter { progressMessage: undefined, }); - // Open the auth page with the group and mode so it shows the right form - vscode.commands.executeCommand('rocketride.page.auth.open', this.group, mode, error.message); + // Open the settings page focused on the failing group so the user can fix credentials + vscode.commands.executeCommand('rocketride.page.settings.open', this.group, error.message); return; } this.logger.output(`${icons.info} Reconnect attempt failed: ${error.message}`); diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 49e79d18b..012491fbe 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -50,7 +50,7 @@ import { WelcomeProvider } from './providers/WelcomeProvider'; import { AccountProvider } from './providers/AccountProvider'; import { EnvironmentProvider } from './providers/EnvironmentProvider'; // BillingProvider removed — billing is now a tab in AccountProvider -import { AuthProvider } from './providers/AuthProvider'; +// AuthProvider removed — auth failures now open the Settings page directly import { AgentManager } from './agents/agent-manager'; import { syncServiceCatalog } from './agents/services'; import { CloudAuthProvider } from './auth/CloudAuthProvider'; @@ -270,8 +270,7 @@ export async function activate(context: vscode.ExtensionContext): Promise welcome = new WelcomeProvider(context, context.extensionUri); const account = new AccountProvider(context); const environment = new EnvironmentProvider(context); - const auth = new AuthProvider(context, context.extensionUri); - context.subscriptions.push(account, environment, auth); + context.subscriptions.push(account, environment); // Register unified project editor (canvas + status + trace) project = new ProjectProvider(context); diff --git a/apps/vscode/src/providers/AuthProvider.ts b/apps/vscode/src/providers/AuthProvider.ts deleted file mode 100644 index 74002f856..000000000 --- a/apps/vscode/src/providers/AuthProvider.ts +++ /dev/null @@ -1,301 +0,0 @@ -// ============================================================================= -// MIT License -// Copyright (c) 2026 Aparavi Software AG -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// ============================================================================= - -/** - * AuthProvider — authentication recovery page. - * - * Opened automatically when a connection attempt fails with an - * AuthenticationException. Shows the appropriate credential form based on - * the active connection mode (Cloud sign-in, On-prem API key, etc.). - * - * After the user fixes their credentials, "Save & Connect" persists them - * and triggers an EngineRegistry.reconcile() which restarts affected - * engines and reconnects the ConnectionManagers with fresh credentials. - */ - -import * as vscode from 'vscode'; -import { ConfigManager } from '../config'; -import { getConnectionManager, getEngineRegistry } from '../extension'; -import { ConnectionMessageHandler } from './shared/connection-message-handler'; -import { CloudAuthProvider } from '../auth/CloudAuthProvider'; - -// ============================================================================= -// PROVIDER -// ============================================================================= - -/** - * Authentication recovery page provider. - * - * Creates a webview panel that lets the user fix credentials after an - * AuthenticationException. Supports Cloud OAuth (PKCE), On-prem API keys, - * and Docker/Service default keys. After saving, triggers an engine - * reconcile to restart and reconnect with the new credentials. - */ -export class AuthProvider { - private disposables: vscode.Disposable[] = []; - private configManager: ConfigManager; - private connHandler: ConnectionMessageHandler; - private panel: vscode.WebviewPanel | undefined; - private pendingGroup: string | undefined; - private pendingConnectionMode: string | undefined; - private pendingErrorMessage: string | undefined; - - constructor( - private readonly context: vscode.ExtensionContext, - private readonly extensionUri: vscode.Uri - ) { - this.configManager = ConfigManager.getInstance(); - this.connHandler = new ConnectionMessageHandler({ - extensionFsPath: extensionUri.fsPath, - getActiveWebviews: () => (this.panel ? [this.panel.webview] : []), - getConnectionManager, - }); - this.registerCommands(); - } - - // ========================================================================= - // COMMANDS - // ========================================================================= - - private registerCommands(): void { - const cmd = vscode.commands.registerCommand('rocketride.page.auth.open', async (group?: string, connectionMode?: string, errorMessage?: string) => { - this.pendingGroup = group || 'development'; - this.pendingConnectionMode = connectionMode; - this.pendingErrorMessage = errorMessage; - await this.show(); - }); - this.disposables.push(cmd); - this.context.subscriptions.push(cmd); - } - - // ========================================================================= - // SHOW / LIFECYCLE - // ========================================================================= - - /** - * Creates or reveals the auth panel. - */ - public async show(): Promise { - if (this.panel) { - this.panel.reveal(vscode.ViewColumn.One); - // Re-send init in case mode/error changed - this.sendInit(); - return; - } - - this.panel = vscode.window.createWebviewPanel('rocketrideAuth', 'RocketRide: Sign In', vscode.ViewColumn.One, { - enableScripts: true, - localResourceRoots: [this.extensionUri], - retainContextWhenHidden: false, - }); - - this.panel.webview.html = this.getHtmlForWebview(this.panel.webview); - - // --- Message handler ----------------------------------------------------- - const messageDisposable = this.panel.webview.onDidReceiveMessage(async (message) => { - if (!this.panel) return; - try { - switch (message.type) { - case 'view:ready': - this.sendInit(); - break; - - case 'saveCredentials': - await this.saveAndConnect(message); - break; - - default: { - // Delegate cloud auth messages (cloud:signIn, cloud:signOut, cloud:getStatus, fetchTeams) - const handled = await this.connHandler.handleMessage(message, this.panel.webview); - if (handled) break; - console.warn('[AuthProvider] Unhandled message type:', message.type); - break; - } - } - } catch (error) { - console.error('[AuthProvider] Message handling error:', error); - this.panel?.webview.postMessage({ type: 'showMessage', level: 'error', message: `Error: ${error}` }); - } - }); - this.disposables.push(messageDisposable); - - // Listen for cloud auth changes (e.g. PKCE callback completes) - const panelWebview = this.panel.webview; - const cleanupCloudAuth = this.connHandler.registerCloudAuthListener(panelWebview); - - // When PKCE sign-in completes (cloud mode), auto-reconnect and close. - const cloudAuth = CloudAuthProvider.getInstance(); - const onAuthChanged = async () => { - try { - const signedIn = await cloudAuth.isSignedIn(); - if (signedIn) { - // Reconcile — cloud token changed, engine restarts, CM reconnects - const registry = getEngineRegistry(); - if (registry) await registry.reconcile(); - this.panel?.dispose(); - } - } catch (error) { - console.error('[AuthProvider] Reconnect after sign-in failed:', error); - } - }; - cloudAuth.onDidChange.on('changed', onAuthChanged); - - this.panel.onDidDispose(() => { - cleanupCloudAuth(); - cloudAuth.onDidChange.removeListener('changed', onAuthChanged); - this.panel = undefined; - const index = this.disposables.indexOf(messageDisposable); - if (index !== -1) { - this.disposables.splice(index, 1); - } - }); - } - - // ========================================================================= - // MESSAGING - // ========================================================================= - - /** - * Send the initial state to the webview so it knows which mode to render. - */ - private sendInit(): void { - if (!this.panel) return; - - const config = this.configManager.getConfig(); - const group = (this.pendingGroup || 'development') as 'development' | 'deployment'; - const groupConfig = config[group]; - const connectionMode = this.pendingConnectionMode || groupConfig.connectionMode; - - this.panel.webview.postMessage({ - type: 'init', - group, - connectionMode, - errorMessage: this.pendingErrorMessage || 'Authentication failed', - hostUrl: groupConfig.hostUrl, - apiKey: groupConfig.apiKey || '', - }); - - // Also send cloud auth status so CloudPanel knows the state - this.connHandler.sendCloudStatus(this.panel.webview); - } - - // ========================================================================= - // SAVE & CONNECT - // ========================================================================= - - /** - * Persist new credentials and trigger a reconnect via engine reconcile. - * On success, closes the auth panel. On failure, shows an error in the panel. - * - * Unlike SettingsProvider.saveAllSettings(), this only writes the credentials - * that changed (API key and/or host URL) rather than the full settings snapshot, - * since the user is only fixing auth — not changing other settings. - * - * @param message - The webview message containing group, apiKey, and/or hostUrl. - */ - private async saveAndConnect(message: Record): Promise { - try { - const group = (message.group as 'development' | 'deployment') || (this.pendingGroup as 'development' | 'deployment') || 'development'; - - // Persist API key if provided (on-prem, docker, service modes) - if (typeof message.apiKey === 'string') { - if (message.apiKey.trim() !== '') { - await this.configManager.setApiKey(group, message.apiKey.trim()); - } else { - await this.configManager.deleteApiKey(group); - } - } - - // Persist host URL if provided (on-prem mode) - if (typeof message.hostUrl === 'string') { - await this.configManager.updateHostUrl(group, message.hostUrl); - } - - // Reconcile triggers the full sequence: config checksum change detected - // -> engine restart -> 'ready' event -> CM reconnects with new credentials - const registry = getEngineRegistry(); - if (registry) await registry.reconcile(); - - // Close the panel only after successful reconnect - this.panel?.dispose(); - } catch (error) { - console.error('[AuthProvider] Failed to save credentials:', error); - this.panel?.webview.postMessage({ type: 'showMessage', level: 'error', message: `Failed to save: ${error}` }); - } - } - - // ========================================================================= - // HTML - // ========================================================================= - - private getHtmlForWebview(webview: vscode.Webview): string { - const nonce = this.generateNonce(); - const htmlPath = vscode.Uri.joinPath(this.extensionUri, 'webview', 'page-auth.html'); - - try { - let htmlContent = require('fs').readFileSync(htmlPath.fsPath, 'utf8'); - - htmlContent = htmlContent.replace(/\{\{nonce\}\}/g, nonce).replace(/\{\{cspSource\}\}/g, webview.cspSource); - - return htmlContent.replace(/(?:src|href)="(\/static\/[^"]+)"/g, (match: string, relativePath: string): string => { - const cleanPath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath; - const resourceUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'webview', cleanPath)); - return match.replace(relativePath, resourceUri.toString()); - }); - } catch (error) { - console.error('Error loading auth HTML:', error); - return ` - - Auth Error - -
-

Error Loading Auth View

-

Error: ${error}

-

Run pnpm run build to build the webview.

-

Expected: ${htmlPath.fsPath}

-
- - `; - } - } - - private generateNonce(): string { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; - } - - // ========================================================================= - // DISPOSE - // ========================================================================= - - public dispose(): void { - this.connHandler.dispose(); - this.panel?.dispose(); - this.disposables.forEach((d) => d.dispose()); - this.disposables = []; - } -} diff --git a/apps/vscode/src/providers/BarStatusProvider.ts b/apps/vscode/src/providers/BarStatusProvider.ts index b9d6f0cd1..1709591aa 100644 --- a/apps/vscode/src/providers/BarStatusProvider.ts +++ b/apps/vscode/src/providers/BarStatusProvider.ts @@ -141,7 +141,7 @@ export class BarStatus { vscode.commands.executeCommand('setContext', 'rocketride.connected', false); } else if (status.state === ConnectionState.AUTH_FAILED) { this.statusBarItem.text = '$(key) RocketRide: Sign In Required'; - this.statusBarItem.command = 'rocketride.page.auth.open'; + this.statusBarItem.command = 'rocketride.page.settings.open'; this.statusBarItem.tooltip = status.lastError || 'Authentication failed — click to sign in'; this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); vscode.commands.executeCommand('setContext', 'rocketride.connected', false); diff --git a/apps/vscode/src/providers/SettingsProvider.ts b/apps/vscode/src/providers/SettingsProvider.ts index d12473f42..0c5022d87 100644 --- a/apps/vscode/src/providers/SettingsProvider.ts +++ b/apps/vscode/src/providers/SettingsProvider.ts @@ -71,8 +71,8 @@ export class SettingsProvider { */ private registerCommands(): void { const commands = [ - vscode.commands.registerCommand('rocketride.page.settings.open', async (focus?: string) => { - await this.openSettings(focus); + vscode.commands.registerCommand('rocketride.page.settings.open', async (focus?: string, authError?: string) => { + await this.openSettings(focus, authError); }), vscode.commands.registerCommand('rocketride.page.settings.setupCredentials', async () => { @@ -105,19 +105,26 @@ export class SettingsProvider { */ /** Pending focus section — sent to webview after view:ready. */ private pendingFocus?: string; + /** Pending auth error — shown as a banner when the page opens due to auth failure. */ + private pendingAuthError?: string; /** * Opens the settings page, optionally focused on a single section. * @param focus - If set ('development' or 'deployment'), shows only that section. + * @param authError - If set, displays an auth-failure banner that clears on successful test. */ - public async openSettings(focus?: string): Promise { + public async openSettings(focus?: string, authError?: string): Promise { this.pendingFocus = focus; + this.pendingAuthError = authError; if (this.panel) { this.panel.reveal(vscode.ViewColumn.One); // Panel already open — send focus update directly if (focus) { this.panel.webview.postMessage({ type: 'setFocus', focus }); } + if (authError) { + this.panel.webview.postMessage({ type: 'authError', message: authError }); + } return; } @@ -144,6 +151,10 @@ export class SettingsProvider { panel.webview.postMessage({ type: 'setFocus', focus: this.pendingFocus }); this.pendingFocus = undefined; } + if (this.pendingAuthError) { + panel.webview.postMessage({ type: 'authError', message: this.pendingAuthError }); + this.pendingAuthError = undefined; + } await this.connHandler.startStatusPolling(); break; @@ -355,9 +366,7 @@ export class SettingsProvider { // Verify it was actually cleared const hasApiKey = this.configManager.hasApiKey(); - if (!hasApiKey) { - this.showMessage(webview, 'success', 'API Key cleared successfully and removed from secure storage'); - } else { + if (hasApiKey) { this.showMessage(webview, 'error', 'API Key may not have been fully cleared - please try again'); } diff --git a/apps/vscode/src/providers/shared/connection-message-handler.ts b/apps/vscode/src/providers/shared/connection-message-handler.ts index 5da0cbd66..104dd2c07 100644 --- a/apps/vscode/src/providers/shared/connection-message-handler.ts +++ b/apps/vscode/src/providers/shared/connection-message-handler.ts @@ -68,8 +68,8 @@ interface VersionCache { * engine version/Docker tag fetching (with caching), ioControl dispatch to * engine backends, sudo password relay, and engine status polling. * - * Used by SettingsProvider, WelcomeProvider, and AuthProvider so that - * connection management code is never duplicated across webview hosts. + * Used by SettingsProvider and WelcomeProvider so that connection + * management code is never duplicated across webview hosts. */ export class ConnectionMessageHandler { private readonly engineInstaller: EngineInstaller; diff --git a/apps/vscode/src/providers/views/Auth/AuthWebview.tsx b/apps/vscode/src/providers/views/Auth/AuthWebview.tsx deleted file mode 100644 index 394826c19..000000000 --- a/apps/vscode/src/providers/views/Auth/AuthWebview.tsx +++ /dev/null @@ -1,245 +0,0 @@ -// ============================================================================= -// MIT License -// Copyright (c) 2026 Aparavi Software AG -// ============================================================================= - -/** - * AuthWebview — authentication recovery page. - * - * Shown when a connection attempt fails with an AuthenticationException. - * Uses ConnectionConfig with authOnly={true} to render the appropriate - * credential form based on the failing connection's mode and group. - */ - -import React, { useState, CSSProperties } from 'react'; -import 'shared/themes/rocketride-default.css'; -import 'shared/themes/rocketride-vscode.css'; -import '../../styles/root.css'; -import { useMessaging } from '../hooks/useMessaging'; -import { ConnectionConfig } from '../components/ConnectionConfig'; -import { settingsStyles as S } from '../Settings/SettingsWebview'; -import type { SettingsData, ConnectionMode, ConnectionGroupSettings } from '../Settings/SettingsWebview'; - -// ============================================================================= -// STYLES -// ============================================================================= - -const styles = { - container: { - maxWidth: 560, - margin: '40px auto', - padding: '0 24px', - display: 'flex', - flexDirection: 'column', - gap: 24, - } as CSSProperties, - errorBanner: { - display: 'flex', - alignItems: 'center', - gap: 10, - padding: '12px 16px', - borderRadius: 6, - backgroundColor: 'var(--vscode-inputValidation-errorBackground, rgba(255,0,0,0.1))', - border: '1px solid var(--vscode-inputValidation-errorBorder, #be1100)', - color: 'var(--vscode-errorForeground, #f44336)', - fontSize: 13, - } as CSSProperties, - title: { - fontSize: 20, - fontWeight: 600, - margin: 0, - color: 'var(--rr-text-primary)', - } as CSSProperties, - saveButton: { - width: 'auto', - padding: '10px 24px', - fontWeight: 600, - alignSelf: 'flex-start', - } as CSSProperties, -}; - -// ============================================================================= -// DEFAULT GROUP SETTINGS -// ============================================================================= - -const DEFAULT_GROUP: ConnectionGroupSettings = { - connectionMode: 'local', - hostUrl: '', - hasApiKey: false, - apiKey: '', - teamId: '', - local: { engineVersion: 'latest', debugOutput: false, engineArgs: '' }, -}; - -// ============================================================================= -// COMPONENT -// ============================================================================= - -export const Auth: React.FC = () => { - // ── State ──────────────────────────────────────────────────────────────── - const [group, setGroup] = useState<'development' | 'deployment'>('development'); - const [errorMessage, setErrorMessage] = useState('Authentication failed'); - const [settings, setSettings] = useState({ - development: { ...DEFAULT_GROUP }, - deployment: { ...DEFAULT_GROUP, connectionMode: null }, - defaultPipelinePath: 'pipelines', - pipelineRestartBehavior: 'prompt', - envVars: {}, - autoAgentIntegration: true, - integrationCopilot: false, - integrationClaudeCode: false, - integrationCursor: false, - integrationWindsurf: false, - integrationClaudeMd: false, - integrationAgentsMd: false, - }); - - // Cloud auth state - const [cloudSignedIn, setCloudSignedIn] = useState(false); - const [cloudUserName, setCloudUserName] = useState(''); - const [teams, setTeams] = useState>([]); - - // ── Messaging ──────────────────────────────────────────────────────────── - const { sendMessage } = useMessaging, Record>({ - onMessage: (message) => { - switch (message.type as string) { - case 'init': - if (message.group) setGroup(message.group as 'development' | 'deployment'); - if (message.connectionMode) { - const g = (message.group as 'development' | 'deployment') || group; - setSettings((prev) => ({ - ...prev, - [g]: { - ...prev[g], - connectionMode: message.connectionMode as ConnectionMode, - hostUrl: (message.hostUrl as string) || prev[g].hostUrl, - apiKey: (message.apiKey as string) || prev[g].apiKey, - hasApiKey: !!message.apiKey, - }, - })); - } - if (message.errorMessage) setErrorMessage(message.errorMessage as string); - break; - - case 'cloud:status': - setCloudSignedIn((message.signedIn as boolean) ?? false); - setCloudUserName((message.userName as string) ?? ''); - break; - - case 'teamsLoaded': - setTeams((message.teams as Array<{ id: string; name: string }>) ?? []); - break; - } - }, - }); - - // ── Handlers ───────────────────────────────────────────────────────────── - - const handleSettingsChange = (changes: Partial) => { - setSettings((prev) => { - const next = { ...prev }; - if (changes.development) { - next.development = { ...prev.development, ...changes.development }; - if (changes.development.local) { - next.development.local = { ...prev.development.local, ...changes.development.local }; - } - } - if (changes.deployment) { - next.deployment = { ...prev.deployment, ...changes.deployment }; - if (changes.deployment.local) { - next.deployment.local = { ...prev.deployment.local, ...changes.deployment.local }; - } - } - const { development, deployment, ...topLevel } = changes; - Object.assign(next, topLevel); - return next; - }); - }; - - /** Save credentials and trigger reconnect. */ - const handleSave = () => { - const gc = settings[group]; - sendMessage({ type: 'saveCredentials', group, apiKey: gc.apiKey, hostUrl: gc.hostUrl }); - }; - - const groupLabel = group === 'development' ? 'Development' : 'Deployment'; - const connectionMode = settings[group].connectionMode; - - // ── Render ─────────────────────────────────────────────────────────────── - return ( -
-

{groupLabel}: Authentication Required

- -
- - {errorMessage} -
- - {/* Auth-only panel for the failing connection */} - {connectionMode === 'local' ? ( -
-
Local mode authentication failed unexpectedly. Try restarting the local engine or check the Output panel (RocketRide: Extension) for details.
-
- ) : ( - <> - {}} - settings={settings} - onSettingsChange={handleSettingsChange} - cloudSignedIn={cloudSignedIn} - cloudUserName={cloudUserName} - onCloudSignIn={() => sendMessage({ type: 'cloud:signIn' })} - onCloudSignOut={() => sendMessage({ type: 'cloud:signOut' })} - teams={teams} - onClearCredentials={() => { - handleSettingsChange({ [group]: { apiKey: '', hasApiKey: false } } as Partial); - }} - onTestConnection={() => {}} - testMessage={null} - engineVersions={[]} - engineVersionsLoading={false} - dockerStatus={{ state: 'not-installed', version: null, publishedAt: null, imageTag: null }} - dockerProgress={null} - dockerError={null} - dockerBusy={false} - dockerAction={null} - dockerVersions={[]} - dockerSelectedVersion="latest" - onDockerVersionChange={() => {}} - onDockerInstall={() => {}} - onDockerUpdate={() => {}} - onDockerRemove={() => {}} - onDockerStart={() => {}} - onDockerStop={() => {}} - serviceStatus={{ state: 'not-installed', version: null, publishedAt: null, installPath: null }} - serviceProgress={null} - serviceError={null} - serviceBusy={false} - serviceAction={null} - serviceVersions={[]} - serviceSelectedVersion="latest" - onServiceVersionChange={() => {}} - onServiceInstall={() => {}} - onServiceUpdate={() => {}} - onServiceRemove={() => {}} - onServiceStart={() => {}} - onServiceStop={() => {}} - sudoPromptVisible={false} - sudoPasswordInput="" - onSudoPasswordChange={() => {}} - onSudoSubmit={() => {}} - /> - - - - )} -
- ); -}; diff --git a/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx b/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx index 87ea9a6a4..65e8c5f77 100644 --- a/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx +++ b/apps/vscode/src/providers/views/Settings/SettingsWebview.tsx @@ -268,6 +268,37 @@ const subscribeBannerStyles = { } as CSSProperties, }; +// ============================================================================ +// AUTH ERROR BANNER STYLES +// ============================================================================ + +const authErrorBannerStyles = { + container: { + background: 'var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1))', + borderBottom: '1px solid var(--vscode-inputValidation-errorBorder, #be1100)', + padding: '10px 16px', + } as CSSProperties, + content: { + display: 'flex', + alignItems: 'center', + gap: 10, + } as CSSProperties, + text: { + fontSize: 13, + color: 'var(--vscode-errorForeground, #f44336)', + flex: 1, + } as CSSProperties, + dismiss: { + background: 'none', + border: 'none', + color: 'var(--vscode-errorForeground, #f44336)', + cursor: 'pointer', + fontSize: 14, + padding: '2px 6px', + flexShrink: 0, + } as CSSProperties, +}; + // ============================================================================ // SHARED CARD HEADER WITH SAVE BUTTON // ============================================================================ @@ -389,6 +420,9 @@ export const Settings: React.FC = () => { const [sudoPromptVisible, setSudoPromptVisible] = useState(false); const [sudoPasswordInput, setSudoPasswordInput] = useState(''); + // Auth error banner — shown when the settings page opens due to an auth failure + const [authError, setAuthError] = useState(null); + // Active settings tab const [activeTab, setActiveTab] = useState('development'); @@ -439,6 +473,10 @@ export const Settings: React.FC = () => { if ((message as any).focus) setActiveTab((message as any).focus); break; + case 'authError' as any: + setAuthError((message as any).message || 'Authentication failed'); + break; + case 'serverInfo' as any: { const caps = (message as any).capabilities || []; setServerCapabilities(caps); @@ -516,7 +554,11 @@ export const Settings: React.FC = () => { ? { level: 'success', message: 'Connection successful!' } : { level: 'error', message: error || 'Connection failed' }; setTestMessage(msg); - if (success) setTimeout(() => setTestMessage(null), 5000); + // Clear the auth error banner on successful test connection + if (success) { + setAuthError(null); + setTimeout(() => setTestMessage(null), 5000); + } break; } @@ -872,6 +914,22 @@ export const Settings: React.FC = () => { return (
+ {/* ── Auth error banner (shown when opened due to auth failure) ── */} + {authError && ( +
+
+ + {authError} + +
+
+ )} {/* ── Subscribe banner (cloud-signed-in but not subscribed) ── */} {cloudSignedIn && !subscribed && (
diff --git a/nodes/src/nodes/aparavi_aql/IGlobal.py b/nodes/src/nodes/aparavi_aql/IGlobal.py new file mode 100644 index 000000000..258d77eb2 --- /dev/null +++ b/nodes/src/nodes/aparavi_aql/IGlobal.py @@ -0,0 +1,74 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Aparavi AQL tool node — global (shared) state. + +Reads configuration and creates the AqlClient HTTP client for Aparavi REST API access. +""" + +from __future__ import annotations + +from ai.common.config import Config +from rocketlib import IGlobalBase, OPEN_MODE, warning + +from .aql_client import AqlClient + + +class IGlobal(IGlobalBase): + """Global state for aparavi_aql. + + Manages the shared AqlClient instance and configuration values + that are shared across all pipeline instances. + """ + + client: AqlClient | None = None + db_description: str = '' + + def beginGlobal(self) -> None: + """Initialize the Aparavi HTTP client from node configuration.""" + # Skip initialization during config-only mode + if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: + return + + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + + # Validate required URL + url = (cfg.get('url') or '').strip() + if not url: + warning('aparavi_aql: url is required') + return + + # Store data description for LLM prompt injection + self.db_description = str(cfg.get('db_description') or '') + + # Create the HTTP client + self.client = AqlClient( + url=url, + user=str(cfg.get('user') or ''), + password=str(cfg.get('password') or ''), + ) + + def endGlobal(self) -> None: + """Release the HTTP client.""" + self.client = None diff --git a/nodes/src/nodes/aparavi_aql/IInstance.py b/nodes/src/nodes/aparavi_aql/IInstance.py new file mode 100644 index 000000000..fea9d2cf5 --- /dev/null +++ b/nodes/src/nodes/aparavi_aql/IInstance.py @@ -0,0 +1,354 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Aparavi AQL tool node — instance state. + +Exposes three tools via @tool_function decorators: + get_data — natural language -> AQL -> execute -> return rows + get_aql — natural language -> AQL (no execution) + get_schema — return fixed STORE column schema +""" + +from __future__ import annotations + +import re +from typing import Any, Dict + +from rocketlib import IInstanceBase, tool_function +from rocketlib.types import IInvokeLLM +from ai.common.schema import Question + +from .IGlobal import IGlobal +from .aql_schema import get_schema_dict, get_schema_prompt_text + + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +# Block any mutations before hitting the network +_UNSAFE_PATTERN = re.compile( + r'\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|EXEC|EXECUTE)\b', + re.IGNORECASE, +) + +# Maximum retries when AQL execution fails +_MAX_AQL_RETRIES = 3 + + +def _aql_safe(aql: str) -> bool: + """ + Return True if the AQL is a single SELECT statement with no unsafe keywords. + + Enforces SELECT-only semantics: strips a trailing semicolon, rejects + multi-statement input (embedded semicolons), requires the query to begin + with SELECT, and blocks mutation keywords anywhere in the text. + """ + normalised = aql.strip().rstrip(';').strip() + # Reject multi-statement queries (semicolon inside the body) + if ';' in normalised: + return False + if not normalised.upper().startswith('SELECT'): + return False + return not _UNSAFE_PATTERN.search(normalised) + + +def _get_description(self: Any) -> str: + """Build tool description, prepending db_description when configured.""" + db_desc = (getattr(self.IGlobal, 'db_description', '') or '').strip() + prefix = f'{db_desc} ' if db_desc else '' + return ( + f'{prefix}' + 'PRIMARY tool for ALL Aparavi data retrieval. ' + 'Pass a plain English question — do NOT look up schema, column names, or write AQL yourself. ' + 'This tool handles schema knowledge, AQL generation, and execution internally. ' + 'Just describe what you want in natural language and it returns the rows.' + ) + + +# ============================================================================= +# IINSTANCE +# ============================================================================= + + +class IInstance(IInstanceBase): + """Aparavi AQL instance — provides tool functions for AQL query generation and execution.""" + + IGlobal: IGlobal + + # ------------------------------------------------------------------ + # TOOL METHODS + # ------------------------------------------------------------------ + + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['question'], + 'properties': { + 'question': { + 'type': 'string', + 'description': 'Natural-language description of the data you want from Aparavi', + }, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'rows': { + 'type': 'array', + 'description': 'Result rows returned by the AQL query', + 'items': {'type': 'object'}, + }, + 'aql': { + 'type': 'string', + 'description': 'The AQL query that was executed', + }, + 'count': { + 'type': 'integer', + 'description': 'Number of rows returned', + }, + 'error': { + 'type': 'string', + 'description': 'Error message if the query failed', + }, + }, + }, + description=_get_description, + ) + def get_data(self, args: Any) -> Dict[str, Any]: + """Translate natural language to AQL, execute against Aparavi, and return rows.""" + # Validate input + if not isinstance(args, dict): + raise ValueError('Tool input must be a JSON object') + question = args.get('question') + if not question or not isinstance(question, str) or not question.strip(): + raise ValueError('"question" is required and must be a non-empty string') + + # Get the HTTP client from global state + client = self.IGlobal.client + if client is None: + return {'error': 'aparavi_aql: client not initialized (check URL config)', 'rows': []} + + # Retry loop: generate AQL -> safety check -> execute + question_text = question.strip() + previous_aql: str | None = None + last_error: str | None = None + + for _ in range(_MAX_AQL_RETRIES): + # Generate AQL — include in retry loop so LLM failures also retry + try: + aql = self._generate_aql(question_text, previous_aql=previous_aql, error=last_error) + except Exception as exc: + last_error = str(exc) + previous_aql = None + continue + + # Safety check — block non-SELECT / mutation queries + if not _aql_safe(aql): + return {'error': 'Generated AQL contains unsafe operations', 'aql': aql, 'rows': []} + + try: + result = client.execute(aql) + return {'rows': result['rows'], 'aql': aql, 'count': result['count']} + except RuntimeError as exc: + last_error = str(exc) + previous_aql = aql + + return {'error': last_error, 'aql': previous_aql, 'rows': []} + + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['question'], + 'properties': { + 'question': { + 'type': 'string', + 'description': 'Natural-language description of the data you want from Aparavi', + }, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'aql': { + 'type': 'string', + 'description': 'The generated AQL SELECT statement', + }, + }, + }, + description=( + 'Convert a natural-language question to an AQL SELECT statement ' + 'without executing it. Use only when the user explicitly asks to see the query.' + ), + ) + def get_aql(self, args: Any) -> Dict[str, Any]: + """Generate an AQL SELECT statement from natural language without executing it.""" + # Validate input + if not isinstance(args, dict): + raise ValueError('Tool input must be a JSON object') + question = args.get('question') + if not question or not isinstance(question, str) or not question.strip(): + raise ValueError('"question" is required and must be a non-empty string') + + aql = self._generate_aql(question.strip()) + return {'aql': aql} + + @tool_function( + input_schema={ + 'type': 'object', + 'properties': {}, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'store': { + 'type': 'string', + 'description': 'Table name (always "STORE")', + }, + 'columns': { + 'type': 'array', + 'description': 'Column definitions for the STORE table', + 'items': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'type': {'type': 'string', 'description': 'STRING | NUMBER | DATE | OBJECT'}, + 'description': {'type': 'string'}, + }, + }, + }, + }, + }, + description=( + 'FALLBACK ONLY — returns the fixed column schema for the Aparavi STORE table. ' + 'Do NOT call this preemptively; only use if get_data fails or returns unexpected results.' + ), + ) + def get_schema(self, args: Any) -> Dict[str, Any]: + """Return the fixed STORE table column schema.""" + return get_schema_dict() + + # ------------------------------------------------------------------ + # AQL GENERATION + # ------------------------------------------------------------------ + + def _generate_aql( + self, + question_text: str, + *, + previous_aql: str | None = None, + error: str | None = None, + ) -> str: + """Use the connected LLM to translate a natural-language question into AQL. + + Builds a structured Question with schema context, syntax rules, and examples, + then invokes the LLM via the framework's standard IInvokeLLM.Ask pattern. + """ + # Build the structured question for the LLM + q = Question(role='You are an Aparavi AQL query generator.') + + q.addInstruction( + 'Output format', + 'Output ONLY the raw AQL query string -- no markdown fences, no explanation, no preamble.', + ) + + q.addInstruction( + 'AQL syntax', + ( + 'AQL is an SQL-like language for querying the Aparavi STORE table.\n' + 'Basic structure:\n' + " SELECT cols FROM STORE [WHERE condition] [WHICH CONTAIN 'term']\n" + ' [GROUP BY col] [HAVING cond] [ORDER BY col ASC|DESC] [LIMIT n]\n\n' + 'Key rules:\n' + ' - No JOINs -- STORE is the only table\n' + ' - Size units are supported: 10 MB, 5 GB, 100 KB\n' + ' - Always add LIMIT 250 unless the user specifies a different limit\n' + ' - Aggregate functions: COUNT, SUM, AVG, MIN, MAX\n' + ' - Date functions: NOW(), TODAY(), YEAR(), MONTH(), DAY()\n' + ' - NOW() returns seconds since the Unix epoch; DATE columns are also compared in seconds\n' + ' - Date arithmetic example: last 30 days = NOW() - (30 * 86400)\n' + ' - String functions: UPPER, LOWER, TRIM, LENGTH, SUBSTR, CONCAT\n' + ' - CAST(expr AS NUMBER|DATE|STRING)\n' + ' - CASE WHEN cond THEN val ELSE val END\n' + ' - Always quote column ALIASES with double quotes to avoid reserved-word conflicts,\n' + ' e.g. YEAR(createTime) AS "year", COUNT(*) AS "count", size AS "size"' + ), + ) + + q.addInstruction( + 'Column selection', + ( + 'Select only the columns relevant to the question. ' + 'Use SELECT * only when the user explicitly asks for all data. ' + 'For count-only questions use COUNT(*) with GROUP BY.' + ), + ) + + # Inject the full schema as context + q.addContext(get_schema_prompt_text()) + + # Inject optional data description from config + db_desc = (self.IGlobal.db_description or '').strip() + if db_desc: + q.addContext(f'Data context: {db_desc}') + + # Few-shot examples + q.addExample( + 'Find all PDF files larger than 10 MB', + "SELECT name, parentPath, size, modifyTime FROM STORE WHERE extension = 'pdf' AND size > 10 MB LIMIT 250", + ) + q.addExample( + 'Count files by extension', + 'SELECT extension, COUNT(*) AS "count" FROM STORE GROUP BY extension ORDER BY "count" DESC LIMIT 250', + ) + q.addExample( + 'Files modified in the last 7 days', + 'SELECT name, parentPath, size, modifyTime FROM STORE WHERE modifyTime > NOW() - (7 * 86400) LIMIT 250', + ) + + # If retrying, include the previous failure context + if previous_aql and error: + q.addContext( + f'Your previous AQL attempt was rejected with this error:\n\n{error}\n\n' + f'Failed AQL:\n{previous_aql}\n\n' + f'Fix the query and try again.' + ) + + q.addGoal('Generate a valid AQL SELECT query for the Aparavi STORE table that answers the question.') + q.addQuestion(question_text) + + # Invoke the LLM via the framework + result = self.instance.invoke(IInvokeLLM.Ask(question=q)) + + if not result or not result.answer: + raise ValueError('LLM failed to generate an AQL query.') + + # Strip any accidental markdown fences from the response + text = result.answer.strip() + if text.startswith('```'): + lines = text.split('\n') + end = len(lines) - 1 if lines[-1].strip() == '```' else len(lines) + text = '\n'.join(lines[1:end]).strip() + + return text diff --git a/nodes/src/nodes/aparavi_aql/__init__.py b/nodes/src/nodes/aparavi_aql/__init__.py new file mode 100644 index 000000000..0166206f4 --- /dev/null +++ b/nodes/src/nodes/aparavi_aql/__init__.py @@ -0,0 +1,37 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +# ------------------------------------------------------------------------------ +# Main module +# ------------------------------------------------------------------------------ +import os +from depends import depends # type: ignore + +# Load the requirements +requirements = os.path.dirname(os.path.realpath(__file__)) + '/requirements.txt' +depends(requirements) + +from .IGlobal import IGlobal # noqa: E402 +from .IInstance import IInstance # noqa: E402 + +__all__ = ['IGlobal', 'IInstance'] diff --git a/nodes/src/nodes/aparavi_aql/aql_client.py b/nodes/src/nodes/aparavi_aql/aql_client.py new file mode 100644 index 000000000..2d5691f66 --- /dev/null +++ b/nodes/src/nodes/aparavi_aql/aql_client.py @@ -0,0 +1,149 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +HTTP client for the Aparavi REST API. + +Executes AQL queries via: + POST /server/api/v3/database/query + Body: {"select": "", "options": {"objectOffset": 0, "objectLimit": n}} + Auth: HTTP Basic Auth (username + password) + +Validates AQL via: + GET /server/api/v3/database/query?select=&options={"validate":true} +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import requests + + +class AqlClient: + """HTTP client for the Aparavi REST API. + + Executes and validates AQL queries via the Aparavi database endpoint. + Handles Basic Auth, timestamp normalization, and error wrapping. + """ + + BASE_PATH = '/server/api/v3/database/query' + DEFAULT_LIMIT = 250 + TIMEOUT = 30 + + def __init__(self, *, url: str, user: str, password: str) -> None: + """Initialize the client with server URL and credentials.""" + self._base = url.rstrip('/') + self._auth = (user, password) + + def execute(self, aql: str, limit: int = DEFAULT_LIMIT) -> Dict[str, Any]: + """Execute an AQL query and return parsed results. + + Returns: + {"rows": [...], "count": int, "columns": [...]} + + Raises: + RuntimeError on HTTP error or non-JSON response. + """ + try: + resp = requests.post( + self._base + self.BASE_PATH, + auth=self._auth, + json={ + 'select': aql, + 'options': {'objectOffset': 0, 'objectLimit': limit}, + }, + timeout=self.TIMEOUT, + ) + resp.raise_for_status() + envelope = resp.json() + except requests.HTTPError as exc: + raise RuntimeError(f'Aparavi API HTTP error: {exc}') from exc + except requests.RequestException as exc: + raise RuntimeError(f'Aparavi API connection error: {exc}') from exc + except (ValueError, KeyError) as exc: + raise RuntimeError(f'Aparavi API returned unexpected response: {exc}') from exc + + if not isinstance(envelope, dict): + raise RuntimeError(f'Aparavi API returned non-object response: {type(envelope).__name__}') + + if envelope.get('status') != 'OK': + msg = envelope.get('message') or envelope.get('error') or envelope.get('status') or 'unknown error' + raise RuntimeError(f'Aparavi API error: {msg}') + + data = envelope.get('data') or {} + rows: List[Any] = data.get('objects') or [] + count: int = data.get('objectCount') or len(rows) + columns: List[Any] = data.get('columns') or [] + + rows = [self._normalize_row(row) for row in rows] + return {'rows': rows, 'count': count, 'columns': columns} + + # Epoch values above this threshold are in milliseconds, not seconds. + # 1e10 == year 2286 in seconds -- nothing Aparavi stores will legitimately + # be that far in the future, so anything larger must be milliseconds. + _MS_THRESHOLD = 10_000_000_000 + + # frozenset — immutable constant; a mutable set could be accidentally modified at class level + _DATE_FIELDS: frozenset = frozenset( + { + 'createTime', + 'modifyTime', + 'accessTime', + 'docCreateTime', + 'docModifyTime', + 'instanceMessageTime', + 'objectMessageTime', + } + ) + + def _normalize_row(self, row: Any) -> Any: + """Normalize timestamp fields from milliseconds to seconds where needed.""" + if not isinstance(row, dict): + return row + for field in self._DATE_FIELDS: + val = row.get(field) + if isinstance(val, (int, float)) and val > self._MS_THRESHOLD: + row[field] = val / 1000 + return row + + def validate(self, aql: str) -> bool: + """Validate an AQL query without executing it.""" + try: + resp = requests.get( + self._base + self.BASE_PATH, + auth=self._auth, + params={ + 'select': aql, + 'options': json.dumps({'validate': True}), + }, + timeout=self.TIMEOUT, + ) + resp.raise_for_status() + envelope = resp.json() + if envelope.get('status') != 'OK': + return False + return bool((envelope.get('data') or {}).get('valid', False)) + except Exception: + return False diff --git a/nodes/src/nodes/aparavi_aql/aql_schema.py b/nodes/src/nodes/aparavi_aql/aql_schema.py new file mode 100644 index 000000000..5c8874bc1 --- /dev/null +++ b/nodes/src/nodes/aparavi_aql/aql_schema.py @@ -0,0 +1,236 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Fixed schema for the Aparavi STORE table. + +Derived from: + C:/Projects/aparavi/app/server/server/services/parse/public/columns.ts + +Provides two representations: + - get_schema_dict() Structured dict for the get_schema tool response + - get_schema_prompt_text() Compact text block for injection into LLM prompts +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +# --------------------------------------------------------------------------- +# Column definitions — canonical source of truth +# Each entry: name, type (STRING | NUMBER | DATE | OBJECT), description +# --------------------------------------------------------------------------- +COLUMNS: List[Dict[str, Any]] = [ + # Default / most-used columns (ordered first for relevance) + {'name': 'parentPath', 'type': 'STRING', 'description': 'Path to the corresponding directory of a file'}, + {'name': 'name', 'type': 'STRING', 'description': 'Name of file or directory'}, + {'name': 'classId', 'type': 'STRING', 'description': 'Type of node: idxcontainer=directory, idxobject=file, etc'}, + {'name': 'size', 'type': 'NUMBER', 'description': 'Size of the file on the local file system'}, + {'name': 'storeSize', 'type': 'NUMBER', 'description': 'Size of the file as stored by the database in bytes'}, + { + 'name': 'modifyTime', + 'type': 'DATE', + 'description': 'Date file was last modified on the local file system (since Unix epoch in seconds)', + }, + { + 'name': 'createTime', + 'type': 'DATE', + 'description': 'Date file was first created on the local file system (since Unix epoch in seconds)', + }, + { + 'name': 'accessTime', + 'type': 'DATE', + 'description': 'Date file was last accessed on the local file system (since Unix epoch in seconds)', + }, + # Identity + {'name': 'uniqueId', 'type': 'STRING', 'description': 'Unique external ID provided by the source service'}, + {'name': 'uniqueName', 'type': 'STRING', 'description': 'Unique name inside its parent directory'}, + {'name': 'objectId', 'type': 'STRING', 'description': 'Internal object ID'}, + {'name': 'parentId', 'type': 'STRING', 'description': 'Parent object ID'}, + {'name': 'instanceId', 'type': 'NUMBER', 'description': 'Instance ID of the file'}, + {'name': 'nodeObjectId', 'type': 'STRING', 'description': 'ID of the returned node'}, + {'name': 'componentId', 'type': 'STRING', 'description': 'Component ID as text'}, + {'name': 'dupKey', 'type': 'STRING', 'description': 'Cryptographic file signature representing unique content'}, + # File attributes + {'name': 'extension', 'type': 'STRING', 'description': 'File extension (without dot)'}, + {'name': 'category', 'type': 'STRING', 'description': 'Category for which the file extension belongs'}, + {'name': 'mimeType', 'type': 'STRING', 'description': 'File MIME type'}, + { + 'name': 'version', + 'type': 'NUMBER', + 'description': 'Number of times a file has changed; each number is a new version', + }, + {'name': 'attrib', 'type': 'NUMBER', 'description': 'Number of attributes associated with the file'}, + {'name': 'flags', 'type': 'NUMBER', 'description': 'Object flags indicating status of file'}, + {'name': 'iFlags', 'type': 'NUMBER', 'description': 'Instance flags indicating current status of file'}, + # Document metadata + { + 'name': 'metadata', + 'type': 'STRING', + 'description': 'Document internal information (creator, original dates, etc.)', + }, + {'name': 'metadataObject', 'type': 'OBJECT', 'description': 'Document internal information as JSON object'}, + {'name': 'docModifyTime', 'type': 'DATE', 'description': 'Date the document was last modified'}, + {'name': 'docCreateTime', 'type': 'DATE', 'description': 'Date the document was created'}, + {'name': 'docModifier', 'type': 'STRING', 'description': 'User by whom the document was last modified'}, + {'name': 'docCreator', 'type': 'STRING', 'description': 'User by whom the document was created'}, + # Email metadata + {'name': 'metadataEmailTo', 'type': 'STRING', 'description': 'Email recipient(s) from metadata'}, + {'name': 'metadataEmailFrom', 'type': 'STRING', 'description': 'Email sender from metadata'}, + {'name': 'metadataEmailCc', 'type': 'STRING', 'description': 'Email CC recipients from metadata'}, + {'name': 'metadataEmailFromEmail', 'type': 'STRING', 'description': 'Email sender email address from metadata'}, + {'name': 'metadataEmailFromName', 'type': 'STRING', 'description': 'Email sender name from metadata'}, + { + 'name': 'metadataEmailHasAttachments', + 'type': 'STRING', + 'description': 'Indicates if email has attachments based on multipart subtype', + }, + # Cost / storage metrics + {'name': 'storageCost', 'type': 'NUMBER', 'description': 'Cost of maintaining a file in storage for up to 30 days'}, + {'name': 'retrievalCost', 'type': 'NUMBER', 'description': 'Cost for retrieving a file'}, + {'name': 'retrievalTime', 'type': 'NUMBER', 'description': 'Estimated time for retrieving a file'}, + {'name': 'compressionRatio', 'type': 'NUMBER', 'description': 'Ratio of stored size to actual file size (percent)'}, + {'name': 'dupCount', 'type': 'NUMBER', 'description': 'Number of identical copies of a file in the database'}, + {'name': 'dri', 'type': 'NUMBER', 'description': 'Data Readiness Index of the file'}, + # Paths + {'name': 'path', 'type': 'STRING', 'description': 'Full path to the file'}, + {'name': 'localPath', 'type': 'STRING', 'description': 'Local path of the file'}, + {'name': 'localParentPath', 'type': 'STRING', 'description': 'Path to file on local disk'}, + {'name': 'winPath', 'type': 'STRING', 'description': 'Windows absolute path of file'}, + {'name': 'winParentPath', 'type': 'STRING', 'description': 'Windows parent path of file'}, + {'name': 'winLocalPath', 'type': 'STRING', 'description': 'Windows local path of file'}, + {'name': 'winLocalParentPath', 'type': 'STRING', 'description': 'Windows local parent path of file'}, + {'name': 'winNativePath', 'type': 'STRING', 'description': 'Windows Local and SMB Paths combined'}, + {'name': 'linNativePath', 'type': 'STRING', 'description': 'Linux Local and SMB Paths combined'}, + # Service + {'name': 'serviceId', 'type': 'NUMBER', 'description': 'Target or source unique identifier'}, + {'name': 'service', 'type': 'STRING', 'description': 'Name of the target or source service'}, + {'name': 'serviceType', 'type': 'STRING', 'description': 'Type of service (target or source)'}, + {'name': 'serviceMode', 'type': 'STRING', 'description': 'Source or Target'}, + # Tags + {'name': 'tags', 'type': 'OBJECT', 'description': 'Object tags as a collection (processed by backend)'}, + {'name': 'tagString', 'type': 'STRING', 'description': 'Object tags as a single line of text'}, + {'name': 'tagSetId', 'type': 'NUMBER', 'description': 'Tag set ID'}, + {'name': 'instanceTags', 'type': 'OBJECT', 'description': 'Instance tags as a collection'}, + {'name': 'instanceTagString', 'type': 'STRING', 'description': 'Instance tags as text'}, + {'name': 'userTag', 'type': 'STRING', 'description': 'Single user tag associated with file'}, + {'name': 'userTags', 'type': 'STRING', 'description': 'Set of user tags for the file'}, + {'name': 'userTagObjects', 'type': 'OBJECT', 'description': 'User tags as objects (processed by backend)'}, + # Dataset + {'name': 'datasetId', 'type': 'NUMBER', 'description': 'ID of the dataset'}, + {'name': 'dataset', 'type': 'STRING', 'description': 'Name of the dataset'}, + {'name': 'batchId', 'type': 'NUMBER', 'description': 'Batch ID of the batch the files belonged to when processed'}, + {'name': 'instanceBatchId', 'type': 'NUMBER', 'description': 'Instance batch ID'}, + {'name': 'wordBatchId', 'type': 'NUMBER', 'description': 'Batch ID containing the words in the file'}, + {'name': 'vectorBatchId', 'type': 'NUMBER', 'description': 'Batch ID containing the vectors in the file'}, + # Classification + {'name': 'classificationId', 'type': 'NUMBER', 'description': 'ID number associated with a classification'}, + {'name': 'classification', 'type': 'STRING', 'description': 'Name of the classification applied to the file'}, + {'name': 'confidence', 'type': 'NUMBER', 'description': 'Confidence level of a classification hit (0-1)'}, + { + 'name': 'classifications', + 'type': 'STRING', + 'description': 'Semicolon-separated list of all classifications for the file', + }, + { + 'name': 'classificationObjects', + 'type': 'OBJECT', + 'description': 'Classification set as objects (processed by backend)', + }, + # Ownership & permissions + {'name': 'osOwner', 'type': 'STRING', 'description': 'Owner of the file'}, + {'name': 'osOwnerObject', 'type': 'OBJECT', 'description': 'OS owner details (processed by backend)'}, + {'name': 'osPermission', 'type': 'STRING', 'description': "A file's Operating System permission"}, + {'name': 'osPermissions', 'type': 'STRING', 'description': 'All OS permissions associated with the file'}, + { + 'name': 'osPermissionObjects', + 'type': 'OBJECT', + 'description': 'OS permissions as objects (processed by backend)', + }, + # Audit messages + {'name': 'instanceMessage', 'type': 'STRING', 'description': 'Audit message of action(s) taken on an instance'}, + {'name': 'instanceMessageTime', 'type': 'DATE', 'description': 'Time an action was taken on an instance'}, + { + 'name': 'instanceMessageObjects', + 'type': 'OBJECT', + 'description': 'Audit message with associated transaction (processed by backend)', + }, + {'name': 'objectMessage', 'type': 'STRING', 'description': 'Message associated with the file'}, + {'name': 'objectMessageTime', 'type': 'DATE', 'description': 'Time when the file message was sent'}, + {'name': 'objectMessageObjects', 'type': 'OBJECT', 'description': 'File message as objects (processed by backend)'}, + # Search hits + {'name': 'searchHit', 'type': 'STRING', 'description': 'Search term that was found'}, + {'name': 'searchHitWordsBefore', 'type': 'STRING', 'description': 'Words leading up to the search hit'}, + {'name': 'searchHitWordsAfter', 'type': 'STRING', 'description': 'Words trailing the search hit'}, + {'name': 'searchHits', 'type': 'OBJECT', 'description': 'Full search hit context (use with WHICH CONTAIN clause)'}, + # Classification hits + {'name': 'classificationHit', 'type': 'STRING', 'description': 'Content detected as a classification hit'}, + {'name': 'classificationHitPolicy', 'type': 'STRING', 'description': 'Policy of the classification hit'}, + { + 'name': 'classificationHitRule', + 'type': 'STRING', + 'description': 'Rules of the classification associated with the file', + }, + { + 'name': 'classificationHitConfidence', + 'type': 'NUMBER', + 'description': 'Confidence threshold required for classification', + }, + { + 'name': 'classificationHitWordsBefore', + 'type': 'STRING', + 'description': 'Words leading up to the classification hit', + }, + {'name': 'classificationHitWordsAfter', 'type': 'STRING', 'description': 'Words trailing the classification hit'}, + {'name': 'classificationHits', 'type': 'OBJECT', 'description': 'Full classification hit context'}, + # Status flags (stored as NUMBER: 0 or 1) + {'name': 'isContainer', 'type': 'NUMBER', 'description': '1 if this item is a directory/container, 0 otherwise'}, + {'name': 'isDeleted', 'type': 'NUMBER', 'description': '1 if the data has been removed, 0 otherwise'}, + {'name': 'isObject', 'type': 'NUMBER', 'description': '1 if this item is a file/object, 0 otherwise'}, + { + 'name': 'isSigned', + 'type': 'NUMBER', + 'description': '1 if a cryptographic signature has been applied to the file', + }, + {'name': 'isIndexed', 'type': 'NUMBER', 'description': '1 if the file is indexed'}, + {'name': 'isClassified', 'type': 'NUMBER', 'description': '1 if the file is classified'}, + # Virtual + {'name': 'node', 'type': 'STRING', 'description': 'Virtual column: name of the aggregator or collector node'}, + {'name': 'processPipe', 'type': 'STRING', 'description': 'Process pipe ID of the file'}, +] + + +def get_schema_dict() -> Dict[str, Any]: + """Return the structured schema dict for the get_schema tool response.""" + return {'store': 'STORE', 'columns': list(COLUMNS)} + + +def get_schema_prompt_text() -> str: + """Return a compact column listing for injection into LLM prompts.""" + lines = [ + 'Table: STORE', + 'Columns:', + ] + for col in COLUMNS: + lines.append(f' {col["name"]} ({col["type"]}) — {col["description"]}') + return '\n'.join(lines) diff --git a/nodes/src/nodes/aparavi_aql/requirements.txt b/nodes/src/nodes/aparavi_aql/requirements.txt new file mode 100644 index 000000000..d3607737b --- /dev/null +++ b/nodes/src/nodes/aparavi_aql/requirements.txt @@ -0,0 +1,2 @@ +# Nothing other than the base is required + diff --git a/nodes/src/nodes/aparavi_aql/services.json b/nodes/src/nodes/aparavi_aql/services.json new file mode 100644 index 000000000..85eceb1b5 --- /dev/null +++ b/nodes/src/nodes/aparavi_aql/services.json @@ -0,0 +1,163 @@ +{ + // + // Required: + // The displayable name of this node + // + "title": "Aparavi AQL", + // + // Required: + // The protocol is the endpoint protocol + // + "protocol": "aparavi_aql://", + // + // Required: + // Class type of the node - what it does + // + "classType": [ + "database", + "tool" + ], + // + // Required: + // Capabilities are flags that change the behavior of the underlying + // engine + // + "capabilities": [ + "noremote", + "invoke" + ], + // + // Optional: + // Register is either filter, endpoint or ignored if not specified. + // + "register": "filter", + // + // Optional: + // The node is the actual physical node to instantiate + // + "node": "python", + // + // Optional: + // The path is the executable/script code + // + "path": "nodes.aparavi_aql", + // + // Required: + // The prefix map when added/removed when converting URLs <=> paths + // + "prefix": "aparavi", + // + // Optional: + // Description of this driver + // + "description": [ + "Queries the Aparavi data governance platform using AQL (Aparavi Query Language). ", + "Accepts natural-language questions, generates AQL SELECT statements via the ", + "connected LLM, and returns file metadata rows from the Aparavi STORE table. ", + "Schema is fixed — no dynamic introspection required." + ], + // + // Optional: + // Defines the invoke connections that may/must be connected + // + "invoke": { + "llm": { + "description": "LLM used to generate AQL queries from natural language", + "min": 1 + } + }, + // + // Optional: + // Pure tool node — no pipeline lanes + // + "lanes": {}, + // + // Optional: + // Preconfig profiles + // + "preconfig": { + "default": "default", + "profiles": { + "default": { + "url": "" + } + } + }, + // + // Optional: + // Field definitions + // + "fields": { + "aparavi.url": { + "type": "string", + "title": "Aparavi Server URL", + "default": "", + "description": "Base URL of the Aparavi server, e.g. https://aparavi.example.com" + }, + "aparavi.user": { + "type": "string", + "title": "Username", + "default": "", + "description": "Aparavi login username" + }, + "aparavi.password": { + "type": "string", + "title": "Password", + "description": "Aparavi login password", + "secure": true, + "ui": { + "ui:widget": "password" + } + }, + "aparavi.db_description": { + "type": "string", + "title": "Data description", + "default": "", + "description": "What is this data used for? Describe its content and purpose — this helps the LLM generate more accurate AQL queries.", + "ui": { + "ui:widget": "textarea" + } + }, + "aparavi.default": { + "object": "default", + "properties": [ + "aparavi.db_description", + "aparavi.url", + "aparavi.user", + "aparavi.password" + ] + }, + "aparavi.profile": { + "hidden": true, + "type": "string", + "default": "default", + "enum": [ + [ + "default", + "Default" + ] + ], + "conditional": [ + { + "value": "default", + "properties": [ + "aparavi.default" + ] + } + ] + } + }, + // + // Required: + // Defines the shape of the service in the UI + // + "shape": [ + { + "section": "Pipe", + "title": "Aparavi AQL", + "properties": [ + "aparavi.profile" + ] + } + ] +} diff --git a/nodes/src/nodes/tool_chartjs/IInstance.py b/nodes/src/nodes/tool_chartjs/IInstance.py index aacf4f257..60ba305dc 100644 --- a/nodes/src/nodes/tool_chartjs/IInstance.py +++ b/nodes/src/nodes/tool_chartjs/IInstance.py @@ -70,7 +70,9 @@ class IInstance(IInstanceBase): 'description': 'A ready-to-render ```chartjs fenced block. Use this string verbatim in the answer — do not add extra fences around it.', }, description=( - 'Generate a Chart.js chart configuration from data. ' + 'ALWAYS use this tool when the user requests a chart, graph, or visualization. ' + 'Do NOT generate Chart.js configs manually — call this tool instead. ' + 'It generates a ready-to-render chart from data. ' 'Required: "data" (the raw data to chart). ' 'Optional: "chart_type" (bar, line, pie, doughnut, radar, polarArea, scatter, bubble), ' '"title" (chart title), "description" (natural language description of desired chart). ' diff --git a/packages/ai/src/ai/modules/task/task_conn.py b/packages/ai/src/ai/modules/task/task_conn.py index cff57a9c3..e7eba1636 100644 --- a/packages/ai/src/ai/modules/task/task_conn.py +++ b/packages/ai/src/ai/modules/task/task_conn.py @@ -581,6 +581,39 @@ async def on_command(self, request: Dict[str, Any]) -> None: # Call it return await self.request(request) + async def on_rrext_identify(self, request: Dict[str, Any]) -> Dict[str, Any]: + """Update the client display name for this connection. + + Allows clients to refine their identity after auth — e.g. when an + app plugin loads and wants to show "Cloud Shell-UI — rocketride.pipeBuilder" + instead of the generic "Cloud Shell-UI". + + Args: + request: DAP request with ``arguments.clientName`` (str). + + Returns: + Acknowledgement with the new name. + """ + args = request.get('arguments', {}) + new_name = args.get('clientName') + if new_name and isinstance(new_name, str): + self._client_info['name'] = new_name + # Notify dashboard so the monitor UI updates in real time + await self._server.broadcast_server_event( + EVENT_TYPE.DASHBOARD, + { + 'event': 'apaevt_dashboard', + 'body': { + 'action': 'connection_updated', + 'timestamp': time.time(), + 'connectionId': self.get_connection_id(), + 'clientName': new_name, + }, + }, + user_id=self._account_info.userId if self._account_info else None, + ) + return self.build_response(request, body={'clientName': self._client_info.get('name')}) + async def on_rrext_ping(self, request: Dict[str, Any]) -> Dict[str, Any]: """ Handle DAP ping/ping. diff --git a/packages/client-python/src/rocketride/mixins/events.py b/packages/client-python/src/rocketride/mixins/events.py index b51f172d0..86096c9ad 100644 --- a/packages/client-python/src/rocketride/mixins/events.py +++ b/packages/client-python/src/rocketride/mixins/events.py @@ -439,6 +439,34 @@ async def remove_monitor(self, key: Dict[str, Any], types: List[str]) -> None: if not ref_counts: self._monitor_keys.pop(key_str, None) + async def clear_all_monitors(self) -> None: + """Remove all monitor subscriptions from this client. + + Sends an empty types list for each active monitor key to unsubscribe + on the server, then clears the local ref-count map. + """ + empty: Dict[str, int] = {} + for key_str in list(self._monitor_keys.keys()): + key = self._monitor_string_to_key(key_str) + if key is not None: + try: + await self._sync_monitor(key, empty) + except Exception: + pass # Best-effort — server may have already cleared + self._monitor_keys.clear() + + async def identify(self, client_name: str) -> None: + """Update this connection's display name on the server. + + Useful when an app plugin loads and wants the server monitor to show + a more descriptive name instead of the generic client name sent at + auth time. + + Args: + client_name: The new display name for this connection. + """ + await self.call('rrext_identify', clientName=client_name) + async def _sync_monitor(self, key: Dict[str, Any], ref_counts: Dict[str, int]) -> None: """Send the merged type list for a monitor key to the server.""" if not self.is_connected(): diff --git a/packages/client-typescript/src/client/client.ts b/packages/client-typescript/src/client/client.ts index 308cd4db8..afe65751f 100644 --- a/packages/client-typescript/src/client/client.ts +++ b/packages/client-typescript/src/client/client.ts @@ -1730,6 +1730,41 @@ export class RocketRideClient extends DAPClient { } } + /** + * Remove all monitor subscriptions from this client. + * + * Sends an empty types list for each active monitor key to unsubscribe + * on the server, then clears the local ref-count map. Called by the + * shell when an app unmounts so the next app starts with a clean slate. + */ + async clearAllMonitors(): Promise { + const emptyMap = new Map(); + for (const [keyStr] of this._monitorKeys) { + const key = this._monitorStringToKey(keyStr); + if (key) { + try { + await this._syncMonitor(key, emptyMap); + } catch { + // Best-effort — server may have already cleared + } + } + } + this._monitorKeys.clear(); + } + + /** + * Update this connection's display name on the server. + * + * Useful when an app plugin loads and wants the server monitor to show + * a more descriptive name (e.g. "Cloud Shell-UI — rocketride.pipeBuilder") + * instead of the generic client name sent at auth time. + * + * @param clientName - The new display name for this connection. + */ + async identify(clientName: string): Promise { + await this.call('rrext_identify', { clientName }); + } + /** * Send the merged type list for a monitor key to the server. */ diff --git a/packages/shared-ui/src/modules/chat/hooks/useChatMessages.ts b/packages/shared-ui/src/modules/chat/hooks/useChatMessages.ts index 7fabaca63..371bc7aa2 100644 --- a/packages/shared-ui/src/modules/chat/hooks/useChatMessages.ts +++ b/packages/shared-ui/src/modules/chat/hooks/useChatMessages.ts @@ -64,11 +64,11 @@ export interface UseChatMessagesReturn { * setMessages directly. Direct setMessages calls bypass the messagesRef * sync and will cause sendMessage to build history from a stale snapshot. */ -export function useChatMessages({ welcomeMessage }: UseChatMessagesOptions = {}): UseChatMessagesReturn { - const [messages, setMessages] = useState([]); +export function useChatMessages({ welcomeMessage, initialMessages }: UseChatMessagesOptions = {}): UseChatMessagesReturn { + const [messages, setMessages] = useState(initialMessages ?? []); const [isTyping, setIsTyping] = useState(false); - const messagesRef = useRef([]); + const messagesRef = useRef(initialMessages ?? []); const updateMessages = useCallback((updater: (prev: ChatMessage[]) => ChatMessage[]) => { setMessages((prev) => { diff --git a/packages/shared-ui/src/modules/chat/types.ts b/packages/shared-ui/src/modules/chat/types.ts index 2a4f0e0ed..0945b3688 100644 --- a/packages/shared-ui/src/modules/chat/types.ts +++ b/packages/shared-ui/src/modules/chat/types.ts @@ -53,6 +53,8 @@ export interface IChatViewProps { export interface UseChatMessagesOptions { /** System message shown after clearMessages(). */ welcomeMessage?: string; + /** Seed messages to restore a previous conversation (preserves sender, timestamp, etc.). */ + initialMessages?: ChatMessage[]; } // ============================================================================= diff --git a/packages/shared-ui/src/modules/explorer/Explorer.tsx b/packages/shared-ui/src/modules/explorer/Explorer.tsx index 4285d1612..88cd739ff 100644 --- a/packages/shared-ui/src/modules/explorer/Explorer.tsx +++ b/packages/shared-ui/src/modules/explorer/Explorer.tsx @@ -725,9 +725,11 @@ export const Explorer: React.FC = ({ vfs, config, entries, statu - + {config.allowFolders !== false && ( + + )} )} +
+ +
+
Connection Info
+
+ Connected at + {formatTime(connection.connectedAt)} +
+
+ API Key + + {connection.apikey ? `${connection.apikey.slice(0, 4)}${'•'.repeat(8)}${connection.apikey.slice(-4)}` : '—'} + +
+ {connection.clientInfo.name && ( +
+ Client + + {connection.clientInfo.name} {connection.clientInfo.version ?? ''} + +
+ )} +
+ +
+
Monitors ({connection.monitors.length})
+ {connection.monitors.length === 0 && none} + {connection.monitors.map((m) => ( +
+ {m.key} + + {m.flags.map((f) => ( + + {f} + + ))} + +
+ ))} +
+ +
+
Attached Tasks ({connection.attachedTasks.length})
+
+ {connection.attachedTasks.map((t) => ( + + {t} + + ))} + {connection.attachedTasks.length === 0 && none} +
+
+ +
+
Traffic
+
+ Messages In + {formatNumber(connection.messagesIn)} +
+
+ Messages Out + {formatNumber(connection.messagesOut)} +
+
+
+ + ); +}; + +// ============================================================================= +// CONNECTIONS TAB // ============================================================================= export const ConnectionsTab: React.FC<{ connections: DashboardConnection[] }> = ({ connections }) => { @@ -115,11 +206,10 @@ export const ConnectionsTab: React.FC<{ connections: DashboardConnection[] }> = const selected = connections.find((c) => c.id === selectedId); return ( -
+ <>
Active Connections ({connections.length}) - click a row for details
@@ -138,12 +228,12 @@ export const ConnectionsTab: React.FC<{ connections: DashboardConnection[] }> = setSelectedId(conn.id === selectedId ? null : conn.id)} + onClick={() => setSelectedId(conn.id)} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - setSelectedId(conn.id === selectedId ? null : conn.id); + setSelectedId(conn.id); } }} > @@ -174,79 +264,7 @@ export const ConnectionsTab: React.FC<{ connections: DashboardConnection[] }> =
- {selected && ( -
-
- - #{selected.id} — {selected.clientInfo?.name || selected.clientId || `Conn #${selected.id}`} - - -
- -
-
Connection Info
-
- Connected at - {formatTime(selected.connectedAt)} -
-
- API Key - {selected.apikey ? `${selected.apikey.slice(0, 4)}${'•'.repeat(8)}${selected.apikey.slice(-4)}` : '—'} -
- {selected.clientInfo.name && ( -
- Client - - {selected.clientInfo.name} {selected.clientInfo.version ?? ''} - -
- )} -
- -
-
Monitors ({selected.monitors.length})
- {selected.monitors.length === 0 && none} - {selected.monitors.map((m) => ( -
- {m.key} - - {m.flags.map((f) => ( - - {f} - - ))} - -
- ))} -
- -
-
Attached Tasks ({selected.attachedTasks.length})
-
- {selected.attachedTasks.map((t) => ( - - {t} - - ))} - {selected.attachedTasks.length === 0 && none} -
-
- -
-
Traffic
-
- Messages In - {formatNumber(selected.messagesIn)} -
-
- Messages Out - {formatNumber(selected.messagesOut)} -
-
-
- )} -
+ {selected && setSelectedId(null)} />} + ); }; diff --git a/packages/shared-ui/src/modules/server/components/OverviewTab.tsx b/packages/shared-ui/src/modules/server/components/OverviewTab.tsx index 015894969..906a78438 100644 --- a/packages/shared-ui/src/modules/server/components/OverviewTab.tsx +++ b/packages/shared-ui/src/modules/server/components/OverviewTab.tsx @@ -3,9 +3,10 @@ // Copyright (c) 2026 Aparavi Software AG Inc. // ============================================================================= -import React, { CSSProperties } from 'react'; +import React, { useState, CSSProperties } from 'react'; import type { DashboardResponse, DashboardTask, DashboardConnection, ActivityEvent, DashboardEvent, TaskEvent } from '../types'; import { StatusPill } from './StatusPill'; +import { ConnectionDetailModal } from './ConnectionsTab'; import { formatUptime, formatTime, formatTimeAgo, formatNumber } from '../util'; import { commonStyles } from '../../../themes/styles'; @@ -365,6 +366,8 @@ export const OverviewTab: React.FC = ({ data, events, onRefres const agg = aggregateMetrics(tasks); const recentEvents = events.slice(0, 5); const tickerEvents = events.slice(0, 4); + const [selectedConnId, setSelectedConnId] = useState(null); + const selectedConn = connections.find((c) => c.id === selectedConnId); return (
@@ -445,7 +448,7 @@ export const OverviewTab: React.FC = ({ data, events, onRefres {/* Connection rows */} {connections.map((conn: DashboardConnection) => ( - + setSelectedConnId(conn.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedConnId(conn.id); } }}>
{conn.clientInfo?.name || conn.clientId || `Conn #${conn.id}`}
@@ -611,6 +614,7 @@ export const OverviewTab: React.FC = ({ data, events, onRefres
+ {selectedConn && setSelectedConnId(null)} />} ); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18ce8eb82..48c9fc2c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,34 @@ importers: specifier: ^8.60.0 version: 8.60.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + apps/aparavi-ui: + dependencies: + '@module-federation/rsbuild-plugin': + specifier: ^2.5.1 + version: 2.5.1(@rsbuild/core@2.0.11(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.48.0))(@rspack/core@2.0.6(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.104.1) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + shared: + specifier: workspace:* + version: link:../../packages/shared-ui + shell-ui: + specifier: workspace:* + version: link:../shell-ui + devDependencies: + '@rsbuild/core': + specifier: ~2.0.11 + version: 2.0.11(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.48.0) + '@rsbuild/plugin-react': + specifier: ~2.0.1 + version: 2.0.1(@rsbuild/core@2.0.11(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.48.0))(@rspack/core@2.0.6(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) + typescript: + specifier: ^5.3.0 + version: 5.9.3 + apps/chat-ui: dependencies: '@types/ws': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dac99c169..692edb806 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,7 @@ packages: - 'packages/client-typescript' # Applications + - 'apps/aparavi-ui' - 'apps/chat-ui' - 'apps/dropper-ui' - 'apps/hello-ui'