From b6bebdd3fd18866e9b66f23d375ea8a59ff2a3d0 Mon Sep 17 00:00:00 2001 From: Andrea Buzzi <155985472+buzzia2001@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:13:31 +0200 Subject: [PATCH 1/2] Moved frontend from FS extension, added Joblog command --- package.json | 10 + src/extension.ts | 4 + src/typings.ts | 2 + src/ui/frontendTables.ts | 1043 ++++++++++++++++++++++++++++++++++ src/webviews/wrkjob/index.ts | 230 ++++++++ 5 files changed, 1289 insertions(+) create mode 100644 src/ui/frontendTables.ts create mode 100644 src/webviews/wrkjob/index.ts diff --git a/package.json b/package.json index 979e58733..cc241ed5f 100644 --- a/package.json +++ b/package.json @@ -1066,6 +1066,12 @@ "title": "Open Errors", "category": "IBM i" }, + { + "command": "code-for-ibmi.showJobLog", + "enablement": "code-for-ibmi:connected", + "title": "Display Job Log", + "category": "IBM i" + }, { "command": "code-for-ibmi.launchTerminalPicker", "enablement": "code-for-ibmi:connected && !code-for-ibmi:isSystemReadonly", @@ -2233,6 +2239,10 @@ "command": "code-for-ibmi.showAdditionalSettings", "when": "code-for-ibmi:connected" }, + { + "command": "code-for-ibmi.showJobLog", + "when": "code-for-ibmi:connected" + }, { "command": "code-for-ibmi.selectForCompare", "when": "never" diff --git a/src/extension.ts b/src/extension.ts index 33c1fed56..9e85e7ea3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,6 +27,7 @@ import { mergeCommandProfiles } from "./mergeProfiles"; import { initialise } from "./testing"; import { CodeForIBMi } from "./typings"; import { VscodeTools } from "./ui/Tools"; +import { FrontendTables } from "./ui/frontendTables"; import { registerActionTools } from "./ui/actions"; import { initializeConnectionBrowser } from "./ui/views/ConnectionBrowser"; import { initializeLibraryListView } from "./ui/views/LibraryListView"; @@ -41,6 +42,7 @@ import { openURIHandler } from "./uri/handlers/open"; import { initializeSandbox, sandboxURIHandler } from "./uri/handlers/sandbox"; import { CustomUI } from "./webviews/CustomUI"; import { SettingsUI } from "./webviews/settings"; +import { JobLogUI } from "./webviews/wrkjob"; let temporaryPassword: string | undefined; export let getPassword: (connection: IBMi, prompt: string) => Promise; @@ -69,6 +71,7 @@ export async function activate(context: ExtensionContext): Promise }; SettingsUI.init(context); + JobLogUI.init(context); initializeConnectionBrowser(context); initializeObjectBrowser(context) initializeIFSBrowser(context); @@ -165,6 +168,7 @@ export async function activate(context: ExtensionContext): Promise customEditor: (target, onSave, onClosed) => new CustomEditor(target, onSave, onClosed), evfeventParser: parseErrors, tools: VscodeTools, + frontendTables: FrontendTables, deployTools: DeployTools, actionTools: ActionTools, componentRegistry: extensionComponentRegistry, diff --git a/src/typings.ts b/src/typings.ts index e5b109d4c..8202d5759 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -8,6 +8,7 @@ import { CustomEditor } from "./editors/customEditorProvider"; import { DeployTools } from "./filesystems/local/deployTools"; import { ActionTools } from "./api/actions"; import { VscodeTools } from "./ui/Tools"; +import { FrontendTables } from "./ui/frontendTables"; import { SearchTools } from "./api/SearchTools"; import { CustomUI } from "./webviews/CustomUI"; @@ -17,6 +18,7 @@ export interface CodeForIBMi { customEditor: (target: string, onSave: (data: T) => Promise, onClosed?: () => void) => CustomEditor, evfeventParser: (lines: string[]) => Map, tools: typeof VscodeTools, + frontendTables: typeof FrontendTables, deployTools: typeof DeployTools, actionTools: typeof ActionTools, componentRegistry: ComponentRegistry, diff --git a/src/ui/frontendTables.ts b/src/ui/frontendTables.ts new file mode 100644 index 000000000..1c0a9d476 --- /dev/null +++ b/src/ui/frontendTables.ts @@ -0,0 +1,1043 @@ +import * as vscode from 'vscode'; + +export namespace FrontendTables { + + const EMPTY_VALUE_PLACEHOLDER = ''; + + /** Escape a value for safe interpolation into the generated HTML. */ + function escapeHtml(text: string | number): string { + if (text === 0) return '0'; + if (text === null || text === undefined || text === '') return '-'; + const str = String(text); + if (str === 'null' || str === 'undefined' || str.trim() === '') return '-'; + const map: { [key: string]: string } = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return str.replace(/[&<>"']/g, m => map[m]); + } + + /** Renders YES/NO as colored badges and integers with locale formatting; undefined if the value is neither. */ + function formatYesNoOrNumber(strValue: string, skipNumeric: boolean): string | undefined { + if (strValue === 'YES') { + return '✓ YES'; + } + if (strValue === 'NO') { + return '✗ NO'; + } + if (!skipNumeric && !isNaN(Number(strValue)) && strValue !== '') { + const num = Number(strValue); + if (Number.isInteger(num)) { + return `${num.toLocaleString()}`; + } + } + return undefined; + } + + /** + * Column definition for FastTable + */ + export interface FastTableColumn { + /** Column title in header */ + title: string; + /** Function to extract value from row */ + getValue: (row: T) => string | number; + /** Column width - supports: "100px", "20%", "1fr", "2fr", "auto", "minmax(100px, 1fr)" */ + width?: string; + /** Custom CSS class for cells (optional) */ + cellClass?: string; + /** If true, this column will be collapsible (hidden by default, shown in code format when expanded) */ + collapsible?: boolean; + /** If true, shows the column title in the table header even when collapsible (default: false) */ + showTitle?: boolean; + } + + /** + * Options for generating a FastTable + */ + export interface FastTableOptions { + /** Page title */ + title: string; + /** Subtitle or additional info (optional) */ + subtitle?: string; + /** Table columns */ + columns: FastTableColumn[]; + /** Data to display */ + data: T[]; + /** Sticky header (default: true) */ + stickyHeader?: boolean; + /** Message when no data (optional) */ + emptyMessage?: string; + /** Additional custom CSS (optional) */ + customStyles?: string; + /** Custom JavaScript (optional) */ + customScript?: string; + /** Enable search bar (default: false) */ + enableSearch?: boolean; + /** Search placeholder text (optional) */ + searchPlaceholder?: string; + /** Enable pagination (default: false) */ + enablePagination?: boolean; + /** Items per page (default: 50) */ + itemsPerPage?: number; + /** Total items count (for server-side pagination) */ + totalItems?: number; + /** Current page (for server-side pagination, default: 1) */ + currentPage?: number; + /** Current search term (for server-side search) */ + searchTerm?: string; + /** Table identifier for multi-table documents (e.g., SaveFile with objects/members/spools) */ + tableId?: string; + } + + /** + * Options for generating a detail table (key-value pairs) + */ + export interface DetailTableOptions { + /** Page title */ + title?: string; + /** Subtitle or additional info (optional) */ + subtitle?: string; + /** Column definitions (keys and labels) */ + columns: Map; + /** Data object to display */ + data: any; + /** Array of column names to format as code (optional) */ + codeColumns?: string[]; + /** Additional custom CSS (optional) */ + customStyles?: string; + /** Custom JavaScript (optional) */ + customScript?: string; + /** Show action buttons (optional) */ + actions?: DetailTableAction[]; + /** Hide fields with null values (optional, default: false) */ + hideNullValues?: boolean; + } + + /** + * Action button configuration for detail tables + */ + export interface DetailTableAction { + /** Button label */ + label: string; + /** Action identifier */ + action: string; + /** Button appearance (primary, secondary, etc.) */ + appearance?: string; + /** Custom CSS style */ + style?: string; + } + + /** + * Generate an enhanced detail table (key-value pairs) using Fast components + * This is a modern replacement for generateTableHtml and generateTableHtmlCode + * @param options - Detail table configuration options + * @returns Complete HTML page string + */ + export function generateDetailTable(options: DetailTableOptions): string { + const { + title = '', + subtitle = '', + columns, + data, + codeColumns = [], + customStyles = '', + customScript = '', + actions = [], + hideNullValues = false + } = options; + + // Format value with icons and styling + const formatValue = (value: any, isCodeColumn: boolean): string => { + if (value === null || value === undefined || value.toString().trim() === '') { + return EMPTY_VALUE_PLACEHOLDER; + } + + const strValue = String(value).trim(); + + const commonFormat = formatYesNoOrNumber(strValue, isCodeColumn); + if (commonFormat) return commonFormat; + + // Handle code columns + if (isCodeColumn) { + return `${escapeHtml(value)}`; + } + + return escapeHtml(value); + }; + + // Generate table rows + const rows: string[] = []; + columns.forEach((label, key) => { + if (key in data[0]) { + let value = data[0][key as keyof typeof data]; + + // Skip null/undefined/empty values if hideNullValues is enabled + if (hideNullValues && (value === null || value === undefined || value.toString().trim() === '')) { + return; + } + + const displayValue = formatValue(value, codeColumns.includes(key)); + + rows.push(` + + ${escapeHtml(label)} + ${displayValue} + `); + } + }); + + // Generate action buttons as raw vscode-button markup (click handling is wired up + // by the webview's own footer script via a `[href^="action:"]` selector). + const actionButtons = actions.map((action, index) => { + const marginTop = index === 0 ? '0' : '10px'; + const appearance = action.appearance || 'primary'; + return `${escapeHtml(action.label)}`; + }).join('\n'); + + return /*html*/` +
+ + + ${title || subtitle ? ` +
+ ${title ? `

${escapeHtml(title)}

` : ''} + ${subtitle ? `
${escapeHtml(subtitle)}
` : ''} +
+ ` : ''} + + + ${rows.join('')} + + + ${actions.length > 0 ? ` +
+ ${actionButtons} +
+ ` : ''} +
+ `; + } + + /** + * Generate a complete HTML page with FAST Element table + * Optimized for displaying large datasets with better performance + * @param options - Table configuration options + * @returns Complete HTML page string + */ + export function generateFastTable(options: FastTableOptions): string { + const { + title, + subtitle, + columns, + data, + stickyHeader = true, + emptyMessage = 'No data available.', + customStyles = '', + customScript = '', + enableSearch = false, + searchPlaceholder = 'Search...', + enablePagination = false, + itemsPerPage = 50, + totalItems = data.length, + currentPage = 1, + searchTerm = '', + tableId = undefined + } = options; + + // Format value with icons and styling for FastTable. + // NOTE: unlike generateDetailTable, cell values here are NOT html-escaped — + // several callers intentionally return raw HTML (e.g. action + // markup) from a column's getValue(), which must render as real elements. + const formatFastValue = (value: string | number): string => { + if (value === null || value === undefined || value === '') { + return EMPTY_VALUE_PLACEHOLDER; + } + + const strValue = String(value).trim(); + if (strValue === 'null' || strValue === 'undefined' || strValue === '') { + return EMPTY_VALUE_PLACEHOLDER; + } + + return formatYesNoOrNumber(strValue, false) ?? String(value); + }; + + // Fixed pixel width assigned per 1fr unit, and an absolute floor so no + // column becomes too narrow to read. Columns get an actual fixed size + // instead of a percentage, so resizing the window never squashes them — + // the wrapper scrolls horizontally instead (see .table-scroll-wrapper below). + // The floor is intentionally low relative to MIN_PX_PER_FR so 'fr' weights + // stay visually distinct instead of every small-weight column clamping to + // the same floor value; wide tables (many columns) are expected to scroll. + const MIN_PX_PER_FR = 140; + const MIN_COLUMN_WIDTH = 90; + + // Fixed pixel width for each column + const columnMinWidths = columns.map(col => { + if (!col.width) return MIN_COLUMN_WIDTH; + if (col.width.includes('fr')) { + return Math.max(MIN_COLUMN_WIDTH, parseFloat(col.width) * MIN_PX_PER_FR); + } + if (col.width.endsWith('px')) { + return Math.max(MIN_COLUMN_WIDTH, parseFloat(col.width)); + } + // For %, minmax(), or other values we can't easily measure — use the floor + return MIN_COLUMN_WIDTH; + }); + + // Total fixed width for the whole table. When the viewport is narrower than + // this, the wrapper scrolls horizontally instead of squashing columns. + const tableMinWidth = columnMinWidths.reduce((sum, w) => sum + w, 0); + + // Fixed pixel width per column, passed to the vscode-table 'columns' attribute + const columnsArray = columnMinWidths.map(w => `${w}px`); + + // Check if there are any collapsible columns + const hasCollapsibleColumns = columns.some(col => col.collapsible); + const collapsibleIndices = columns.map((col, idx) => col.collapsible ? idx : -1).filter(idx => idx !== -1); + + // Store collapsible data for modal + const collapsibleData = hasCollapsibleColumns ? data.map((row, rowIndex) => { + return collapsibleIndices.map(colIdx => { + const col = columns[colIdx]; + return { + title: col.title, + value: col.getValue(row) + }; + }); + }) : []; + + // Generate table rows with width styles and collapsible support + const rows = data.map((row, rowIndex) => { + const visibleCells = columns.map((col, index) => { + if (col.collapsible) { + // For collapsible columns, check if value is empty/null before showing button + const value = col.getValue(row); + const isEmpty = value === null || value === undefined || value === '' || String(value).trim() === '' || String(value) === 'null' || String(value) === 'undefined'; + + if (isEmpty) { + // Show empty cell if no content + return ``; + } else { + // Show button to open modal if content exists + const colIndex = collapsibleIndices.indexOf(index); + return ` + + + + + `; + } + } + const value = col.getValue(row); + const cellClass = col.cellClass ? ` class="${col.cellClass}"` : ''; + const widthStyle = columnsArray[index] !== 'auto' ? ` style="width: ${columnsArray[index]};"` : ''; + return `${formatFastValue(value)}`; + }).join('\n '); + + return ` + ${visibleCells} + `; + }).join('\n '); + + // Generate table header with width styles (skip collapsible columns) + const headerCells = columns.map((col, index) => { + if (col.collapsible) { + const widthStyle = columnsArray[index] !== 'auto' ? ` style="width: ${columnsArray[index]};"` : ''; + const title = col.showTitle ? escapeHtml(col.title) : ''; + return `${title}`; + } + const widthStyle = columnsArray[index] !== 'auto' ? ` style="width: ${columnsArray[index]};"` : ''; + return `${escapeHtml(col.title)}`; + }).join('\n '); + + return /*html*/` + +
+ ${title || subtitle ? ` +
+ ${title ? `

${escapeHtml(title)}

` : ''} + ${subtitle ? `
${escapeHtml(subtitle)}
` : ''} +
+ ` : ''} + + ${enableSearch ? ` + + ` : ''} + + ${data.length > 0 ? ` +
+ + + ${headerCells} + + + ${rows} + + + + ${hasCollapsibleColumns ? ` + + + ` : ''} + + ${enablePagination ? ` +
+
+
+ + + + + + + +
+ Page + + +
+ + + + + + + +
+
+ ` : ''} +
+ ` : ` +
+

${escapeHtml(emptyMessage)}

+
+ `} +
+ + + + `; + } + +} diff --git a/src/webviews/wrkjob/index.ts b/src/webviews/wrkjob/index.ts new file mode 100644 index 000000000..9faac3849 --- /dev/null +++ b/src/webviews/wrkjob/index.ts @@ -0,0 +1,230 @@ +import vscode from "vscode"; +import { Tools } from "../../api/Tools"; +import { instance } from "../../instantiate"; +import { FrontendTables } from "../../ui/frontendTables"; + +const vscodeElements = require(`@vscode-elements/elements/dist/bundled`); + +interface JoblogEntry { + msgid: string; + msgtext: string; + msgtext2: string; + severity: number; + fromProgram: string; + msgFile: string; + timestamp: string; +} + +/** + * Displays the job log for a given job in a webview panel. + */ +export namespace JobLogUI { + /** Auto-refresh interval in milliseconds. */ + const AUTO_REFRESH_INTERVAL = 30000; + + interface PanelState { + panel: vscode.WebviewPanel; + /** Whether auto-refresh is currently enabled for this panel. */ + autoRefresh: boolean; + /** Active auto-refresh timer, if any. */ + timer?: NodeJS.Timeout; + } + + const activePanels = new Map(); + + export function init(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.commands.registerCommand(`code-for-ibmi.showJobLog`, async (jobName?: string) => { + const connection = instance.getConnection(); + if (!connection) { + vscode.window.showErrorMessage(vscode.l10n.t("Not connected to IBM i")); + return; + } + + if (!jobName) { + const currentJob = await connection.runSQL(`select JOB_NAME as JOBNAME from table(qsys2.active_job_info(job_name_filter => '*', detailed_info => 'NONE'))`); + + jobName = await vscode.window.showInputBox({ + placeHolder: vscode.l10n.t("000000/USER/MYJOB"), + title: vscode.l10n.t("Enter job name (number/user/name)"), + value: currentJob.length ? String(currentJob[0].JOBNAME) : undefined, + validateInput: (value) => { + if (value.split(`/`).length !== 3) { + return vscode.l10n.t("Job name must be in format: number/user/name"); + } + } + }); + } + + if (jobName) { + await openJobLog(jobName); + } + }) + ); + } + + async function fetchJoblog(jobName: string): Promise { + const connection = instance.getConnection(); + if (!connection) { + return []; + } + + const rows = await connection.runSQL( + `SELECT MESSAGE_ID, + MESSAGE_TEXT, + MESSAGE_SECOND_LEVEL_TEXT, + SEVERITY, + FROM_LIBRARY CONCAT '/' CONCAT FROM_PROGRAM AS FROM_PROGRAM, + MESSAGE_LIBRARY CONCAT '/' CONCAT MESSAGE_FILE AS MESSAGE_FILE, + TO_CHAR(MESSAGE_TIMESTAMP, 'YYYY-MM-DD HH24:MI:SS') AS MESSAGE_TIMESTAMP + FROM TABLE(QSYS2.JOBLOG_INFO('${jobName}')) + ORDER BY ORDINAL_POSITION DESC` + ); + + return rows.map((row: Tools.DB2Row): JoblogEntry => ({ + msgid: String(row.MESSAGE_ID), + msgtext: String(row.MESSAGE_TEXT), + msgtext2: String(row.MESSAGE_SECOND_LEVEL_TEXT), + severity: Number(row.SEVERITY), + fromProgram: String(row.FROM_PROGRAM), + msgFile: String(row.MESSAGE_FILE), + timestamp: String(row.MESSAGE_TIMESTAMP) + })); + } + + async function openJobLog(jobName: string) { + const existing = activePanels.get(jobName); + if (existing) { + existing.panel.reveal(); + await render(existing.panel, jobName); + return; + } + + const panel = vscode.window.createWebviewPanel( + `jobLogView`, + vscode.l10n.t("Job Log: {0}", jobName), + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true + } + ); + + const state: PanelState = { panel, autoRefresh: false }; + activePanels.set(jobName, state); + + panel.onDidDispose(() => { + stopAutoRefresh(state); + activePanels.delete(jobName); + }); + + panel.webview.onDidReceiveMessage(async (message: { command?: string }) => { + switch (message?.command) { + case `refresh`: + await render(panel, jobName); + break; + case `toggleAutoRefresh`: + state.autoRefresh = !state.autoRefresh; + if (state.autoRefresh) { + startAutoRefresh(state, jobName); + } else { + stopAutoRefresh(state); + } + await render(panel, jobName); + break; + } + }); + + await render(panel, jobName); + } + + function startAutoRefresh(state: PanelState, jobName: string) { + stopAutoRefresh(state); + state.timer = setInterval(async () => { + try { + await render(state.panel, jobName); + } catch (error) { + console.error(`Job log auto-refresh error:`, error); + } + }, AUTO_REFRESH_INTERVAL); + } + + function stopAutoRefresh(state: PanelState) { + if (state.timer) { + clearInterval(state.timer); + state.timer = undefined; + } + } + + async function render(panel: vscode.WebviewPanel, jobName: string) { + const joblog = await fetchJoblog(jobName); + const autoRefresh = activePanels.get(jobName)?.autoRefresh ?? false; + const lastUpdated = new Date().toLocaleTimeString(); + + const columns: FrontendTables.FastTableColumn[] = [ + { title: vscode.l10n.t("MSGID"), width: "0.7fr", getValue: e => e.msgid }, + { title: vscode.l10n.t("Message"), width: "2fr", getValue: e => e.msgtext }, + { title: vscode.l10n.t("Second Level"), width: "0.3fr", getValue: e => e.msgtext2.replaceAll(`&N`, `\n`).replaceAll(`&B`, `\n\t`).replaceAll(`&P`, `\n\t`), collapsible: true }, + { title: vscode.l10n.t("Sev."), width: "0.3fr", getValue: e => String(e.severity) }, + { title: vscode.l10n.t("From Program"), width: "1.5fr", getValue: e => e.fromProgram }, + { title: vscode.l10n.t("Timestamp"), width: "1.2fr", getValue: e => e.timestamp } + ]; + + const body = FrontendTables.generateFastTable({ + title: vscode.l10n.t("Job Log"), + subtitle: vscode.l10n.t("Job {0} - Total messages: {1}", jobName, String(joblog.length)), + columns, + data: joblog, + stickyHeader: true, + emptyMessage: vscode.l10n.t("No job log messages found.") + }); + + panel.webview.html = /*html*/` + + + + + + ${vscode.l10n.t("Job Log: {0}", jobName)} + + + + +
+ + ${vscode.l10n.t("Refresh")} + + + ${autoRefresh + ? vscode.l10n.t("Auto-refresh: ON ({0}s)", String(AUTO_REFRESH_INTERVAL / 1000)) + : vscode.l10n.t("Auto-refresh: OFF")} + + ${vscode.l10n.t("Last updated: {0}", lastUpdated)} +
+ ${body} + + + `; + } +} From 9111bacaef2b2ad8c59179b923f71c5d57de1b6f Mon Sep 17 00:00:00 2001 From: Andrea Buzzi Date: Sat, 11 Jul 2026 09:28:57 +0200 Subject: [PATCH 2/2] Change escapeHtml function to use VscodeTools, changed button --- src/ui/frontendTables.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/ui/frontendTables.ts b/src/ui/frontendTables.ts index 1c0a9d476..b55c5fed0 100644 --- a/src/ui/frontendTables.ts +++ b/src/ui/frontendTables.ts @@ -1,23 +1,17 @@ import * as vscode from 'vscode'; +import { VscodeTools } from './Tools'; export namespace FrontendTables { const EMPTY_VALUE_PLACEHOLDER = ''; - /** Escape a value for safe interpolation into the generated HTML. */ + /** Escape a value for safe interpolation into the generated HTML; empty/null-ish values render as '-'. */ function escapeHtml(text: string | number): string { if (text === 0) return '0'; if (text === null || text === undefined || text === '') return '-'; const str = String(text); if (str === 'null' || str === 'undefined' || str.trim() === '') return '-'; - const map: { [key: string]: string } = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - return str.replace(/[&<>"']/g, m => map[m]); + return VscodeTools.escapeHtml(str); } /** Renders YES/NO as colored badges and integers with locale formatting; undefined if the value is neither. */ @@ -133,7 +127,7 @@ export namespace FrontendTables { /** * Generate an enhanced detail table (key-value pairs) using Fast components - * This is a modern replacement for generateTableHtml and generateTableHtmlCode + * Replaces the legacy table generators (generateTableHtml/generateTableHtmlCode) from the FS extension * @param options - Detail table configuration options * @returns Complete HTML page string */ @@ -423,7 +417,7 @@ export namespace FrontendTables { const colIndex = collapsibleIndices.indexOf(index); return ` - + + `; }