@bakery-framework/plugin-dashboard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +19 -0
- package/README.md +44 -0
- package/package.json +42 -0
- package/src/authorize.ts +76 -0
- package/src/client/dashboard.ts +165 -0
- package/src/client/parts/database.ts +1176 -0
- package/src/client/parts/effects.ts +327 -0
- package/src/client/parts/logs.ts +132 -0
- package/src/client/parts/sessions.ts +382 -0
- package/src/client/parts/stats.ts +957 -0
- package/src/client/parts/utils.ts +237 -0
- package/src/components/DBBrowser.tsx +365 -0
- package/src/components/LogsPanel.tsx +35 -0
- package/src/components/SessionsPanel.tsx +90 -0
- package/src/components/StatsPanel.tsx +265 -0
- package/src/components/TopPagesPanel.tsx +57 -0
- package/src/endpoints/database.ts +211 -0
- package/src/endpoints/sessions.ts +59 -0
- package/src/index.ts +42 -0
- package/src/paths.ts +14 -0
- package/src/setup.ts +268 -0
- package/src/shell.tsx +137 -0
- package/src/test-fixtures.ts +55 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared browser-side helpers for the dashboard panels.
|
|
3
|
+
*
|
|
4
|
+
* The three panels (database, sessions, stats) were built independently and
|
|
5
|
+
* each grew its own copy of the same four things: an element-text setter, a
|
|
6
|
+
* `results-empty` block, a fetch/`.json()` pair, and a prev/next pager. They
|
|
7
|
+
* are declared once here because this file is compiled into the shipped
|
|
8
|
+
* bundle — a duplicated string literal is duplicated bytes, the minifier does
|
|
9
|
+
* not merge them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** `innerText` on an element that may not be in the DOM yet. */
|
|
13
|
+
export function setText(id: string, value: string) {
|
|
14
|
+
const el = document.getElementById(id)
|
|
15
|
+
if (el) el.innerText = value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The one spelling of the panels' placeholder/error block.
|
|
20
|
+
*
|
|
21
|
+
* `message` is **escaped here**, so a caller cannot leak markup by forgetting.
|
|
22
|
+
* These helpers previously took a string straight into `innerHTML`: every
|
|
23
|
+
* caller happened to pass a literal or escape first, which is exactly the state
|
|
24
|
+
* a codebase is in right before it stops being true. Every XSS found in this
|
|
25
|
+
* repo came from hand-built DOM strings in these three files, including a
|
|
26
|
+
* stored-XSS to arbitrary-SQL chain, so the default here is the safe one and
|
|
27
|
+
* markup is not expressible through it at all.
|
|
28
|
+
*/
|
|
29
|
+
export function emptyBox(message: string, isError = false): string {
|
|
30
|
+
const style = isError ? ' style="color: var(--accent-red);"' : ''
|
|
31
|
+
return `<div class="results-empty"${style}><span>${escapeHTML(message)}</span></div>`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function setEmpty(
|
|
35
|
+
el: HTMLElement | null,
|
|
36
|
+
message: string,
|
|
37
|
+
isError = false,
|
|
38
|
+
) {
|
|
39
|
+
if (el) el.innerHTML = emptyBox(message, isError)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Inline SVGs, built from the path data alone. Every hand-written copy in the
|
|
44
|
+
* panels carried `width="1em" height="1em"` twice — a duplicate attribute the
|
|
45
|
+
* parser silently drops — and the same ~380-byte markup was pasted once per
|
|
46
|
+
* use site.
|
|
47
|
+
*/
|
|
48
|
+
export function icon(d: string, size: string): string {
|
|
49
|
+
return (
|
|
50
|
+
`<svg style="font-size: ${size};" width="1em" height="1em" ` +
|
|
51
|
+
`xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">` +
|
|
52
|
+
`<path fill="currentColor" d="${d}"/></svg>`
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const ICON_TABLE =
|
|
57
|
+
'M19 21H5q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21M5 8h14V5H5zm2.5 2H5v9h2.5zm9 0v9H19v-9zm-2 0h-5v9h5z'
|
|
58
|
+
export const ICON_EYE =
|
|
59
|
+
'M15.188 14.688Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7T8.813 8.313T7.5 11.5t1.313 3.188T12 16t3.188-1.312m-5.1-1.276Q9.3 12.625 9.3 11.5t.788-1.912T12 8.8t1.913.788t.787 1.912t-.787 1.913T12 14.2t-1.912-.787m-4.738 3.55Q2.35 14.925 1 11.5q1.35-3.425 4.35-5.462T12 4t6.65 2.038T23 11.5q-1.35 3.425-4.35 5.463T12 19t-6.65-2.037m11.838-1.45Q19.55 14.025 20.8 11.5q-1.25-2.525-3.613-4.012T12 6T6.813 7.488T3.2 11.5q1.25 2.525 3.613 4.013T12 17t5.188-1.487'
|
|
60
|
+
export const ICON_EDIT =
|
|
61
|
+
'M5 19h1.425L16.2 9.225L14.775 7.8L5 17.575zm-2 2v-4.25L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.438.65T21 6.4q0 .4-.137.763t-.438.662L7.25 21zM19 6.4L17.6 5zm-3.525 2.125l-.7-.725L16.2 9.225z'
|
|
62
|
+
export const ICON_DELETE =
|
|
63
|
+
'M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zM17 6H7v13h10zM9 17h2V8H9zm4 0h2V8h-2zM7 6v13z'
|
|
64
|
+
export const ICON_WARN =
|
|
65
|
+
'M1 21L12 2l11 19zm3.45-2h15.1L12 6zm8.263-1.287Q13 17.425 13 17t-.288-.712T12 16t-.712.288T11 17t.288.713T12 18t.713-.288M11 15h2v-5h-2zm1-2.5'
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* An error block with the warning glyph, as the fetch failure paths render it.
|
|
69
|
+
* `message` is escaped — see `emptyBox`. The two dynamic callers pass a driver
|
|
70
|
+
* error string, which quotes the caller's own SQL back at them.
|
|
71
|
+
*/
|
|
72
|
+
export function errorBox(message: string): string {
|
|
73
|
+
return (
|
|
74
|
+
`<div class="results-empty" style="color: var(--accent-red);">` +
|
|
75
|
+
`<span style="display: inline-flex; align-items: center; gap: 0.25rem;">` +
|
|
76
|
+
`${icon(ICON_WARN, '1.1rem')}${escapeHTML(message)}</span></div>`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** GET a dashboard endpoint and unwrap the JSON envelope. */
|
|
81
|
+
export async function getJson(url: string): Promise<any> {
|
|
82
|
+
const res = await fetch(url)
|
|
83
|
+
return await res.json()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** POST a JSON body to a dashboard endpoint and unwrap the envelope. */
|
|
87
|
+
export async function postJson(url: string, body: unknown): Promise<any> {
|
|
88
|
+
const res = await fetch(url, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify(body),
|
|
92
|
+
})
|
|
93
|
+
return await res.json()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The mutating half of the database panel; every action shares one endpoint. */
|
|
97
|
+
export function executeAction(body: Record<string, unknown>): Promise<any> {
|
|
98
|
+
return postJson('/api/_dashboard/execute-action', body)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Page N of M, with the prev/next buttons disabled at the ends. */
|
|
102
|
+
export function setPager(
|
|
103
|
+
ids: { info: string; prev: string; next: string },
|
|
104
|
+
page: number,
|
|
105
|
+
totalPages: number,
|
|
106
|
+
) {
|
|
107
|
+
setText(ids.info, `Page ${page} of ${totalPages}`)
|
|
108
|
+
const prevBtn = document.getElementById(ids.prev) as HTMLButtonElement | null
|
|
109
|
+
const nextBtn = document.getElementById(ids.next) as HTMLButtonElement | null
|
|
110
|
+
if (prevBtn) prevBtn.disabled = page <= 1
|
|
111
|
+
if (nextBtn) nextBtn.disabled = page >= totalPages
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class SegmentedProgress {
|
|
115
|
+
private container: HTMLElement
|
|
116
|
+
private percent: number
|
|
117
|
+
private barWidth: number
|
|
118
|
+
private barGap: number
|
|
119
|
+
private resizeObserver: ResizeObserver | null = null
|
|
120
|
+
|
|
121
|
+
constructor(
|
|
122
|
+
container: HTMLElement,
|
|
123
|
+
percent: number,
|
|
124
|
+
barWidth = 4,
|
|
125
|
+
barGap = 6,
|
|
126
|
+
) {
|
|
127
|
+
this.container = container
|
|
128
|
+
this.percent = percent
|
|
129
|
+
this.barWidth = barWidth
|
|
130
|
+
this.barGap = barGap
|
|
131
|
+
this.init()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private init() {
|
|
135
|
+
this.container.classList.add('segmented-progress-container')
|
|
136
|
+
if (this.barGap !== 6) {
|
|
137
|
+
this.container.style.gap = `${this.barGap}px`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
141
|
+
this.resizeObserver = new ResizeObserver(() => this.draw())
|
|
142
|
+
this.resizeObserver.observe(this.container)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.draw()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public destroy() {
|
|
149
|
+
if (this.resizeObserver) {
|
|
150
|
+
this.resizeObserver.disconnect()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public draw() {
|
|
155
|
+
const containerWidth = this.container.clientWidth
|
|
156
|
+
if (containerWidth === 0) return
|
|
157
|
+
|
|
158
|
+
const count = Math.floor(
|
|
159
|
+
(containerWidth + this.barGap) / (this.barWidth + this.barGap),
|
|
160
|
+
)
|
|
161
|
+
const activeCount = Math.round((this.percent / 100) * count)
|
|
162
|
+
|
|
163
|
+
let html = ''
|
|
164
|
+
for (let i = 0; i < count; i++) {
|
|
165
|
+
const className = i < activeCount ? 'active' : 'inactive'
|
|
166
|
+
let styleAttr = ''
|
|
167
|
+
if (this.barWidth !== 4) {
|
|
168
|
+
styleAttr = ` style="width: ${this.barWidth}px;"`
|
|
169
|
+
}
|
|
170
|
+
html += `<div class="segmented-bar-segment ${className}"${styleAttr}></div>`
|
|
171
|
+
}
|
|
172
|
+
this.container.innerHTML = html
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function getWebSocketUrl(path: string) {
|
|
177
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
178
|
+
return `${protocol}//${location.host}${path}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function formatUptime(totalSeconds: number): string {
|
|
182
|
+
if (totalSeconds < 0) return '0s'
|
|
183
|
+
const hrs = Math.floor(totalSeconds / 3600)
|
|
184
|
+
const mins = Math.floor((totalSeconds % 3600) / 60)
|
|
185
|
+
const secs = Math.floor(totalSeconds % 60)
|
|
186
|
+
const secsStr = `${secs}s`
|
|
187
|
+
if (hrs > 0) return `${hrs}h ${mins}m ${secsStr}`
|
|
188
|
+
if (mins > 0) return `${mins}m ${secsStr}`
|
|
189
|
+
return secsStr
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const COLOR_MAP: Record<string, string> = {
|
|
193
|
+
r: '#ef4444',
|
|
194
|
+
g: '#10b981',
|
|
195
|
+
y: '#facc15',
|
|
196
|
+
b: '#3b82f6',
|
|
197
|
+
m: '#ec4899',
|
|
198
|
+
c: '#06b6d4',
|
|
199
|
+
w: '#ffffff',
|
|
200
|
+
d: '#9ca3af',
|
|
201
|
+
B: '#b45309',
|
|
202
|
+
p: '#8b5cf6',
|
|
203
|
+
o: '#f97316',
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
type ColorState = { html: string; inSpan: boolean }
|
|
207
|
+
|
|
208
|
+
function applyColorToken(state: ColorState, code: string): void {
|
|
209
|
+
if (code === '%') {
|
|
210
|
+
state.html += '%'
|
|
211
|
+
} else if (code === '*' || code === '0') {
|
|
212
|
+
if (state.inSpan) {
|
|
213
|
+
state.html += '</span>'
|
|
214
|
+
state.inSpan = false
|
|
215
|
+
}
|
|
216
|
+
} else if (COLOR_MAP[code]) {
|
|
217
|
+
if (state.inSpan) state.html += '</span>'
|
|
218
|
+
state.html += `<span style="color: ${COLOR_MAP[code]};">`
|
|
219
|
+
state.inSpan = true
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function colorizeHtml(msg: string): string {
|
|
224
|
+
const state: ColorState = { html: '', inSpan: false }
|
|
225
|
+
const parts = msg.split(/(%[a-zA-Z0-9*%])/g)
|
|
226
|
+
|
|
227
|
+
for (const part of parts) {
|
|
228
|
+
if (part.startsWith('%') && part.length === 2) {
|
|
229
|
+
applyColorToken(state, part[1])
|
|
230
|
+
} else {
|
|
231
|
+
state.html += escapeHTML(part)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (state.inSpan) state.html += '</span>'
|
|
236
|
+
return state.html
|
|
237
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { connection } from '@bakery-framework/orm/connection'
|
|
2
|
+
|
|
3
|
+
const driverNames = {
|
|
4
|
+
postgres: 'PostgreSQL',
|
|
5
|
+
mysql: 'MySQL',
|
|
6
|
+
sqlite: 'SQLite',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderDatabaseBrowser() {
|
|
10
|
+
const driverName = driverNames[connection.driver] || 'Database'
|
|
11
|
+
return (
|
|
12
|
+
<div id="panel-database" class="panel">
|
|
13
|
+
<style>{`
|
|
14
|
+
.db-mobile-toggle { display: none; margin-bottom: 1rem; width: 100%; justify-content: space-between; align-items: center; }
|
|
15
|
+
@media (max-width: 900px) {
|
|
16
|
+
.db-mobile-toggle { display: flex; }
|
|
17
|
+
#db-sidebar { display: none; }
|
|
18
|
+
#db-sidebar.mobile-open { display: block; margin-bottom: 1rem; }
|
|
19
|
+
}
|
|
20
|
+
`}</style>
|
|
21
|
+
<div class="db-mobile-toggle">
|
|
22
|
+
<h2 class="db-browser-mobile-title">
|
|
23
|
+
<iconify-icon icon="lucide:database"></iconify-icon>
|
|
24
|
+
Database Explorer
|
|
25
|
+
</h2>
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
class="btn btn-secondary"
|
|
29
|
+
onclick="document.getElementById('db-sidebar').classList.toggle('mobile-open')">
|
|
30
|
+
<iconify-icon icon="lucide:menu"></iconify-icon>
|
|
31
|
+
<span>Tables Menu</span>
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="db-container">
|
|
35
|
+
{/* Left Sidebar: Discovered Tables & Schema Details */}
|
|
36
|
+
<div id="db-sidebar" class="sidebar glass-effect">
|
|
37
|
+
<div class="sidebar-scroll-container">
|
|
38
|
+
<div class="sidebar-search">
|
|
39
|
+
<input
|
|
40
|
+
type="text"
|
|
41
|
+
id="db-table-search"
|
|
42
|
+
class="search-input"
|
|
43
|
+
placeholder="Search tables..."
|
|
44
|
+
oninput="filterTablesList()"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="sidebar-title">
|
|
48
|
+
<span>DISCOVERED TABLES</span>
|
|
49
|
+
<span id="tables-count">(0)</span>
|
|
50
|
+
</div>
|
|
51
|
+
<ul class="table-list" id="tables-list">
|
|
52
|
+
<li class="results-empty">Scanning schema...</li>
|
|
53
|
+
</ul>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{/* Right Content Area: Main Browser and SQL Terminal */}
|
|
58
|
+
<div class="console-container">
|
|
59
|
+
{/* Visual Browser Header (Shown only when a table is selected) */}
|
|
60
|
+
<div id="db-browser-view" class="browser-card glass-effect">
|
|
61
|
+
<div class="browser-header">
|
|
62
|
+
<div class="table-info">
|
|
63
|
+
<h2 id="current-table-title">
|
|
64
|
+
<iconify-icon icon="lucide:table"></iconify-icon>
|
|
65
|
+
<span>users</span>
|
|
66
|
+
</h2>
|
|
67
|
+
<span id="table-row-count-badge" class="badge">
|
|
68
|
+
0 rows
|
|
69
|
+
</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="actions-group">
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
class="btn btn-primary"
|
|
75
|
+
onclick="openInsertModal()">
|
|
76
|
+
<iconify-icon icon="lucide:plus"></iconify-icon>
|
|
77
|
+
<span>Add Row</span>
|
|
78
|
+
</button>
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
class="btn btn-secondary"
|
|
82
|
+
onclick="openImportModal()">
|
|
83
|
+
<iconify-icon icon="lucide:file-up"></iconify-icon>
|
|
84
|
+
<span>Import CSV</span>
|
|
85
|
+
</button>
|
|
86
|
+
<div class="export-dropdown-wrapper">
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
class="btn btn-secondary"
|
|
90
|
+
onclick="toggleExportMenu()">
|
|
91
|
+
<iconify-icon icon="lucide:download"></iconify-icon>
|
|
92
|
+
<span>Export ▾</span>
|
|
93
|
+
</button>
|
|
94
|
+
<div id="export-menu" class="export-menu">
|
|
95
|
+
<button type="button" onclick="exportToCSV()">
|
|
96
|
+
Export to CSV
|
|
97
|
+
</button>
|
|
98
|
+
<button type="button" onclick="exportToJSON()">
|
|
99
|
+
Export to JSON
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
class="btn btn-secondary btn-danger"
|
|
106
|
+
onclick="truncateCurrentTable()">
|
|
107
|
+
<iconify-icon icon="lucide:alert-triangle"></iconify-icon>
|
|
108
|
+
<span>Truncate</span>
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Filter Query Builder Bar */}
|
|
114
|
+
<div class="filter-builder-card glass-effect">
|
|
115
|
+
<div class="filter-builder-row">
|
|
116
|
+
<span class="filter-label">
|
|
117
|
+
<iconify-icon icon="lucide:filter"></iconify-icon>
|
|
118
|
+
<span>Filters</span>
|
|
119
|
+
</span>
|
|
120
|
+
<select id="filter-col-select" class="filter-select">
|
|
121
|
+
<option value="">-- Choose Column --</option>
|
|
122
|
+
</select>
|
|
123
|
+
<select id="filter-op-select" class="filter-select">
|
|
124
|
+
<option value="like">contains</option>
|
|
125
|
+
<option value="=">equals</option>
|
|
126
|
+
<option value=">">></option>
|
|
127
|
+
<option value="<"><</option>
|
|
128
|
+
<option value="is_null">is null</option>
|
|
129
|
+
<option value="is_not_null">is not null</option>
|
|
130
|
+
</select>
|
|
131
|
+
<input
|
|
132
|
+
type="text"
|
|
133
|
+
id="filter-val-input"
|
|
134
|
+
class="filter-input"
|
|
135
|
+
placeholder="Filter value..."
|
|
136
|
+
/>
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
class="btn btn-secondary"
|
|
140
|
+
onclick="addActiveFilter()">
|
|
141
|
+
Apply
|
|
142
|
+
</button>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
class="btn btn-secondary"
|
|
146
|
+
onclick="clearActiveFilters()">
|
|
147
|
+
Clear All
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
<div id="active-filters-list" class="active-filters-list">
|
|
151
|
+
{/* Dynamically generated filter chips */}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* Pagination Controls */}
|
|
156
|
+
<div class="pagination-bar">
|
|
157
|
+
<div class="page-size-selector">
|
|
158
|
+
<span>Show</span>
|
|
159
|
+
<select
|
|
160
|
+
id="db-page-size"
|
|
161
|
+
class="filter-select"
|
|
162
|
+
onchange="changePageSize()">
|
|
163
|
+
<option value="10">10 rows</option>
|
|
164
|
+
<option value="25">25 rows</option>
|
|
165
|
+
<option value="50" selected>
|
|
166
|
+
50 rows
|
|
167
|
+
</option>
|
|
168
|
+
<option value="100">100 rows</option>
|
|
169
|
+
<option value="500">500 rows</option>
|
|
170
|
+
</select>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="page-nav">
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
id="btn-page-prev"
|
|
176
|
+
class="btn btn-secondary"
|
|
177
|
+
onclick="prevPage()">
|
|
178
|
+
<iconify-icon icon="lucide:chevron-left"></iconify-icon>
|
|
179
|
+
<span>Prev</span>
|
|
180
|
+
</button>
|
|
181
|
+
<span id="db-page-info">Page 1 of 1</span>
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
id="btn-page-next"
|
|
185
|
+
class="btn btn-secondary"
|
|
186
|
+
onclick="nextPage()">
|
|
187
|
+
<span>Next</span>
|
|
188
|
+
<iconify-icon icon="lucide:chevron-right"></iconify-icon>
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="rows-meta">
|
|
192
|
+
<span id="db-rows-meta">0 rows matching filters</span>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{/* Interactive Data Table Grid */}
|
|
197
|
+
<div class="results-card glass-effect">
|
|
198
|
+
<div id="browser-grid-body">
|
|
199
|
+
<div class="results-empty">
|
|
200
|
+
<span>Loading table data...</span>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{/* SQL Terminal Console */}
|
|
207
|
+
<div class="editor-card glass-effect">
|
|
208
|
+
<div class="sql-terminal-header">
|
|
209
|
+
<h2>{driverName} SQL Terminal</h2>
|
|
210
|
+
<span class="console-mode-badge">Read/Write Mode Enabled</span>
|
|
211
|
+
</div>
|
|
212
|
+
<textarea
|
|
213
|
+
class="sql-textarea"
|
|
214
|
+
id="sql-query"
|
|
215
|
+
placeholder="SELECT * FROM users LIMIT 10;"
|
|
216
|
+
/>
|
|
217
|
+
<div class="actions-row">
|
|
218
|
+
<span class="sql-terminal-help">
|
|
219
|
+
Supports SELECT, UPDATE, INSERT, DELETE, and administrative
|
|
220
|
+
statements.
|
|
221
|
+
</span>
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
class="btn sql-terminal-run"
|
|
225
|
+
onclick="runQuery()">
|
|
226
|
+
<iconify-icon icon="lucide:play"></iconify-icon>
|
|
227
|
+
<span>Run SQL Command</span>
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* SQL Terminal Output */}
|
|
233
|
+
<div class="results-card glass-effect" id="sql-console-results-card">
|
|
234
|
+
<div class="results-header">
|
|
235
|
+
<span>SQL TERMINAL OUTPUT</span>
|
|
236
|
+
<span id="results-meta"></span>
|
|
237
|
+
</div>
|
|
238
|
+
<div id="results-body">
|
|
239
|
+
<div class="results-empty">
|
|
240
|
+
<span>
|
|
241
|
+
No SQL query executed yet. Write a query above and click "Run
|
|
242
|
+
SQL Command".
|
|
243
|
+
</span>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{/* Dynamic Insert Modal */}
|
|
251
|
+
<div id="modal-insert" class="modal-overlay">
|
|
252
|
+
<div class="modal-card">
|
|
253
|
+
<div class="modal-header">
|
|
254
|
+
<h3>Add New Row</h3>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
class="modal-close"
|
|
258
|
+
onclick="closeInsertModal()">
|
|
259
|
+
×
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
<form id="insert-row-form" onsubmit="submitInsertRow(event)">
|
|
263
|
+
<div id="insert-fields-container" class="modal-fields-container">
|
|
264
|
+
{/* Dynamically generated form fields */}
|
|
265
|
+
</div>
|
|
266
|
+
<div class="modal-actions">
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
class="btn btn-secondary"
|
|
270
|
+
onclick="closeInsertModal()">
|
|
271
|
+
Cancel
|
|
272
|
+
</button>
|
|
273
|
+
<button type="submit" class="btn btn-primary">
|
|
274
|
+
Add Row
|
|
275
|
+
</button>
|
|
276
|
+
</div>
|
|
277
|
+
</form>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
{/* Dynamic Edit Modal */}
|
|
282
|
+
<div id="modal-edit" class="modal-overlay">
|
|
283
|
+
<div class="modal-card">
|
|
284
|
+
<div class="modal-header">
|
|
285
|
+
<h3>Edit Row Details</h3>
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
class="modal-close"
|
|
289
|
+
onclick="closeEditModal()">
|
|
290
|
+
×
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
<form id="edit-row-form" onsubmit="submitEditRow(event)">
|
|
294
|
+
<input type="hidden" id="edit-row-rowid" />
|
|
295
|
+
<div id="edit-fields-container" class="modal-fields-container">
|
|
296
|
+
{/* Dynamically generated form fields */}
|
|
297
|
+
</div>
|
|
298
|
+
<div class="modal-actions">
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
class="btn btn-secondary"
|
|
302
|
+
onclick="closeEditModal()">
|
|
303
|
+
Cancel
|
|
304
|
+
</button>
|
|
305
|
+
<button type="submit" class="btn btn-primary">
|
|
306
|
+
Save Changes
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
</form>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
{/* CSV Import Modal */}
|
|
314
|
+
<div id="modal-import" class="modal-overlay">
|
|
315
|
+
<div class="modal-card">
|
|
316
|
+
<div class="modal-header">
|
|
317
|
+
<h3>
|
|
318
|
+
<iconify-icon icon="lucide:file-up"></iconify-icon>
|
|
319
|
+
<span>Bulk Import CSV Data</span>
|
|
320
|
+
</h3>
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
class="modal-close"
|
|
324
|
+
onclick="closeImportModal()">
|
|
325
|
+
×
|
|
326
|
+
</button>
|
|
327
|
+
</div>
|
|
328
|
+
<div class="modal-fields-container">
|
|
329
|
+
<p>
|
|
330
|
+
Paste CSV data below. The first row must contain column headers
|
|
331
|
+
matching the table schema. Data values are automatically parsed.
|
|
332
|
+
</p>
|
|
333
|
+
<textarea
|
|
334
|
+
id="csv-import-textarea"
|
|
335
|
+
class="sql-textarea"
|
|
336
|
+
placeholder="username,email alice,alice@example.com bob,bob@example.com"></textarea>
|
|
337
|
+
<div class="import-upload-block">
|
|
338
|
+
<span>Or upload a .csv file:</span>
|
|
339
|
+
<input
|
|
340
|
+
type="file"
|
|
341
|
+
id="csv-file-input"
|
|
342
|
+
accept=".csv"
|
|
343
|
+
onchange="handleCsvFileSelect(event)"
|
|
344
|
+
/>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
<div class="modal-actions">
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
class="btn btn-secondary"
|
|
351
|
+
onclick="closeImportModal()">
|
|
352
|
+
Cancel
|
|
353
|
+
</button>
|
|
354
|
+
<button
|
|
355
|
+
type="button"
|
|
356
|
+
class="btn btn-primary"
|
|
357
|
+
onclick="submitImportCsv()">
|
|
358
|
+
Import Rows
|
|
359
|
+
</button>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
)
|
|
365
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function renderLogsPanel() {
|
|
2
|
+
return (
|
|
3
|
+
<div id="panel-logs" class="panel">
|
|
4
|
+
<div class="card glass-effect">
|
|
5
|
+
<div class="actions-row">
|
|
6
|
+
<h2>Real-time Server Logs</h2>
|
|
7
|
+
<div class="actions-group">
|
|
8
|
+
<button
|
|
9
|
+
type="button"
|
|
10
|
+
class="btn btn-secondary"
|
|
11
|
+
onclick="toggleLogsPlay()"
|
|
12
|
+
id="btn-logs-play">
|
|
13
|
+
<iconify-icon icon="lucide:pause"></iconify-icon>
|
|
14
|
+
<span>Pause</span>
|
|
15
|
+
</button>
|
|
16
|
+
<button
|
|
17
|
+
type="button"
|
|
18
|
+
class="btn btn-secondary btn-danger"
|
|
19
|
+
onclick="clearLogs()">
|
|
20
|
+
<iconify-icon icon="lucide:trash-2"></iconify-icon>
|
|
21
|
+
<span>Clear Logs</span>
|
|
22
|
+
</button>
|
|
23
|
+
<label>
|
|
24
|
+
<input type="checkbox" id="logs-autoscroll" checked /> Auto-scroll
|
|
25
|
+
</label>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div id="logs-console" class="log-console">
|
|
30
|
+
<div class="text-secondary">Connecting to server log stream...</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|