pgkiln 0.37.0 → 0.38.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/SECURITY.md +1 -0
- package/db/migrations/091_workspace_service.sql +20 -0
- package/db/migrations/092_app_groups.sql +23 -0
- package/docs/apex-feature-parity.md +13 -8
- package/docs/guide/03-builder.md +76 -5
- package/docs/guide/12-development.md +6 -0
- package/examples/gallery/README.md +13 -0
- package/examples/gallery/gallery.json +20 -0
- package/examples/gallery/starters/asset-inventory.json +30 -0
- package/examples/gallery/starters/contacts.json +41 -0
- package/examples/gallery/starters/event-registrations.json +29 -0
- package/examples/gallery/starters/expense-claims.json +32 -0
- package/examples/gallery/starters/team-tasks.json +35 -0
- package/package.json +1 -1
- package/public/builder.css +91 -0
- package/src/app.ts +6 -0
- package/src/builder/app-groups.ts +132 -0
- package/src/builder/blueprints.ts +14 -0
- package/src/builder/cross-app.ts +106 -0
- package/src/builder/gallery.ts +154 -0
- package/src/builder/home.ts +27 -8
- package/src/builder/routes.ts +76 -24
- package/src/builder/scripts.ts +6 -2
- package/src/builder/sql.ts +106 -7
- package/src/builder/ui.ts +30 -7
- package/src/builder/workspaces.ts +116 -7
- package/src/cli/replace.ts +2 -0
- package/src/runtime/calendar.ts +2 -1
- package/src/session.ts +13 -2
package/src/builder/home.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
|
|
|
3
3
|
import { html, raw, type Raw } from '../html.ts';
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
5
|
import { APP_COLORS, BASE, csrf, developer, input, region, select, send, shell, type Req } from './ui.ts';
|
|
6
|
+
import { crossAppLinks } from './cross-app.ts';
|
|
6
7
|
import type { Session } from '../session.ts';
|
|
7
8
|
import { isAdmin } from './locks.ts';
|
|
8
9
|
import { currentWorkspace, inWorkspaceSql, workspaceAdmin } from './workspaces.ts';
|
|
@@ -98,15 +99,22 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
98
99
|
if (typeof req.query.sort === 'string' && req.query.sort in SORTS) s.state.__BSORT = req.query.sort;
|
|
99
100
|
const view: View = s.state.__BVIEW === 'grid' ? 'grid' : 'report';
|
|
100
101
|
const sort: Sort = (typeof s.state.__BSORT === 'string' && s.state.__BSORT in SORTS ? s.state.__BSORT : 'name') as Sort;
|
|
102
|
+
// (092) application groups of the workspace: a filter, remembered for the session
|
|
103
|
+
const groups = (await owner.query<{ id: number; name: string }>('select id, name from meta.app_group where workspace_id = $1 order by lower(name)', [currentWorkspace(s)])).rows;
|
|
104
|
+
if (typeof req.query.group === 'string') s.state.__BGROUP = req.query.group;
|
|
105
|
+
const group = groups.find((g) => String(g.id) === s.state.__BGROUP) ?? null;
|
|
101
106
|
const [apps, total, recent] = await Promise.all([
|
|
102
107
|
owner.query(
|
|
103
|
-
`select a.id, a.name, a.alias, a.authentication, a.db_role, a.updated_at,
|
|
108
|
+
`select a.id, a.name, a.alias, a.authentication, a.db_role, a.updated_at, g.name as group_name,
|
|
104
109
|
(select count(*) from meta.page p where p.app_id = a.id)::int as pages,
|
|
105
110
|
(select count(*) from meta.activity_log l where l.app_id = a.id and l.event = 'page_view' and l.at > now() - interval '1 day')::int as views
|
|
106
111
|
from meta.app a
|
|
112
|
+
left join meta.app_group_member gm on gm.app_id = a.id
|
|
113
|
+
left join meta.app_group g on g.id = gm.group_id
|
|
107
114
|
where ($1 = '' or a.name ilike '%' || $1 || '%' or a.alias ilike '%' || $1 || '%' or a.id::text = $1) and ${inWorkspaceSql('a', 2)}
|
|
115
|
+
and ($3::int is null or gm.group_id = $3)
|
|
108
116
|
order by ${SORTS[sort]}`,
|
|
109
|
-
[q.replace(/[\\%_]/g, (c) => `\\${c}`), currentWorkspace(s)],
|
|
117
|
+
[q.replace(/[\\%_]/g, (c) => `\\${c}`), currentWorkspace(s), group?.id ?? null],
|
|
110
118
|
),
|
|
111
119
|
owner.one(`select count(*)::int as n from meta.app a where ${inWorkspaceSql('a', 1)}`, [currentWorkspace(s)]),
|
|
112
120
|
recentApps(s),
|
|
@@ -122,11 +130,11 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
122
130
|
html`<th scope="col"${cls ? raw(` class="${cls}"`) : ''}${sort === key ? raw(' aria-sort="' + (key === 'pages' || key === 'updated' ? 'descending' : 'ascending') + '"') : ''}><a href="${link({ sort: key })}">${label}${sort === key ? html`<span class="ab-sorted" aria-hidden="true">${key === 'pages' || key === 'updated' ? '↓' : '↑'}</span>` : ''}</a></th>`;
|
|
123
131
|
const rows = apps.rows;
|
|
124
132
|
const list = rows.length === 0
|
|
125
|
-
? html`<p class="ab-empty">${total.n ? html`No application matches “${q}
|
|
133
|
+
? html`<p class="ab-empty">${total.n ? html`No application ${group ? html`in the group ${group.name}${q ? html` matches “${q}”` : ''}` : html`matches “${q}”`}. <a href="${BASE}?q=&group=">Show all</a>` : html`No applications yet. <a href="${BASE}/create">Create one</a> or <a href="${BASE}/import">import one</a>.`}</p>`
|
|
126
134
|
: view === 'grid'
|
|
127
135
|
? html`<div class="ab-cards">${rows.map((a) => html`<article class="ab-card" data-filter-row>
|
|
128
136
|
<a class="ab-card-main" href="${BASE}/apps/${a.id}">${appIcon(a)}<span><strong>${a.name}</strong><small>${a.id} · /a/${a.alias}</small></span></a>
|
|
129
|
-
<div class="ab-card-meta"
|
|
137
|
+
<div class="ab-card-meta">${a.group_name ? html`<span class="tag">${a.group_name}</span>` : ''}<span>${a.pages} pages</span><span>${a.views} views today</span><span>${ago(a.updated_at)}</span></div>
|
|
130
138
|
<div class="ab-card-actions">
|
|
131
139
|
<a class="tb-btn" href="${BASE}/apps/${a.id}" title="Edit ${a.name}">${icon('edit')}<span class="sr-only">Edit ${a.name}</span></a>
|
|
132
140
|
<a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="Run ${a.name}">${icon('play')}<span class="sr-only">Run ${a.name}</span></a>
|
|
@@ -137,7 +145,7 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
137
145
|
<tbody>${rows.map((a) => html`<tr data-filter-row>
|
|
138
146
|
<td class="num">${a.id}</td>
|
|
139
147
|
<td class="ab-name"><a href="${BASE}/apps/${a.id}">${appIcon(a)}<span>${a.name}</span></a></td>
|
|
140
|
-
<td>${a.alias}</td>
|
|
148
|
+
<td>${a.alias}${a.group_name ? html` <span class="tag">${a.group_name}</span>` : ''}</td>
|
|
141
149
|
<td class="num">${a.pages}</td>
|
|
142
150
|
<td class="num">${a.views}</td>
|
|
143
151
|
<td>${a.authentication === 'none' ? 'Public' : a.authentication === 'header' ? 'HTTP header' : a.authentication === 'database' ? 'Database accounts' : a.authentication === 'custom' ? 'Custom' : 'App users'}</td>
|
|
@@ -151,6 +159,7 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
151
159
|
const main = html`<div class="ab-home">
|
|
152
160
|
<div class="ab-main">
|
|
153
161
|
<h1 class="sr-only">App Builder</h1>
|
|
162
|
+
${s.workspace?.message ? html`<div class="alert alert-info ab-message" role="status">${icon('bell')}<div><strong>${s.workspace.name}</strong><p>${s.workspace.message}</p></div></div>` : ''}
|
|
154
163
|
${tiles()}
|
|
155
164
|
<div class="ab-toolbar">
|
|
156
165
|
<form class="ab-search" method="get" action="${BASE}" role="search">
|
|
@@ -158,6 +167,9 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
158
167
|
<input id="ab-q" name="q" type="search" value="${q}" placeholder="Search" autocomplete="off" data-filter-list=".ab-home [data-filter-row]">
|
|
159
168
|
</form>
|
|
160
169
|
<div class="ab-views" role="group" aria-label="View">${viewButton('grid', 'grid', 'Cards')}${viewButton('report', 'list', 'Report')}</div>
|
|
170
|
+
${groups.length ? html`<form method="get" action="${BASE}" class="ab-group"><label class="sr-only" for="ab-group">Application group</label>
|
|
171
|
+
<select id="ab-group" name="group" data-autosubmit><option value="">All groups</option>${groups.map((g) => html`<option value="${g.id}"${group?.id === g.id ? raw(' selected') : ''}>${g.name}</option>`)}</select>
|
|
172
|
+
<noscript><button class="btn btn-sm">Show</button></noscript></form>` : ''}
|
|
161
173
|
<details class="menu ab-menu"><summary class="btn">Actions ${icon('chevron')}</summary>
|
|
162
174
|
<div class="menu-panel" role="menu">
|
|
163
175
|
<div class="menu-section"><span class="small muted">Sort by</span>
|
|
@@ -195,7 +207,7 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
195
207
|
${input('name', 'Name', '', { required: true })}
|
|
196
208
|
${input('alias', 'Alias (URL)', '', { required: true, help: 'lowercase, e.g. inventory → /a/inventory' })}
|
|
197
209
|
${boilerplates.length
|
|
198
|
-
? select('boilerplate', 'Start from', '', [['', '- a blank application (a Home page) -'], ...boilerplates.map((x): [string, string] => [String(x.id), `${x.name} (boilerplate)`])],
|
|
210
|
+
? select('boilerplate', 'Start from', /^\d{1,9}$/.test(String(req.query?.boilerplate ?? '')) ? String(req.query!.boilerplate) : '', [['', '- a blank application (a Home page) -'], ...boilerplates.map((x): [string, string] => [String(x.id), `${x.name} (boilerplate)`])],
|
|
199
211
|
'A boilerplate application\'s pages, shared components and settings are copied; the new application keeps its own name, alias, role and authentication.')
|
|
200
212
|
: ''}
|
|
201
213
|
${select('schema', 'Parsing schema', '', [['', '- new schema named after the alias -'], ...schemas.rows.map((r): [string, string] => [r.nspname, r.nspname])],
|
|
@@ -275,9 +287,11 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
275
287
|
if (!s) return;
|
|
276
288
|
const card = (href: string, ic: Raw | '', title: string, text: string) =>
|
|
277
289
|
html`<a class="ab-tile" href="${href}"><span class="ab-tile-icon">${ic}</span><strong>${title}</strong><span>${text}</span></a>`;
|
|
278
|
-
const main = html`<h1 class="u-mb1">Workspace utilities</h1>
|
|
290
|
+
const main = html`<div class="ab-home"><div class="ab-main"><h1 class="u-mb1">Workspace utilities</h1>
|
|
279
291
|
<div class="ab-tiles ab-tiles-wrap">
|
|
280
292
|
${card(`${BASE}/users`, icon('user'), 'Users', 'Accounts of the people who use the applications, and their access.')}
|
|
293
|
+
${card(`${BASE}/app-groups`, icon('folder'), 'Application groups', 'Organise the applications of this workspace into groups.')}
|
|
294
|
+
${card(`${BASE}/gallery`, icon('package'), 'App Gallery', 'Sample and starter applications to install, and this workspace\'s boilerplates.')}
|
|
281
295
|
${card(`${BASE}/users/providers`, icon('key'), 'Identity providers', 'OpenID Connect and SAML sign-in.')}
|
|
282
296
|
${card(`${BASE}/users/directories`, icon('org'), 'LDAP directories', 'Sign in with directory accounts.')}
|
|
283
297
|
${card(`${BASE}/users#account-settings`, icon('shield'), 'Password policy', 'Length, complexity and lifetime of passwords.')}
|
|
@@ -293,7 +307,12 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
293
307
|
${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), 'AI services', 'Claude and OpenAI for the applications: models, keys, limits and usage (administrators).') : ''}
|
|
294
308
|
${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), 'Mail services', 'SMTP, Microsoft 365, Amazon SES, Resend, Postmark, SendGrid or Mailgun for the applications\' e-mail (administrators).') : ''}
|
|
295
309
|
${(await isAdmin(s.username)) ? card(`${BASE}/installation`, icon('history'), 'Installation', 'Version, install and upgrade runs, applied migrations (administrators).') : ''}
|
|
296
|
-
</div
|
|
310
|
+
</div></div>
|
|
311
|
+
<aside class="ab-side" aria-label="About and reports">
|
|
312
|
+
<section><h2>About</h2><p>Workspace utilities hold what the applications of this workspace share: users and sign-in, groups, the gallery, and reports across the applications.</p>
|
|
313
|
+
<a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#workspaces" target="_blank" rel="noopener">Learn more …</a></section>
|
|
314
|
+
<section><h2>Cross Application Reports</h2><ul class="ab-links ab-tasks">${crossAppLinks()}</ul></section>
|
|
315
|
+
</aside></div>`;
|
|
297
316
|
return send(reply, s, shell(s, 'Workspace utilities', [['App Builder', BASE], ['Workspace utilities']], main));
|
|
298
317
|
});
|
|
299
318
|
}
|
package/src/builder/routes.ts
CHANGED
|
@@ -31,23 +31,61 @@ import { clearDevbar } from '../runtime/devbar.ts';
|
|
|
31
31
|
|
|
32
32
|
export async function builderRoutes(app: FastifyInstance) {
|
|
33
33
|
// ---------------------------------------------------------------- login
|
|
34
|
+
// (sprint 61, APEX: the workspace sign-in page) workspace (optional), username and password;
|
|
35
|
+
// "Remember workspace and username" keeps both in a cookie of the builder's path
|
|
36
|
+
const REMEMBER_COOKIE = 'pgkiln_bsignin';
|
|
37
|
+
const remembered = (req: Req): { w: string; u: string } | null => {
|
|
38
|
+
try {
|
|
39
|
+
const v = JSON.parse(Buffer.from(String(req.cookies?.[REMEMBER_COOKIE] ?? ''), 'base64url').toString('utf8'));
|
|
40
|
+
return typeof v?.w === 'string' && typeof v?.u === 'string' ? { w: v.w.slice(0, 60), u: v.u.slice(0, 100) } : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** Where to go after signing in: a builder page only. */
|
|
46
|
+
const safeNext = (v: unknown) => (typeof v === 'string' && /^\/builder(\/[A-Za-z0-9_/-]{0,200})?$/.test(v) ? v : BASE);
|
|
47
|
+
|
|
34
48
|
app.get(`${BASE}/login`, async (req: Req, reply) => {
|
|
35
49
|
const s = await getSession(req, reply, null, BASE);
|
|
36
50
|
if (s.username) return reply.redirect(BASE);
|
|
37
51
|
const error = takeFlash(s, '__ERROR');
|
|
38
52
|
await saveState(s);
|
|
53
|
+
const r = remembered(req);
|
|
54
|
+
const next = safeNext(req.query?.next);
|
|
55
|
+
const field = (name: string, label: string, ic: string, value: string, opts: { type?: string; required?: boolean; auto?: string; placeholder?: string; reveal?: boolean }) =>
|
|
56
|
+
html`<div class="field signin-field"><label class="sr-only" for="f_${name}">${label}</label>
|
|
57
|
+
<div class="signin-input">${icon(ic)}<input id="f_${name}" name="${name}" type="${opts.type ?? 'text'}" value="${value}" placeholder="${opts.placeholder ?? label}"${opts.required ? raw(' required') : ''}${opts.auto ? raw(` autocomplete="${opts.auto}"`) : ''}>
|
|
58
|
+
${opts.reveal ? html`<button type="button" class="signin-reveal" data-reveal="f_${name}" aria-controls="f_${name}" aria-pressed="false" data-hide="Hide" hidden>Show</button>` : ''}</div></div>`;
|
|
39
59
|
return reply.type('text/html').send(
|
|
40
60
|
documentShell(
|
|
41
61
|
'Sign in · Builder',
|
|
42
|
-
html`<main class="
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
html`<main class="signin">
|
|
63
|
+
<section class="signin-hero" aria-label="pgkiln">
|
|
64
|
+
<span class="signin-logo" aria-hidden="true">pg</span>
|
|
65
|
+
<h1 class="signin-title">pgkiln</h1>
|
|
66
|
+
<p class="signin-tagline">Build data applications on PostgreSQL, in SQL you can trust</p>
|
|
67
|
+
<p class="signin-foot">Open source under the Apache-2.0 license</p>
|
|
68
|
+
</section>
|
|
69
|
+
<section class="signin-panel">
|
|
70
|
+
<form method="post" class="signin-form">
|
|
71
|
+
<h2>Welcome</h2>
|
|
72
|
+
<p class="muted u-mt0">Sign in to your workspace to continue</p>
|
|
73
|
+
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
74
|
+
${csrf(s)}<input type="hidden" name="next" value="${next}">
|
|
75
|
+
${field('workspace', 'Workspace', 'database', r?.w ?? '', { placeholder: 'Workspace (optional)', auto: 'organization' })}
|
|
76
|
+
${field('username', 'Username', 'user', r?.u ?? '', { required: true, auto: 'username' })}
|
|
77
|
+
${field('password', 'Password', 'key', '', { type: 'password', required: true, auto: 'current-password', reveal: true })}
|
|
78
|
+
<label class="check signin-remember"><input type="checkbox" name="remember" value="1"${r ? raw(' checked') : ''}> Remember workspace and username</label>
|
|
79
|
+
<button class="btn btn-hot signin-submit">Sign in</button>
|
|
80
|
+
<p class="muted small signin-hint">Leave the workspace empty to open your last one; you can switch later in your account menu.</p>
|
|
81
|
+
<nav class="signin-actions" aria-label="Quick actions">
|
|
82
|
+
<a href="${BASE}/login?next=${encodeURIComponent(`${BASE}/utilities`)}">${icon('settings')} Administration</a>
|
|
83
|
+
<a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#workspaces" target="_blank" rel="noopener">${icon('help')} About workspaces</a>
|
|
84
|
+
</nav>
|
|
85
|
+
</form>
|
|
86
|
+
</section>
|
|
87
|
+
</main>`,
|
|
88
|
+
'login-body ide-login signin-body',
|
|
51
89
|
{},
|
|
52
90
|
builderHead(),
|
|
53
91
|
{ theme: validTheme(req.cookies?.[THEME_COOKIE]) ?? 'dark' },
|
|
@@ -58,15 +96,18 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
58
96
|
app.post(`${BASE}/login`, async (req: Req, reply) => {
|
|
59
97
|
const s = await getSession(req, reply, null, BASE);
|
|
60
98
|
const { username = '', password = '' } = req.body ?? {};
|
|
99
|
+
const workspace = String(req.body?.workspace ?? '').trim().slice(0, 60);
|
|
100
|
+
const next = safeNext(req.body?.next);
|
|
101
|
+
const again = next === BASE ? `${BASE}/login` : `${BASE}/login?next=${encodeURIComponent(next)}`;
|
|
61
102
|
const ip = clientIp(req);
|
|
62
103
|
if (req.body?.__csrf !== s.csrf_token) {
|
|
63
104
|
flash(s, 'Your session expired. Please try again.', 'error');
|
|
64
|
-
return back(reply, s,
|
|
105
|
+
return back(reply, s, again);
|
|
65
106
|
}
|
|
66
107
|
if (await loginThrottled(null, username, ip)) {
|
|
67
108
|
logActivity({ username, event: 'login_locked', ip, detail: 'builder' });
|
|
68
109
|
flash(s, 'Too many failed sign-in attempts. Try again later.', 'error');
|
|
69
|
-
return back(reply, s,
|
|
110
|
+
return back(reply, s, again);
|
|
70
111
|
}
|
|
71
112
|
// bcrypt always runs (dummy salt for unknown users) so timing does not reveal accounts
|
|
72
113
|
const dev = await owner.one(
|
|
@@ -76,25 +117,36 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
76
117
|
where crypt($2, coalesce(d.password_hash, gen_salt('bf', 10))) = d.password_hash`,
|
|
77
118
|
[username, password],
|
|
78
119
|
);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
120
|
+
// a workspace, when given, must be one of the developer's (administrators: any); the same answer otherwise
|
|
121
|
+
const ws = dev && workspace
|
|
122
|
+
? await owner.one<{ id: number }>(
|
|
123
|
+
`select w.id from meta.workspace w
|
|
124
|
+
where lower(w.name) = lower($1)
|
|
125
|
+
and (exists (select 1 from meta.developer d where d.username = $2 and d.is_admin)
|
|
126
|
+
or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $2))`,
|
|
127
|
+
[workspace, dev.username],
|
|
128
|
+
)
|
|
129
|
+
: undefined;
|
|
130
|
+
if (!dev || (workspace && !ws)) {
|
|
131
|
+
await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: workspace ? 'builder (workspace)' : 'builder' });
|
|
132
|
+
flash(s, workspace ? 'Invalid workspace, username or password.' : 'Invalid username or password.', 'error');
|
|
133
|
+
return back(reply, s, again);
|
|
83
134
|
}
|
|
84
135
|
await destroySession(reply, s, BASE);
|
|
85
136
|
const ns = await createSession(reply, null, BASE, dev.username);
|
|
86
137
|
// the builder theme chosen earlier on this device
|
|
87
138
|
const theme = validTheme(req.cookies?.[THEME_COOKIE]);
|
|
88
|
-
if (theme)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
139
|
+
if (theme) ns.state.__BTHEME = theme;
|
|
140
|
+
if (ws) ns.state.__WS = String(ws.id);
|
|
141
|
+
if (password === 'admin' || password === dev.username) ns.state.__WEAK = '1';
|
|
142
|
+
await saveState(ns);
|
|
143
|
+
if (req.body?.remember === '1')
|
|
144
|
+
reply.setCookie(REMEMBER_COOKIE, Buffer.from(JSON.stringify({ w: workspace, u: dev.username })).toString('base64url'), {
|
|
145
|
+
path: BASE, httpOnly: true, sameSite: 'lax', maxAge: 365 * 24 * 3600, secure: process.env.COOKIE_SECURE === 'true',
|
|
146
|
+
});
|
|
147
|
+
else reply.clearCookie(REMEMBER_COOKIE, { path: BASE });
|
|
92
148
|
logActivity({ username: dev.username, event: 'login', ip, detail: 'builder' });
|
|
93
|
-
|
|
94
|
-
ns.state.__WEAK = '1';
|
|
95
|
-
await saveState(ns);
|
|
96
|
-
}
|
|
97
|
-
return reply.redirect(BASE, 303);
|
|
149
|
+
return reply.redirect(next, 303);
|
|
98
150
|
});
|
|
99
151
|
|
|
100
152
|
app.post(`${BASE}/logout`, async (req: Req, reply) => {
|
package/src/builder/scripts.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { clientIp, logActivity, type Session } from '../session.ts';
|
|
|
7
7
|
import { runScript, splitScript, type ScriptRun, type StatementResult } from '../sqlscript.ts';
|
|
8
8
|
import { clearCompletions } from './code-editor.ts';
|
|
9
9
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
10
|
-
import { currentWorkspace, workshopDb } from './workspaces.ts';
|
|
10
|
+
import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
|
|
11
11
|
|
|
12
12
|
// SQL Workshop → SQL Scripts: saved scripts (create, edit, upload, download,
|
|
13
13
|
// delete), run with a result per statement (stop at the first error or go on,
|
|
@@ -32,9 +32,13 @@ export interface RunRequest {
|
|
|
32
32
|
/** Run a script, record the run (history + activity log) and return the run's id. */
|
|
33
33
|
export async function runAndRecord(s: Session, ip: string, r: RunRequest): Promise<{ id: string; run: ScriptRun }> {
|
|
34
34
|
const statements = splitScript(r.content);
|
|
35
|
-
const
|
|
35
|
+
const db = await workshopDb(s);
|
|
36
|
+
const schema = await sessionSchema(s, db);
|
|
37
|
+
const c = await db.pool.connect();
|
|
36
38
|
let run: ScriptRun;
|
|
37
39
|
try {
|
|
40
|
+
// (sprint 61) the session's schema first in the search path (the SQL Workshop home)
|
|
41
|
+
if (schema) await c.query(`select set_config('search_path', $1, false)`, [`"${schema.replace(/"/g, '""')}", public`]);
|
|
38
42
|
run = await runScript(c, statements, { stopOnError: r.stopOnError, transaction: r.transaction });
|
|
39
43
|
} finally {
|
|
40
44
|
// never hand a connection back to the pool after arbitrary SQL (SET ROLE, search_path, temp tables)
|
package/src/builder/sql.ts
CHANGED
|
@@ -2,19 +2,94 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
|
2
2
|
import pg from 'pg';
|
|
3
3
|
import { html, raw, type Raw } from '../html.ts';
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
|
-
import { BASE, csrf, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
6
|
-
import {
|
|
5
|
+
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
6
|
+
import { owner } from '../db.ts';
|
|
7
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
7
8
|
import { clearCompletions } from './code-editor.ts';
|
|
8
9
|
import { scriptRoutes } from './scripts.ts';
|
|
9
10
|
import { quickSqlRoutes } from './quicksql.ts';
|
|
10
11
|
import { queryBuilderRoutes } from './querybuilder.ts';
|
|
11
12
|
import { unloadRoutes } from './unload.ts';
|
|
12
13
|
import { ddlToolRoutes } from './ddl-tools.ts';
|
|
13
|
-
import { workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
14
|
+
import { currentWorkspace, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
14
15
|
|
|
15
16
|
// SQL Workshop: SQL commands and the object browser (owner connection; an isolated
|
|
16
17
|
// workspace's developer login for its developers, see workshopDb).
|
|
17
18
|
|
|
19
|
+
const ago = (t: string | Date) => {
|
|
20
|
+
const sec = Math.max(0, (Date.now() - new Date(t).getTime()) / 1000);
|
|
21
|
+
const [n, unit] = sec < 60 ? [sec, 'second'] : sec < 3600 ? [sec / 60, 'minute'] : sec < 86400 ? [sec / 3600, 'hour'] : sec < 86400 * 30 ? [sec / 86400, 'day'] : sec < 86400 * 365 ? [sec / 86400 / 30, 'month'] : [sec / 86400 / 365, 'year'];
|
|
22
|
+
const k = Math.floor(n);
|
|
23
|
+
return `${k} ${unit}${k === 1 ? '' : 's'} ago`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** (sprint 61, APEX: the SQL Workshop home) tiles, the newest tables, recent commands and scripts, the session's schema. */
|
|
27
|
+
async function workshopHome(req: Req, reply: FastifyReply) {
|
|
28
|
+
const s = await developer(req, reply);
|
|
29
|
+
if (!s) return;
|
|
30
|
+
const db = await workshopDb(s);
|
|
31
|
+
const [schemas, schema, tables, commands, scripts] = await Promise.all([
|
|
32
|
+
usableSchemas(db),
|
|
33
|
+
sessionSchema(s, db),
|
|
34
|
+
// PostgreSQL keeps no creation time: object ids grow, so the highest are the newest
|
|
35
|
+
db.query<{ qname: string; schema: string; name: string; rows: number }>(
|
|
36
|
+
`select c.oid::regclass::text as qname, n.nspname as schema, c.relname as name, greatest(c.reltuples, 0)::bigint as rows
|
|
37
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
38
|
+
where c.relkind in ('r', 'p') and not c.relispartition and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')
|
|
39
|
+
and has_schema_privilege(n.oid, 'USAGE') and has_table_privilege(c.oid, 'SELECT')
|
|
40
|
+
and not (n.nspname = 'public' and c.relname like 'pgkiln\\_%')
|
|
41
|
+
order by c.oid desc limit 15`,
|
|
42
|
+
),
|
|
43
|
+
owner.query<{ id: string; detail: string; at: string }>(
|
|
44
|
+
`select id, detail, at from meta.activity_log where username = $1 and event = 'sql_command' order by id desc limit 15`,
|
|
45
|
+
[s.username],
|
|
46
|
+
),
|
|
47
|
+
owner.query<{ id: number; name: string; updated_at: string }>(
|
|
48
|
+
`select id, name, updated_at from meta.sql_script where workspace_id = $1 order by updated_at desc limit 15`,
|
|
49
|
+
[currentWorkspace(s)],
|
|
50
|
+
),
|
|
51
|
+
]);
|
|
52
|
+
const tile = (href: string, ic: string, title: string, text: string) =>
|
|
53
|
+
html`<a class="ab-tile sw-tile" href="${BASE}${href}"><span class="ab-tile-icon sw-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`;
|
|
54
|
+
const list = (title: string, rows: Raw[], empty: string) =>
|
|
55
|
+
html`<section class="sw-recent"><h2>${title}</h2>${rows.length ? html`<ul>${rows}</ul>` : html`<p class="muted">${empty}</p>`}</section>`;
|
|
56
|
+
const main = html`<div class="ab-home">
|
|
57
|
+
<div class="ab-main">
|
|
58
|
+
<h1 class="sr-only">SQL Workshop</h1>
|
|
59
|
+
${workshopTabs('home')}
|
|
60
|
+
<div class="ab-tiles sw-tiles">
|
|
61
|
+
${tile('/sql/objects', 'search', 'Object Browser', 'Browse tables, views and functions, their policies, grants and data.')}
|
|
62
|
+
${tile('/sql/commands', 'code', 'SQL Commands', 'Run SQL statements against your schemas.')}
|
|
63
|
+
${tile('/sql/scripts', 'file', 'SQL Scripts', 'Create, upload and run stored SQL scripts.')}
|
|
64
|
+
${tile('/sql/load', 'upload', 'Utilities', 'Load and unload data, sample data, DDL and schema comparison.')}
|
|
65
|
+
${tile('/sql/quick', 'bolt', 'Quick SQL', 'Write a data model in shorthand and get the DDL.')}
|
|
66
|
+
</div>
|
|
67
|
+
<div class="sw-recents">
|
|
68
|
+
${list('Recently created tables', tables.rows.map((t) => html`<li><a href="${BASE}/sql/objects?o=${encodeURIComponent(t.qname)}">${t.qname}</a><span class="muted">~${Number(t.rows).toLocaleString('en')} rows</span></li>`), 'No tables you can read.')}
|
|
69
|
+
${list('Recent SQL commands', commands.rows.map((c) => html`<li><a href="${BASE}/sql/commands?recent=${c.id}" title="${c.detail.slice(0, 500)}">${c.detail.replace(/\s+/g, ' ').slice(0, 60)}</a><span class="muted">${ago(c.at)}</span></li>`), 'You haven\'t run SQL commands yet.')}
|
|
70
|
+
${list('Recent SQL scripts', scripts.rows.map((x) => html`<li><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a><span class="muted">${ago(x.updated_at)}</span></li>`), 'No scripts in this workspace yet.')}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<aside class="ab-side" aria-label="About, schema and tasks">
|
|
74
|
+
<section><h2>About</h2><p>The SQL Workshop is a collection of tools to manage database objects. It runs as ${workshopRunsAs(db)}.</p>
|
|
75
|
+
<a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener">Learn more …</a></section>
|
|
76
|
+
<section><h2>Schema</h2><p>The default schema of this session: first in the search path of SQL Commands and SQL Scripts.</p>
|
|
77
|
+
<form method="post" action="${BASE}/sql/schema" class="sw-schema">${csrf(s)}
|
|
78
|
+
<label class="sr-only" for="f_schema">Schema</label>
|
|
79
|
+
<select id="f_schema" name="schema" data-autosubmit><option value="">(the default search path)</option>${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
|
|
80
|
+
<button class="btn btn-sm">Set</button></form></section>
|
|
81
|
+
<section><h2>Tasks</h2><ul class="ab-links ab-tasks">
|
|
82
|
+
<li><a href="${BASE}/sql/quick">Create a data model with Quick SQL${icon('chevron')}</a></li>
|
|
83
|
+
<li><a href="${BASE}/blueprints">Create an application from a blueprint${icon('chevron')}</a></li>
|
|
84
|
+
<li><a href="${BASE}/sql/ai/describe">Describe tables${icon('chevron')}</a></li>
|
|
85
|
+
<li><a href="${BASE}/sql/sample-data">Generate sample data${icon('chevron')}</a></li>
|
|
86
|
+
<li><a href="${BASE}/sql/ddl">Generate DDL${icon('chevron')}</a></li>
|
|
87
|
+
</ul></section>
|
|
88
|
+
</aside>
|
|
89
|
+
</div>`;
|
|
90
|
+
return send(reply, s, shell(s, 'SQL Workshop', [['SQL Workshop']], main, 'sql'));
|
|
91
|
+
}
|
|
92
|
+
|
|
18
93
|
export async function sqlRoutes(app: FastifyInstance) {
|
|
19
94
|
// SQL Scripts, Quick SQL and the query builder (sprint 31)
|
|
20
95
|
await app.register(scriptRoutes);
|
|
@@ -37,6 +112,13 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
37
112
|
const sql = req.body?.sql ?? '';
|
|
38
113
|
let result: Raw | '' = '';
|
|
39
114
|
const db = await workshopDb(s);
|
|
115
|
+
// (sprint 61) a command from the home page's Recent SQL Commands (the developer's own)
|
|
116
|
+
let text = sql;
|
|
117
|
+
if (req.method === 'GET' && /^\d{1,18}$/.test(String(req.query?.recent ?? ''))) {
|
|
118
|
+
const r = await owner.one<{ detail: string }>(`select detail from meta.activity_log where id = $1 and username = $2 and event = 'sql_command'`, [req.query!.recent, s.username]);
|
|
119
|
+
if (r) text = r.detail;
|
|
120
|
+
}
|
|
121
|
+
const schema = await sessionSchema(s, db);
|
|
40
122
|
if (req.method === 'POST' && sql.trim()) {
|
|
41
123
|
const started = performance.now();
|
|
42
124
|
try {
|
|
@@ -45,6 +127,7 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
45
127
|
const c = await db.pool.connect();
|
|
46
128
|
let out;
|
|
47
129
|
try {
|
|
130
|
+
if (schema) await c.query(`select set_config('search_path', $1, false)`, [`${pg.escapeIdentifier(schema)}, public`]);
|
|
48
131
|
out = await c.query({ text: sql, rowMode: 'array' });
|
|
49
132
|
} finally {
|
|
50
133
|
c.release(true);
|
|
@@ -61,16 +144,32 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
61
144
|
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), elapsedMs: Math.round(performance.now() - started), detail: sql.length > 2000 ? `${sql.slice(0, 2000)}…` : sql });
|
|
62
145
|
}
|
|
63
146
|
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('sql')}
|
|
64
|
-
<p class="muted">Runs as ${workshopRunsAs(db)} (not as an application role). Multiple statements are allowed; results of the last one are shown.</p>
|
|
65
|
-
<form method="post">${csrf(s)}
|
|
66
|
-
<textarea name="sql" class="code sql-editor" rows="12" spellcheck="false" aria-label="SQL" data-code="plpgsql">${
|
|
147
|
+
<p class="muted">Runs as ${workshopRunsAs(db)} (not as an application role)${schema ? html`, schema <code>${schema}</code> first in the search path (<a href="${BASE}/sql">change</a>)` : ''}. Multiple statements are allowed; results of the last one are shown.</p>
|
|
148
|
+
<form method="post" action="${BASE}/sql/commands">${csrf(s)}
|
|
149
|
+
<textarea name="sql" class="code sql-editor" rows="12" spellcheck="false" aria-label="SQL" data-code="plpgsql">${text || 'select current_user, version();'}</textarea>
|
|
67
150
|
<div class="buttons"><button class="btn btn-hot">${icon('play')} Run (Ctrl+Enter)</button></div>
|
|
68
151
|
</form>
|
|
69
152
|
<div class="u-mt1">${result}</div>`;
|
|
70
153
|
return send(reply, s, shell(s, 'SQL Workshop', [['SQL Workshop']], main, 'sql'));
|
|
71
154
|
};
|
|
72
|
-
app.get(`${BASE}/sql`, sqlWorkshop);
|
|
155
|
+
app.get(`${BASE}/sql/commands`, sqlWorkshop);
|
|
156
|
+
app.post(`${BASE}/sql/commands`, sqlWorkshop);
|
|
157
|
+
// (before sprint 61 SQL Commands was /builder/sql: a post there still runs)
|
|
73
158
|
app.post(`${BASE}/sql`, sqlWorkshop);
|
|
159
|
+
app.get(`${BASE}/sql`, workshopHome);
|
|
160
|
+
|
|
161
|
+
// (sprint 61) the session's default schema: first in the search path of SQL Commands and SQL Scripts
|
|
162
|
+
app.post(`${BASE}/sql/schema`, async (req: Req, reply) => {
|
|
163
|
+
const s = await developer(req, reply);
|
|
164
|
+
if (!s) return;
|
|
165
|
+
const wanted = String(req.body?.schema ?? '');
|
|
166
|
+
const usable = await usableSchemas(await workshopDb(s));
|
|
167
|
+
if (wanted && !usable.includes(wanted)) return reply.code(404).send('Not found');
|
|
168
|
+
if (wanted) s.state.__SQLSCHEMA = wanted;
|
|
169
|
+
else delete s.state.__SQLSCHEMA;
|
|
170
|
+
flash(s, wanted ? `Schema ${wanted} first in the search path of SQL Commands and SQL Scripts.` : 'The default search path again.');
|
|
171
|
+
return back(reply, s, `${BASE}/sql`);
|
|
172
|
+
});
|
|
74
173
|
|
|
75
174
|
app.get(`${BASE}/sql/objects`, async (req: Req, reply) => {
|
|
76
175
|
const s = await developer(req, reply);
|
package/src/builder/ui.ts
CHANGED
|
@@ -43,7 +43,7 @@ export async function send(reply: FastifyReply, s: Session, body: string) {
|
|
|
43
43
|
return reply.type('text/html').send(body);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export type Section = 'apps' | 'users' | 'sql' | 'developers';
|
|
46
|
+
export type Section = 'apps' | 'users' | 'sql' | 'developers' | 'gallery';
|
|
47
47
|
|
|
48
48
|
// ------------------------------------------------------------------ builder head
|
|
49
49
|
// Builder-only stylesheets and scripts, loaded on every builder page after
|
|
@@ -87,8 +87,8 @@ export interface ShellOpts {
|
|
|
87
87
|
const initials = (name: string) => name.replace(/[^\p{L}\p{N}]+/gu, ' ').trim().split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase() || '?';
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* A builder page: the icon rail on the left (App Builder, SQL Workshop,
|
|
91
|
-
*
|
|
90
|
+
* A builder page: the icon rail on the left (App Builder, SQL Workshop, Search;
|
|
91
|
+
* Administration, Help, the developer's menu), an environment banner (091), a compact toolbar with
|
|
92
92
|
* back button and breadcrumb, then the page itself.
|
|
93
93
|
*/
|
|
94
94
|
export function shell(s: Session, title: string, crumbs: [string, string?][], main: Raw, section: Section = 'apps', opts: ShellOpts = {}) {
|
|
@@ -110,11 +110,30 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
110
110
|
<ul class="rail-list">
|
|
111
111
|
${rail('apps', BASE, 'builder', 'App Builder')}
|
|
112
112
|
${rail('sql', `${BASE}/sql`, 'database', 'SQL Workshop')}
|
|
113
|
-
${rail('
|
|
114
|
-
${rail('developers', `${BASE}/developers`, 'users', 'Developers')}
|
|
113
|
+
${rail('gallery', `${BASE}/gallery`, 'package', 'App Gallery')}
|
|
115
114
|
${appId ? rail('search', `${BASE}/apps/${appId}/search`, 'search', 'Search this application') : ''}
|
|
116
115
|
</ul>
|
|
117
116
|
<ul class="rail-list rail-bottom">
|
|
117
|
+
<li><details class="menu rail-user rail-admin">
|
|
118
|
+
<summary class="rail-link${section === 'users' || section === 'developers' ? ' rail-open' : ''}" title="Administration"${section === 'users' || section === 'developers' ? raw(' aria-current="page"') : ''}>${icon('settings')}<span class="rail-label">Administration</span></summary>
|
|
119
|
+
<div class="menu-panel">
|
|
120
|
+
<div class="menu-section menu-links">
|
|
121
|
+
<strong>Administration</strong>
|
|
122
|
+
<a href="${BASE}/utilities">${icon('grid')} Workspace utilities</a>
|
|
123
|
+
</div>
|
|
124
|
+
${s.workspace?.admin ? html`<div class="menu-section menu-links">
|
|
125
|
+
<span class="small muted">Manage service: ${s.workspace.name}</span>
|
|
126
|
+
<a href="${BASE}/workspaces/${s.workspace.id}#service">${icon('bell')} Workspace message and environment banner</a>
|
|
127
|
+
<a href="${BASE}/workspaces/${s.workspace.id}/utilization">${icon('chart')} Workspace utilization</a>
|
|
128
|
+
<a href="${BASE}/workspaces/${s.workspace.id}">${icon('layers')} Workspace and developers</a>
|
|
129
|
+
</div>` : ''}
|
|
130
|
+
<div class="menu-section menu-links">
|
|
131
|
+
<a href="${BASE}/users">${icon('user')} Manage users and groups</a>
|
|
132
|
+
<a href="${BASE}/developers">${icon('users')} Developers</a>
|
|
133
|
+
<a href="${BASE}/dashboard">${icon('activity')} Monitor activity</a>
|
|
134
|
+
<a href="${BASE}/developers">${icon('key')} Change my password</a>
|
|
135
|
+
</div>
|
|
136
|
+
</div></details></li>
|
|
118
137
|
${rail('help', 'https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md', 'help', 'Help', raw(' target="_blank" rel="noopener"'))}
|
|
119
138
|
<li><details class="menu rail-user">
|
|
120
139
|
<summary class="rail-avatar" title="${s.username}"><span aria-hidden="true">${initials(s.username ?? '')}</span><span class="sr-only">Account: ${s.username}</span></summary>
|
|
@@ -139,6 +158,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
139
158
|
html`<li>${href && i < crumbs.length - 1 ? html`<a href="${href}">${label}</a>` : html`<span aria-current="page">${label}</span>`}</li>`)}</ol></nav>
|
|
140
159
|
<div class="ide-tools">${opts.toolbar ?? ''}</div>
|
|
141
160
|
</header>
|
|
161
|
+
${s.workspace?.banner_label ? html`<div class="ide-banner ide-banner-${BANNER_COLORS.includes(s.workspace.banner_color ?? '') ? s.workspace.banner_color : 'blue'}" role="note" aria-label="Environment">${s.workspace.banner_label}</div>` : ''}
|
|
142
162
|
${s.state.__WEAK || ok || err ? html`<div class="ide-alerts">
|
|
143
163
|
${s.state.__WEAK ? html`<div class="alert alert-error" role="alert">You signed in with the default password. <a href="${BASE}/developers">Change it now.</a></div>` : ''}
|
|
144
164
|
${ok ? html`<div class="alert alert-success" role="status">${ok}</div>` : ''}
|
|
@@ -162,6 +182,9 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
162
182
|
);
|
|
163
183
|
}
|
|
164
184
|
|
|
185
|
+
/** (091) the colours of a workspace's environment banner (builder.css draws them) */
|
|
186
|
+
export const BANNER_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'gray'];
|
|
187
|
+
|
|
165
188
|
export const csrf = (s: Session) => html`<input type="hidden" name="__csrf" value="${s.csrf_token}">`;
|
|
166
189
|
|
|
167
190
|
export function input(name: string, label: string, value: unknown, opts: { type?: string; help?: string; required?: boolean; placeholder?: string; auto?: string } = {}) {
|
|
@@ -209,12 +232,12 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
|
|
|
209
232
|
};
|
|
210
233
|
|
|
211
234
|
/** Tabs of the SQL Workshop. */
|
|
212
|
-
export type WorkshopTab = 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare';
|
|
235
|
+
export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare';
|
|
213
236
|
export const workshopTabs = (active: WorkshopTab) => {
|
|
214
237
|
const tab = (key: WorkshopTab, href: string, ic: string, label: string) =>
|
|
215
238
|
html`<a class="ide-tab" href="${BASE}${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
|
|
216
239
|
return html`<nav class="ide-tabs u-mb1" aria-label="SQL Workshop">
|
|
217
|
-
${tab('sql', '/sql', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('quicksql', '/sql/quick', 'bolt', 'Quick SQL')}
|
|
240
|
+
${tab('home', '/sql', 'home', 'Home')}${tab('sql', '/sql/commands', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('quicksql', '/sql/quick', 'bolt', 'Quick SQL')}
|
|
218
241
|
${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ddl', '/sql/ddl', 'file', 'DDL')}${tab('compare', '/sql/compare', 'layers', 'Compare')}${tab('ai', '/sql/ai', 'bolt', 'AI')}</nav>`;
|
|
219
242
|
};
|
|
220
243
|
|