pgkiln 0.84.0 → 0.86.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/db/migrations/155_mail_available.sql +23 -0
- package/docs/apex-feature-parity.md +2 -2
- package/docs/guide/03-builder.md +14 -5
- package/docs/guide/04-pages-and-regions.md +1 -1
- package/docs/guide/06-processing.md +7 -3
- package/docs/guide/07-dynamic-actions.md +3 -3
- package/docs/guide/09-reference.md +1 -0
- package/docs/guide/12-development.md +10 -1
- package/examples/gallery/README.md +23 -6
- package/examples/gallery/gallery.json +4 -0
- package/examples/gallery/samples/sample-approvals.sql +102 -11
- package/examples/gallery/samples/sample-calendar.sql +40 -5
- package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
- package/examples/gallery/samples/sample-grids.sql +35 -3
- package/examples/gallery/samples/sample-maps.sql +65 -7
- package/examples/gallery/samples/sample-processes.sql +466 -0
- package/examples/gallery/samples/sample-validations.sql +407 -0
- package/examples/gallery/samples/ux-patterns.sql +154 -14
- package/examples/hr/README.md +2 -0
- package/examples/hr/hr_80_notifications.sql +125 -0
- package/examples/hr/hr_81_vector_search_type.sql +33 -0
- package/package.json +1 -1
- package/public/app.css +8 -1
- package/public/app.js +28 -9
- package/src/app.ts +9 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3453 -0
- package/src/builder/i18n/en.ts +3453 -0
- package/src/builder/i18n/es.ts +3453 -0
- package/src/builder/i18n/fr.ts +3453 -0
- package/src/builder/i18n/it.ts +3453 -0
- package/src/builder/i18n/ja.ts +3453 -0
- package/src/builder/i18n/ko.ts +3453 -0
- package/src/builder/i18n/nl.ts +3453 -0
- package/src/builder/i18n/pt.ts +3453 -0
- package/src/builder/i18n/zh-cn.ts +3453 -0
- package/src/builder/i18n/zh-tw.ts +3453 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +27 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/runtime/engine.ts +6 -3
- package/src/runtime/lists.ts +2 -2
- package/src/runtime/push.ts +7 -1
- package/src/runtime/regions.ts +9 -4
package/src/builder/ai.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { encryptSecret, secretKeyConfigured } from '../secrets.ts';
|
|
|
10
10
|
import type { Session } from '../session.ts';
|
|
11
11
|
import { isAdmin } from './locks.ts';
|
|
12
12
|
import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
13
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
13
14
|
|
|
14
15
|
// Workspace utilities → AI services (administrators): the AI services of
|
|
15
16
|
// this installation (Claude, OpenAI, Gemini or an OpenAI-compatible endpoint,
|
|
@@ -18,8 +19,8 @@ import { back, BASE, csrf, developer, flash, input, region, select, send, shell,
|
|
|
18
19
|
// usage log. Applications → Activity → AI usage (developers): what an application may use and what it used.
|
|
19
20
|
// API keys are encrypted (src/secrets.ts) and never sent back to the browser.
|
|
20
21
|
|
|
21
|
-
const PROVIDERS: [string, string][]
|
|
22
|
-
const EFFORTS: [string, string][]
|
|
22
|
+
const PROVIDERS = (): [string, string][] => [['anthropic', tx('aisv.claude_anthropic')], ['openai', tx('aisv.openai')], ['gemini', tx('aisv.gemini_google_openai_compatible_endpoint')], ['openai_compatible', tx('aisv.openai_compatible_ollama_vllm_azure')]];
|
|
23
|
+
const EFFORTS = (): [string, string][] => [['', tx('aisv.model_default')], ['low', 'low'], ['medium', 'medium'], ['high', 'high'], ['xhigh', 'xhigh'], ['max', 'max']];
|
|
23
24
|
const TEST_RESULT = '__AITEST';
|
|
24
25
|
|
|
25
26
|
const check = (name: string, label: string, on: boolean, help?: string) =>
|
|
@@ -32,25 +33,25 @@ const KEY_TEXT = { stored: 'stored (encrypted)', env: 'server default', missing:
|
|
|
32
33
|
function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean) {
|
|
33
34
|
const source = s.provider ? keySource(s as AiService) : null;
|
|
34
35
|
return html`<form method="post" action="${action}">${csrfField}
|
|
35
|
-
<fieldset class="prop-group"><legend
|
|
36
|
-
${isNew ? input('name', '
|
|
37
|
-
${input('description', '
|
|
38
|
-
${select('provider', '
|
|
39
|
-
${input('model', '
|
|
40
|
-
${select('effort', '
|
|
41
|
-
${input('max_tokens', '
|
|
42
|
-
${input('timeout_s', '
|
|
43
|
-
${input('base_url', '
|
|
36
|
+
<fieldset class="prop-group"><legend>${tx('aisv.service')}</legend><div class="form-grid">
|
|
37
|
+
${isNew ? input('name', tx('aisv.name'), '', { required: true, placeholder: tx('aisv.e_g_claude'), help: tx('aisv.upper_case_letters_digits_and') }) : ''}
|
|
38
|
+
${input('description', tx('aisv.description'), s.description)}
|
|
39
|
+
${select('provider', tx('aisv.provider'), s.provider ?? 'anthropic', PROVIDERS(), tx('aisv.prompts_and_the_item_values'))}
|
|
40
|
+
${input('model', tx('aisv.model'), s.model ?? (isNew ? DEFAULT_CLAUDE_MODEL : ''), { required: true, help: tx('aisv.model_help', { model: DEFAULT_CLAUDE_MODEL }) })}
|
|
41
|
+
${select('effort', tx('aisv.effort_claude'), isNew ? 'medium' : s.effort ?? '', EFFORTS(), tx('aisv.how_deeply_claude_thinks_thinking'))}
|
|
42
|
+
${input('max_tokens', tx('aisv.maximum_output_tokens'), s.max_tokens ?? 4000, { type: 'number', help: tx('aisv.per_request_1_128000_a') })}
|
|
43
|
+
${input('timeout_s', tx('aisv.time_limit_seconds'), s.timeout_s ?? 120, { type: 'number', help: tx('aisv.5_600_pages_wait_for') })}
|
|
44
|
+
${input('base_url', tx('aisv.base_url'), s.base_url, { placeholder: tx('aisv.empty_the_provider_s_own'), help: `A gateway or proxy that speaks the provider's API. Administrators only. Gemini: empty is ${GEMINI_BASE_URL}. OpenAI-compatible: required, e.g. http://localhost:11434/v1 (Ollama), https://<resource>.openai.azure.com/openai/v1/ (Azure OpenAI).` })}
|
|
44
45
|
</div>
|
|
45
|
-
${check('refusal_fallback', '
|
|
46
|
-
${check('enabled', '
|
|
46
|
+
${check('refusal_fallback', tx('aisv.refusal_fallbacks_claude'), s.refusal_fallback !== false, tx('aisv.when_claude_declines_a_request'))}
|
|
47
|
+
${check('enabled', tx('aisv.enabled'), s.enabled !== false)}
|
|
47
48
|
</fieldset>
|
|
48
|
-
<fieldset class="prop-group"><legend
|
|
49
|
-
${input('api_key', '
|
|
50
|
-
${source === 'stored' ? check('remove_key', '
|
|
51
|
-
${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert"
|
|
49
|
+
<fieldset class="prop-group"><legend>${tx('aisv.api_key')}</legend>
|
|
50
|
+
${input('api_key', tx('aisv.api_key'), '', { type: 'password', auto: 'new-password', help: source === 'stored' ? 'A key is stored (encrypted). It is never shown; type a new one to replace it.' : `Empty: the server's ${envKeyName((s.provider as ProviderName) ?? 'anthropic')} is used (when set)${s.provider === 'openai_compatible' ? '; an endpoint without keys (Ollama) needs none' : ''}.` })}
|
|
51
|
+
${source === 'stored' ? check('remove_key', tx('aisv.remove_the_stored_key_use'), false) : ''}
|
|
52
|
+
${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">${tx.code('aisv.the_server_has_no_pgkiln')}</div>`}
|
|
52
53
|
</fieldset>
|
|
53
|
-
<div class="buttons"><button class="btn btn-hot">${isNew ? '
|
|
54
|
+
<div class="buttons"><button class="btn btn-hot">${isNew ? tx('aisv.add_service') : tx('aisv.save')}</button></div>
|
|
54
55
|
</form>`;
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -58,33 +59,33 @@ function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnTyp
|
|
|
58
59
|
function formValues(b: Record<string, string | undefined>, hasServer = false) {
|
|
59
60
|
const provider: ProviderName = (['openai', 'gemini', 'openai_compatible'] as const).find((p) => p === b.provider) ?? 'anthropic';
|
|
60
61
|
const model = (b.model ?? '').trim();
|
|
61
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/.test(model)) throw new Error('
|
|
62
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/.test(model)) throw new Error(tx('aisv.the_model_is_a_name'));
|
|
62
63
|
const maxTokens = Number(b.max_tokens || 4000);
|
|
63
|
-
if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 128000) throw new Error('
|
|
64
|
+
if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 128000) throw new Error(tx('aisv.maximum_output_tokens_1_to'));
|
|
64
65
|
const timeout = Number(b.timeout_s || 120);
|
|
65
|
-
if (!Number.isInteger(timeout) || timeout < 5 || timeout > 600) throw new Error('
|
|
66
|
+
if (!Number.isInteger(timeout) || timeout < 5 || timeout > 600) throw new Error(tx('aisv.time_limit_5_to_600'));
|
|
66
67
|
const baseUrl = (b.base_url ?? '').trim() || null;
|
|
67
|
-
if (baseUrl && (!/^https?:\/\/[^\s]+$/i.test(baseUrl) || baseUrl.length > 500)) throw new Error('
|
|
68
|
-
if (!baseUrl && provider === 'openai_compatible' && !hasServer) throw new Error('
|
|
68
|
+
if (baseUrl && (!/^https?:\/\/[^\s]+$/i.test(baseUrl) || baseUrl.length > 500)) throw new Error(tx('aisv.the_base_url_starts_with'));
|
|
69
|
+
if (!baseUrl && provider === 'openai_compatible' && !hasServer) throw new Error(tx('aisv.an_openai_compatible_service_needs_2'));
|
|
69
70
|
const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort ?? '') ? b.effort! : null;
|
|
70
71
|
return [(b.description ?? '').trim() || null, provider, model, provider === 'anthropic' ? effort : null, b.refusal_fallback === 'true', maxTokens, timeout, baseUrl, b.enabled === 'true'];
|
|
71
72
|
}
|
|
72
73
|
const COLUMNS = 'description, provider, model, effort, refusal_fallback, max_tokens, timeout_s, base_url, enabled';
|
|
73
74
|
|
|
74
75
|
const usageTable = (rows: any[], withApp: boolean) => html`<div class="table-wrap"><table class="report report-reflow">
|
|
75
|
-
<thead><tr><th
|
|
76
|
+
<thead><tr><th>${tx('aisv.when_utc')}</th>${withApp ? html`<th>${tx('aisv.application')}</th>` : ''}<th class="num">${tx('aisv.page')}</th><th>${tx('aisv.user')}</th><th>${tx('aisv.service')}</th><th>${tx('aisv.model')}</th><th>${tx('aisv.source')}</th><th class="num">${tx('aisv.input')}</th><th class="num">${tx('aisv.output')}</th><th class="num">ms</th><th>${tx('aisv.status')}</th></tr></thead>
|
|
76
77
|
<tbody>${rows.length ? rows.map((u) => html`<tr>
|
|
77
|
-
<td data-label="
|
|
78
|
-
<td class="num" data-label="
|
|
79
|
-
<td data-label="
|
|
80
|
-
<td class="num" data-label="
|
|
81
|
-
<td data-label="
|
|
82
|
-
: html`<tr><td colspan="${withApp ? 11 : 10}" class="empty"
|
|
78
|
+
<td data-label="${tx('aisv.when')}">${when(u.at)}</td>${withApp ? html`<td data-label="${tx('aisv.application')}">${u.app ?? html`<span class="muted">builder</span>`}</td>` : ''}
|
|
79
|
+
<td class="num" data-label="${tx('aisv.page')}">${u.page_no ?? ''}</td><td data-label="${tx('aisv.user')}">${u.username ?? ''}</td><td data-label="${tx('aisv.service')}">${u.service}</td>
|
|
80
|
+
<td data-label="${tx('aisv.model')}">${u.model}</td><td data-label="${tx('aisv.source')}">${u.source}</td>
|
|
81
|
+
<td class="num" data-label="${tx('aisv.input')}">${u.input_tokens}</td><td class="num" data-label="${tx('aisv.output')}">${u.output_tokens}</td><td class="num" data-label="ms">${u.duration_ms}</td>
|
|
82
|
+
<td data-label="${tx('aisv.status')}"><span class="tag${u.status === 'ok' ? ' tag-ok' : ' tag-error'}">${u.status}</span>${u.message ? html` <span class="muted">${u.message}</span>` : ''}</td></tr>`)
|
|
83
|
+
: html`<tr><td colspan="${withApp ? 11 : 10}" class="empty">${tx('aisv.no_ai_requests_yet')}</td></tr>`}</tbody></table></div>`;
|
|
83
84
|
|
|
84
85
|
async function adminOnly(s: Session, reply: any, title: string) {
|
|
85
86
|
if (await isAdmin(s.username)) return true;
|
|
86
|
-
const main = html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert"
|
|
87
|
-
await send(reply.code(403), s, shell(s, title, [['
|
|
87
|
+
const main = html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">${tx('aisv.only_administrators_configure_ai_services')}</div>`;
|
|
88
|
+
await send(reply.code(403), s, shell(s, title, [[tx('aisv.app_builder'), BASE], [tx('aisv.workspace_utilities'), `${BASE}/utilities`], [title]], main));
|
|
88
89
|
return false;
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -100,27 +101,27 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
100
101
|
group by 1, 2 order by 1 nulls first, 2`)).rows;
|
|
101
102
|
const recent = (await owner.query(`select u.*, a.alias as app from meta.ai_usage u left join meta.app a on a.id = u.app_id order by u.id desc limit 100`)).rows;
|
|
102
103
|
const main = html`
|
|
103
|
-
<div class="title-row"><h1
|
|
104
|
-
<p class="muted u-mt0"
|
|
104
|
+
<div class="title-row"><h1>${tx('aisv.ai_services')}</h1></div>
|
|
105
|
+
<p class="muted u-mt0">${tx.rich('aisv.intro', { process: html`<b>${tx('aisv.generate_text_with_ai')}</b>`, fn: html`<code>meta.ai_generate()</code>` })}</p>
|
|
105
106
|
<div class="columns wide-left">
|
|
106
|
-
${region('
|
|
107
|
-
<thead><tr><th
|
|
107
|
+
${region(tx('aisv.services'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
108
|
+
<thead><tr><th>${tx('aisv.service')}</th><th>${tx('aisv.provider')}</th><th>${tx('aisv.model')}</th><th>${tx('aisv.api_key')}</th><th>${tx('aisv.applications')}</th><th>${tx('aisv.status')}</th></tr></thead>
|
|
108
109
|
<tbody>${services.length ? services.map((x) => html`<tr>
|
|
109
|
-
<td data-label="
|
|
110
|
-
<td data-label="
|
|
111
|
-
<td data-label="
|
|
112
|
-
<td data-label="
|
|
113
|
-
<td data-label="
|
|
114
|
-
<td data-label="
|
|
115
|
-
: html`<tr><td colspan="6" class="empty"
|
|
116
|
-
${region('
|
|
110
|
+
<td data-label="${tx('aisv.service')}"><a href="${BASE}/ai/${x.id}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
|
|
111
|
+
<td data-label="${tx('aisv.provider')}">${providerLabel(x.provider)}</td>
|
|
112
|
+
<td data-label="${tx('aisv.model')}">${x.model}${x.effort ? html` <span class="muted">(${x.effort})</span>` : ''}</td>
|
|
113
|
+
<td data-label="${tx('aisv.api_key')}">${KEY_TEXT[keySource(x)]}</td>
|
|
114
|
+
<td data-label="${tx('aisv.applications')}">${x.apps ?? html`<span class="muted">none</span>`}</td>
|
|
115
|
+
<td data-label="${tx('aisv.status')}">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
|
|
116
|
+
: html`<tr><td colspan="6" class="empty">${tx('aisv.no_ai_services_yet')}</td></tr>`}</tbody></table></div>`)}
|
|
117
|
+
${region(tx('aisv.add_service'), serviceForm({}, `${BASE}/ai`, csrf(s), true))}
|
|
117
118
|
</div>
|
|
118
|
-
${region('
|
|
119
|
-
<tbody>${summary.length ? summary.map((r) => html`<tr><td data-label="
|
|
120
|
-
<td class="num" data-label="
|
|
121
|
-
: html`<tr><td colspan="6" class="empty"
|
|
122
|
-
${region('
|
|
123
|
-
return send(reply, s, shell(s, '
|
|
119
|
+
${region(tx('aisv.usage_30_days'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('aisv.application')}</th><th>${tx('aisv.service')}</th><th class="num">${tx('aisv.requests')}</th><th class="num">${tx('aisv.input_tokens')}</th><th class="num">${tx('aisv.output_tokens')}</th><th class="num">${tx('aisv.not_ok')}</th></tr></thead>
|
|
120
|
+
<tbody>${summary.length ? summary.map((r) => html`<tr><td data-label="${tx('aisv.application')}">${r.app ?? html`<span class="muted">${tx('aisv.builder_tests')}</span>`}</td><td data-label="${tx('aisv.service')}">${r.service}</td><td class="num" data-label="${tx('aisv.requests')}">${r.requests}</td>
|
|
121
|
+
<td class="num" data-label="${tx('aisv.input_tokens')}">${r.input}</td><td class="num" data-label="${tx('aisv.output_tokens')}">${r.output}</td><td class="num" data-label="${tx('aisv.not_ok')}">${r.problems}</td></tr>`)
|
|
122
|
+
: html`<tr><td colspan="6" class="empty">${tx('aisv.no_ai_requests_in_the')}</td></tr>`}</tbody></table></div>`)}
|
|
123
|
+
${region(tx('aisv.recent_requests'), html`<p class="muted u-mt0">${tx('aisv.tokens_model_duration_and_status')}</p>${usageTable(recent, true)}`)}`;
|
|
124
|
+
return send(reply, s, shell(s, tx('aisv.ai_services'), [[tx('aisv.app_builder'), BASE], [tx('aisv.workspace_utilities'), `${BASE}/utilities`], [tx('aisv.ai_services')]], main));
|
|
124
125
|
});
|
|
125
126
|
|
|
126
127
|
app.post(`${BASE}/ai`, async (req: Req, reply) => {
|
|
@@ -129,10 +130,10 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
129
130
|
const b = req.body ?? {};
|
|
130
131
|
try {
|
|
131
132
|
const name = (b.name ?? '').trim().toUpperCase();
|
|
132
|
-
if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(name)) throw new Error('
|
|
133
|
+
if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(name)) throw new Error(tx('aisv.the_name_is_upper_case'));
|
|
133
134
|
const key = b.api_key ? encryptSecret(b.api_key) : null;
|
|
134
135
|
const r = await owner.one(`insert into meta.ai_service (name, ${COLUMNS}, api_key_enc) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) returning id`, [name, ...formValues(b), key]);
|
|
135
|
-
flash(s, '
|
|
136
|
+
flash(s, tx('aisv.ai_service_added_allow_applications'));
|
|
136
137
|
return back(reply, s, `${BASE}/ai/${r.id}`);
|
|
137
138
|
} catch (e) {
|
|
138
139
|
flash(s, (e as Error).message.includes('ai_service_name_key') ? 'A service with this name exists already.' : (e as Error).message, 'error');
|
|
@@ -163,33 +164,33 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
163
164
|
const main = html`
|
|
164
165
|
<div class="title-row"><h1 class="ai-name">${x.name}</h1></div>
|
|
165
166
|
<div class="columns wide-left">
|
|
166
|
-
${region('
|
|
167
|
+
${region(tx('aisv.settings'), html`${serviceForm(view, `${BASE}/ai/${x.id}`, csrf(s), false)}
|
|
167
168
|
<form method="post" action="${BASE}/ai/${x.id}/delete" class="danger-zone">${csrf(s)}
|
|
168
|
-
<button class="btn btn-danger" data-confirm="
|
|
169
|
-
${region('
|
|
170
|
-
<p class="muted u-mt0"
|
|
171
|
-
<div class="form-grid">${select('server_id', '
|
|
172
|
-
${input('server_path', '
|
|
173
|
-
<div class="buttons"><button class="btn"
|
|
174
|
-
${region('
|
|
175
|
-
<p class="muted u-mt0"
|
|
169
|
+
<button class="btn btn-danger" data-confirm="${tx('aisv.delete_confirm', { name: x.name })}">${tx('aisv.delete_service')}</button></form>`)}
|
|
170
|
+
${region(tx('aisv.remote_server'), html`<form method="post" action="${BASE}/ai/${x.id}/server">${csrf(s)}
|
|
171
|
+
<p class="muted u-mt0">${tx('aisv.instead_of_the_base_url')}</p>
|
|
172
|
+
<div class="form-grid">${select('server_id', tx('aisv.remote_server'), x.server_id ? String(x.server_id) : '', [['', tx('aisv.none_the_base_url_above')], ...servers.map((r): [string, string] => [String(r.id), `${r.static_id} (${r.workspace})`])])}
|
|
173
|
+
${input('server_path', tx('aisv.path'), x.server_path ?? '', { placeholder: '/v1', help: tx('aisv.after_the_server_s_base') })}</div>
|
|
174
|
+
<div class="buttons"><button class="btn">${tx('aisv.save')}</button></div></form>`)}
|
|
175
|
+
${region(tx('aisv.test'), html`
|
|
176
|
+
<p class="muted u-mt0">${tx('aisv.sends_a_prompt_to_the')}</p>
|
|
176
177
|
<form method="post" action="${BASE}/ai/${x.id}/test">${csrf(s)}
|
|
177
|
-
<div class="field"><label class="label" for="ai_test_prompt"
|
|
178
|
-
<div class="buttons"><button class="btn">${icon('play')}
|
|
178
|
+
<div class="field"><label class="label" for="ai_test_prompt">${tx('aisv.prompt')}</label><textarea id="ai_test_prompt" name="prompt" rows="3" maxlength="4000">${tx('aisv.test_prompt')}</textarea></div>
|
|
179
|
+
<div class="buttons"><button class="btn">${icon('play')} ${tx('aisv.test')}</button></div></form>
|
|
179
180
|
${test ? html`<div class="alert ${test.ok ? 'alert-success' : 'alert-error'}" role="status">${test.meta ?? ''}</div><pre class="source">${test.text}</pre>` : ''}`)}
|
|
180
181
|
</div>
|
|
181
|
-
${region('
|
|
182
|
+
${region(tx('aisv.applications'), html`<p class="muted u-mt0">${tx.rich('aisv.apps_intro', { limited: html`<i>${tx('aisv.limited')}</i>` })}</p>
|
|
182
183
|
<form method="post" action="${BASE}/ai/${x.id}/apps">${csrf(s)}
|
|
183
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
184
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('aisv.application')}</th><th>${tx('aisv.may_use')}</th><th>${tx('aisv.requests_per_day')}</th><th>${tx('aisv.tokens_per_day')}</th></tr></thead>
|
|
184
185
|
<tbody>${apps.length ? apps.map((a) => html`<tr>
|
|
185
|
-
<td data-label="
|
|
186
|
-
<td data-label="
|
|
187
|
-
<td data-label="
|
|
188
|
-
<td data-label="
|
|
189
|
-
: html`<tr><td colspan="4" class="empty"
|
|
190
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
191
|
-
${region('
|
|
192
|
-
return send(reply, s, shell(s, x.name, [['
|
|
186
|
+
<td data-label="${tx('aisv.application')}">${a.name} <span class="muted">${a.alias}</span></td>
|
|
187
|
+
<td data-label="${tx('aisv.may_use')}"><label class="check"><input type="checkbox" name="allow" value="${a.id}"${a.allowed ? raw(' checked') : ''}> allowed</label></td>
|
|
188
|
+
<td data-label="${tx('aisv.requests_per_day')}"><input type="number" min="0" name="req_${a.id}" value="${a.max_requests ?? ''}" aria-label="${tx('aisv.aria_req', { app: a.alias })}"></td>
|
|
189
|
+
<td data-label="${tx('aisv.tokens_per_day')}"><input type="number" min="0" name="tok_${a.id}" value="${a.max_tokens ?? ''}" aria-label="${tx('aisv.aria_tok', { app: a.alias })}"></td></tr>`)
|
|
190
|
+
: html`<tr><td colspan="4" class="empty">${tx('aisv.no_applications')}</td></tr>`}</tbody></table></div>
|
|
191
|
+
<div class="buttons"><button class="btn btn-hot">${tx('aisv.save_access')}</button></div></form>`)}
|
|
192
|
+
${region(tx('aisv.recent_requests'), usageTable(recent, true))}`;
|
|
193
|
+
return send(reply, s, shell(s, x.name, [[tx('aisv.app_builder'), BASE], [tx('aisv.workspace_utilities'), `${BASE}/utilities`], [tx('aisv.ai_services'), `${BASE}/ai`], [x.name]], main));
|
|
193
194
|
});
|
|
194
195
|
|
|
195
196
|
app.post(`${BASE}/ai/:id(^\\d+$)`, async (req: Req, reply) => {
|
|
@@ -204,7 +205,7 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
204
205
|
where id = $1`,
|
|
205
206
|
[req.params.id, ...formValues(b, !!(await owner.one('select 1 as ok from meta.ai_service where id = $1 and server_id is not null', [req.params.id]))), key, b.remove_key === 'true'],
|
|
206
207
|
);
|
|
207
|
-
flash(s, r.rowCount ? 'AI service saved.' : '
|
|
208
|
+
flash(s, r.rowCount ? 'AI service saved.' : tx('aisv.not_found'), r.rowCount ? 'ok' : 'error');
|
|
208
209
|
} catch (e) {
|
|
209
210
|
flash(s, (e as Error).message, 'error');
|
|
210
211
|
}
|
|
@@ -219,12 +220,12 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
219
220
|
try {
|
|
220
221
|
const serverId = /^\d{1,9}$/.test(String(b.server_id ?? '')) ? Number(b.server_id) : null;
|
|
221
222
|
const path = String(b.server_path ?? '').trim() || null;
|
|
222
|
-
if (path && (!/^\/[^\s]*$/.test(path) || path.length > 500)) throw new Error('
|
|
223
|
-
if (serverId && !(await owner.one('select 1 as ok from meta.remote_server where id = $1', [serverId]))) throw new Error('
|
|
223
|
+
if (path && (!/^\/[^\s]*$/.test(path) || path.length > 500)) throw new Error(tx('aisv.the_path_starts_with_without'));
|
|
224
|
+
if (serverId && !(await owner.one('select 1 as ok from meta.remote_server where id = $1', [serverId]))) throw new Error(tx('aisv.no_such_remote_server'));
|
|
224
225
|
const r = await owner.query('update meta.ai_service set server_id = $2, server_path = $3, updated_at = now() where id = $1', [req.params.id, serverId, serverId ? path : null]);
|
|
225
|
-
flash(s, r.rowCount ? (serverId ? '
|
|
226
|
+
flash(s, r.rowCount ? (serverId ? tx('aisv.the_service_now_calls_its') : tx('aisv.the_service_calls_its_base')) : tx('aisv.not_found'), r.rowCount ? 'ok' : 'error');
|
|
226
227
|
} catch (e) {
|
|
227
|
-
flash(s, (e as Error).message.includes('ai_service_compatible_url') ? '
|
|
228
|
+
flash(s, (e as Error).message.includes('ai_service_compatible_url') ? tx('aisv.an_openai_compatible_service_needs') : (e as Error).message, 'error');
|
|
228
229
|
}
|
|
229
230
|
return back(reply, s, `${BASE}/ai/${req.params.id}`);
|
|
230
231
|
});
|
|
@@ -238,7 +239,7 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
238
239
|
const t = String(v ?? '').trim();
|
|
239
240
|
if (!t) return null;
|
|
240
241
|
const n = Number(t);
|
|
241
|
-
if (!Number.isInteger(n) || n < 0) throw new Error('
|
|
242
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(tx('aisv.limits_are_whole_numbers_of'));
|
|
242
243
|
return n;
|
|
243
244
|
};
|
|
244
245
|
try {
|
|
@@ -252,7 +253,7 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
252
253
|
[id, req.params.id, limit(b[`req_${id}`]), limit(b[`tok_${id}`])],
|
|
253
254
|
);
|
|
254
255
|
});
|
|
255
|
-
flash(s, '
|
|
256
|
+
flash(s, tx('aisv.access_saved'));
|
|
256
257
|
} catch (e) {
|
|
257
258
|
flash(s, (e as Error).message, 'error');
|
|
258
259
|
}
|
|
@@ -269,9 +270,9 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
269
270
|
const prompt = String(req.body?.prompt ?? '').slice(0, 4000) || 'Reply with one short sentence.';
|
|
270
271
|
const r = await generate(x.name, { prompt, maxTokens: 1000 }, { appId: null, user: s.username, source: 'builder' });
|
|
271
272
|
const text = r.text.length > 4000 ? `${r.text.slice(0, 4000)}…` : r.text;
|
|
272
|
-
result = { id: x.id, ok: true, text, meta:
|
|
273
|
+
result = { id: x.id, ok: true, text, meta: tx('aisv.answered_by', { model: r.model, input: r.inputTokens, output: r.outputTokens }) };
|
|
273
274
|
} catch (e) {
|
|
274
|
-
result = { id: x.id, ok: false, text: '', meta: e instanceof AiError ? e.message : '
|
|
275
|
+
result = { id: x.id, ok: false, text: '', meta: e instanceof AiError ? e.message : tx('aisv.the_test_failed') };
|
|
275
276
|
}
|
|
276
277
|
s.state[TEST_RESULT] = JSON.stringify(result);
|
|
277
278
|
return back(reply, s, `${BASE}/ai/${x.id}`);
|
|
@@ -281,7 +282,7 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
281
282
|
const s = await developer(req, reply);
|
|
282
283
|
if (!s || !(await adminOnly(s, reply, 'AI services'))) return;
|
|
283
284
|
await owner.query('delete from meta.ai_service where id = $1', [req.params.id]);
|
|
284
|
-
flash(s, '
|
|
285
|
+
flash(s, tx('aisv.ai_service_deleted'));
|
|
285
286
|
return back(reply, s, `${BASE}/ai`);
|
|
286
287
|
});
|
|
287
288
|
|
|
@@ -298,15 +299,15 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
298
299
|
const admin = await isAdmin(s.username);
|
|
299
300
|
const main = html`
|
|
300
301
|
<div class="title-row"><h1 class="ai-name">AI usage · ${a.name}</h1></div>
|
|
301
|
-
<p class="muted u-mt0">The AI services this application may use (an administrator allows them${admin ? html` under <a href="${BASE}/ai"
|
|
302
|
-
${region('
|
|
303
|
-
<tbody>${allowed.length ? allowed.map((x, i) => html`<tr><td data-label="
|
|
304
|
-
<td class="num" data-label="
|
|
305
|
-
<td class="num" data-label="
|
|
306
|
-
<td data-label="
|
|
307
|
-
: html`<tr><td colspan="5" class="empty"
|
|
308
|
-
${region('
|
|
302
|
+
<p class="muted u-mt0">The AI services this application may use (an administrator allows them${admin ? html` under <a href="${BASE}/ai">${tx('aisv.ai_services')}</a>` : ''}), today's use against their limits, and the recent requests.</p>
|
|
303
|
+
${region(tx('aisv.services'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('aisv.service')}</th><th>${tx('aisv.model')}</th><th class="num">${tx('aisv.requests_today')}</th><th class="num">${tx('aisv.tokens_today')}</th><th>${tx('aisv.status')}</th></tr></thead>
|
|
304
|
+
<tbody>${allowed.length ? allowed.map((x, i) => html`<tr><td data-label="${tx('aisv.service')}">${x.name}</td><td data-label="${tx('aisv.model')}">${providerLabel(x.provider)} ${x.model}</td>
|
|
305
|
+
<td class="num" data-label="${tx('aisv.requests_today')}">${today[i].requests}${x.max_requests !== null ? ` / ${x.max_requests}` : ''}</td>
|
|
306
|
+
<td class="num" data-label="${tx('aisv.tokens_today')}">${today[i].tokens}${x.max_tokens !== null ? ` / ${x.max_tokens}` : ''}</td>
|
|
307
|
+
<td data-label="${tx('aisv.status')}">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
|
|
308
|
+
: html`<tr><td colspan="5" class="empty">${tx('aisv.this_application_may_not_use')}</td></tr>`}</tbody></table></div>`)}
|
|
309
|
+
${region(tx('aisv.recent_requests'), usageTable(recent, false))}
|
|
309
310
|
${await toolCallTable(a.id)}`;
|
|
310
|
-
return send(reply, s, shell(s, `AI usage · ${a.name}`, [['
|
|
311
|
+
return send(reply, s, shell(s, `AI usage · ${a.name}`, [[tx('aisv.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('aisv.activity'), `${BASE}/apps/${a.id}/activity`], [tx('aisv.ai_usage')]], main));
|
|
311
312
|
});
|
|
312
313
|
}
|
package/src/builder/api.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { publicUrl } from '../sso.ts';
|
|
|
7
7
|
import { icon } from '../icons.ts';
|
|
8
8
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
9
9
|
import { roleHints, roleHintsHtml, splitRoles } from './users.ts';
|
|
10
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
10
11
|
|
|
11
12
|
// Per-application REST API page: the database role API tokens use, what
|
|
12
13
|
// PostgREST exposes to it, and developer-issued tokens for trying it out.
|
|
@@ -58,43 +59,42 @@ async function clientsRegion(s: Session, a: any, secret?: NewSecret, usable = tr
|
|
|
58
59
|
html`<form method="post" action="${BASE}/apps/${a.id}/api/clients/${c.id}/${path}" class="inline-form">${csrf(s)}${extra}<button class="${cls}">${label}</button></form>`;
|
|
59
60
|
const createForm = html`<form method="post" action="${BASE}/apps/${a.id}/api/clients">${csrf(s)}
|
|
60
61
|
<div class="form-grid">
|
|
61
|
-
${input('name', '
|
|
62
|
-
${input('token_minutes', '
|
|
63
|
-
<div class="field"><label class="label" for="f_roles"
|
|
64
|
-
${input('description', '
|
|
62
|
+
${input('name', tx('apip.name'), '', { required: true, placeholder: tx('apip.e_g_payroll_sync'), help: tx('apip.lower_case_letters_digits') })}
|
|
63
|
+
${input('token_minutes', tx('apip.token_lifetime_minutes'), 60, { type: 'number', help: tx('apip.5_to_1440') })}
|
|
64
|
+
<div class="field"><label class="label" for="f_roles">${tx('apip.roles')}</label><input id="f_roles" name="roles" placeholder="${tx('apip.comma_separated_or_pick_below')}">${roleHintsHtml(hints)}</div>
|
|
65
|
+
${input('description', tx('apip.description'), '', { placeholder: tx('apip.who_uses_it') })}
|
|
65
66
|
</div>
|
|
66
|
-
<div class="buttons"><button class="btn btn-hot"${usable ? '' : raw(' disabled')}
|
|
67
|
+
<div class="buttons"><button class="btn btn-hot"${usable ? '' : raw(' disabled')}>${tx('apip.create_client')}</button></div>
|
|
67
68
|
</form>`;
|
|
68
69
|
return region(
|
|
69
70
|
'OAuth clients',
|
|
70
|
-
html`<p class="muted u-mt0"
|
|
71
|
-
<code>${tokenUrl}</code> (<code>grant_type=client_credentials</code>) and acts as <code>client:<name></code> with its roles, read at every request.</p>
|
|
71
|
+
html`<p class="muted u-mt0">${tx.rich('apip.oauth_intro', { ords: html`<code>oauth.create_client</code>`, url: html`<code>${tokenUrl}</code>`, grant: html`<code>grant_type=client_credentials</code>`, client: html`<code>client:<name></code>` })}</p>
|
|
72
72
|
${secret
|
|
73
|
-
? html`<div class="alert alert-success" role="status">${secret.rotated ?
|
|
74
|
-
secret.rotated && secret.graceHours ? `
|
|
73
|
+
? html`<div class="alert alert-success" role="status">${secret.rotated ? tx('apip.new_secret_for', { name: secret.name }) : tx('apip.client_created', { name: secret.name })}. ${tx('apip.copy_now')}${
|
|
74
|
+
secret.rotated && secret.graceHours ? ` ${tx('apip.grace', { n: secret.graceHours })}` : ''}</div>
|
|
75
75
|
<div class="form-grid">
|
|
76
|
-
<div class="field"><label class="label" for="oauth_id"
|
|
77
|
-
<div class="field"><label class="label" for="oauth_secret"
|
|
76
|
+
<div class="field"><label class="label" for="oauth_id">${tx('apip.client_id')}</label><input id="oauth_id" class="code" readonly value="${secret.clientId}"></div>
|
|
77
|
+
<div class="field"><label class="label" for="oauth_secret">${tx('apip.client_secret')}</label><input id="oauth_secret" class="code" readonly value="${secret.secret}"></div>
|
|
78
78
|
</div>
|
|
79
79
|
<pre class="source">curl -u '${secret.clientId}:${secret.secret}' -d grant_type=client_credentials ${tokenUrl}</pre>`
|
|
80
80
|
: ''}
|
|
81
81
|
${clients.length
|
|
82
|
-
? html`<div class="table-wrap"><table class="report report-reflow api-clients"><thead><tr><th
|
|
82
|
+
? html`<div class="table-wrap"><table class="report report-reflow api-clients"><thead><tr><th>${tx('apip.client')}</th><th>${tx('apip.roles')}</th><th class="num">${tx('apip.token')}</th><th>${tx('apip.secret_changed')}</th><th>${tx('apip.last_used')}</th><th><span class="sr-only">${tx('apip.actions')}</span></th></tr></thead><tbody>
|
|
83
83
|
${clients.map((c) => html`<tr${c.active ? '' : raw(' class="muted"')}>
|
|
84
|
-
<td data-label="
|
|
85
|
-
<td data-label="
|
|
86
|
-
<input name="roles" value="${c.roles.join(', ')}" aria-label="
|
|
87
|
-
<td class="num" data-label="
|
|
88
|
-
<td data-label="
|
|
89
|
-
<td data-label="
|
|
84
|
+
<td data-label="${tx('apip.client')}"><strong>${c.name}</strong>${c.active ? '' : html` <span class="tag">revoked</span>`}<br><code class="small">${c.client_id}</code>${c.description ? html`<br><small class="help">${c.description}</small>` : ''}</td>
|
|
85
|
+
<td data-label="${tx('apip.roles')}"><form method="post" action="${BASE}/apps/${a.id}/api/clients/${c.id}/roles" class="search roles-form u-m0 u-mwnone">${csrf(s)}
|
|
86
|
+
<input name="roles" value="${c.roles.join(', ')}" aria-label="${tx('apip.aria_roles', { name: c.name })}" placeholder="${tx('apip.no_roles')}"><button class="btn btn-sm">${tx('apip.save')}</button>${roleHintsHtml(hints, tx('apip.add'))}</form></td>
|
|
87
|
+
<td class="num" data-label="${tx('apip.token')}">${tx('apip.minutes', { n: c.token_minutes })}</td>
|
|
88
|
+
<td data-label="${tx('apip.secret_changed')}">${when(c.secret_changed_at)}${c.grace ? html`<br><small class="help">${tx('apip.old_valid_until', { when: when(c.previous_valid_until) })}</small>` : ''}</td>
|
|
89
|
+
<td data-label="${tx('apip.last_used')}">${when(c.last_used_at) || html`<span class="muted">${tx('apip.never')}</span>`}</td>
|
|
90
90
|
<td class="actions"><div class="api-client-actions">
|
|
91
|
-
${action(c, 'rotate', '
|
|
92
|
-
${action(c, 'active', c.active ? '
|
|
93
|
-
${action(c, 'delete', '
|
|
91
|
+
${action(c, 'rotate', tx('apip.new_secret'), html`<select name="grace" aria-label="${tx('apip.keep_the_old_secret')}"><option value="24">${tx('apip.old_24h')}</option><option value="168">${tx('apip.old_7d')}</option><option value="0">${tx('apip.old_now')}</option></select>`, 'btn btn-sm')}
|
|
92
|
+
${action(c, 'active', c.active ? tx('apip.revoke') : tx('apip.reactivate'), html`<input type="hidden" name="active" value="${c.active ? 'false' : 'true'}">`, 'btn btn-sm')}
|
|
93
|
+
${action(c, 'delete', tx('apip.delete'), '', 'btn btn-sm btn-danger')}
|
|
94
94
|
</div></td></tr>`)}
|
|
95
95
|
</tbody></table></div>
|
|
96
|
-
<details class="api-new u-mt1"><summary class="btn">${icon('plus')}
|
|
97
|
-
: html`<p class="muted"
|
|
96
|
+
<details class="api-new u-mt1"><summary class="btn">${icon('plus')} ${tx('apip.new_client')}</summary><div class="api-new-body">${createForm}</div></details>`
|
|
97
|
+
: html`<p class="muted">${tx('apip.no_clients_yet')}</p>${createForm}`}`,
|
|
98
98
|
html`<span class="count">${clients.length}</span>`,
|
|
99
99
|
);
|
|
100
100
|
}
|
|
@@ -117,42 +117,42 @@ async function apiPage(s: Session, a: any, issued?: { token: string; username: s
|
|
|
117
117
|
`curl -X POST ${url}/rpc/${e.name} -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" -d '{ … }'`),
|
|
118
118
|
].join('\n');
|
|
119
119
|
|
|
120
|
-
const pill = (ok: boolean, text: Raw | string) => html`<li class="${ok ? 'is-ok' : 'is-bad'}"><span aria-hidden="true">${ok ? '✓' : '✗'}</span> <span class="sr-only">${ok ? '
|
|
120
|
+
const pill = (ok: boolean, text: Raw | string) => html`<li class="${ok ? 'is-ok' : 'is-bad'}"><span aria-hidden="true">${ok ? '✓' : '✗'}</span> <span class="sr-only">${ok ? tx('apip.ok') : tx('apip.problem')}</span>${text}</li>`;
|
|
121
121
|
const main = html`${appHeader(a, 'api', s)}
|
|
122
|
-
<p class="muted api-intro"
|
|
123
|
-
<ul class="api-status" aria-label="
|
|
124
|
-
${pill(status.ok, html
|
|
125
|
-
${role ? pill(!problem, problem ?? html`<code>${role}</code
|
|
126
|
-
${role && !problem ? pill(!!granted?.ok, granted?.ok ?
|
|
122
|
+
<p class="muted api-intro">${tx.rich('apip.intro', { schema: html`<code>${API_SCHEMA}</code>`, role: html`<code>role</code>`, user: html`<code>meta.app_user()</code>`, has: html`<code>meta.has_role()</code>` })}</p>
|
|
123
|
+
<ul class="api-status" aria-label="${tx('apip.status')}">
|
|
124
|
+
${pill(status.ok, html`${tx.rich('apip.at', { url: html`<code>${url}</code>` })} ${status.ok ? status.detail : html`<b>${tx('apip.not_reachable')}</b> (${status.detail})`}`)}
|
|
125
|
+
${role ? pill(!problem, problem ?? tx.rich('apip.dedicated', { role: html`<code>${role}</code>` })) : pill(false, tx('apip.no_role'))}
|
|
126
|
+
${role && !problem ? pill(!!granted?.ok, granted?.ok ? tx.rich('apip.may_switch', { role: html`<code>${role}</code>` }) : tx.rich('apip.run_grant', { run: html`<b>${tx('apip.run')}</b>`, sql: html`<code>grant ${role} to pgkiln_authenticator;</code>` })) : ''}
|
|
127
127
|
</ul>
|
|
128
128
|
<div class="columns api-top">
|
|
129
|
-
${region('
|
|
130
|
-
${input('api_role', '
|
|
131
|
-
<div class="field"><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}>
|
|
132
|
-
<small class="help"
|
|
133
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
129
|
+
${region(tx('apip.setup'), html`<form method="post" action="${BASE}/apps/${a.id}/api">${csrf(s)}
|
|
130
|
+
${input('api_role', tx('apip.api_database_role'), role, { placeholder: tx('apip.e_g_myapp_api'), help: tx('apip.grant_it_only_the_api') })}
|
|
131
|
+
<div class="field"><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}> ${tx('apip.users_create_their_own_tokens')}</label>
|
|
132
|
+
<small class="help">${tx.rich('apip.self_help', { account: html`<em>${tx('apip.my_account')}</em>` })}</small></div>
|
|
133
|
+
<div class="buttons"><button class="btn btn-hot">${tx('apip.save')}</button></div>
|
|
134
134
|
</form>`)}
|
|
135
|
-
${region('
|
|
135
|
+
${region(tx('apip.issue_a_token'), html`<p class="muted u-mt0">${tx.code('apip.for_development_and_trusted_scripts')}</p>
|
|
136
136
|
<form method="post" action="${BASE}/apps/${a.id}/api/token">${csrf(s)}
|
|
137
137
|
<div class="form-grid">
|
|
138
|
-
${input('username', '
|
|
139
|
-
${input('hours', '
|
|
138
|
+
${input('username', tx('apip.account'), issued?.username ?? '', { required: true, auto: 'off' })}
|
|
139
|
+
${input('hours', tx('apip.valid_for_hours'), issued?.expiresInHours ?? 8, { type: 'number', help: `1 to ${MAX_TOKEN_HOURS}.` })}
|
|
140
140
|
</div>
|
|
141
|
-
<div class="buttons"><button class="btn btn-hot"${!problem ? '' : raw(' disabled')}
|
|
141
|
+
<div class="buttons"><button class="btn btn-hot"${!problem ? '' : raw(' disabled')}>${tx('apip.issue_token')}</button></div>
|
|
142
142
|
</form>
|
|
143
143
|
${issued ? html`<div class="field u-mt1" data-wide><label class="label" for="api_token">Token for ${issued.username} (shown once, valid ${issued.expiresInHours} h)</label>
|
|
144
144
|
<textarea id="api_token" class="code" rows="4" readonly spellcheck="false">${issued.token}</textarea></div>` : ''}`)}
|
|
145
145
|
</div>
|
|
146
|
-
${region('
|
|
147
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
148
|
-
<tbody>${eps.map((e) => html`<tr><td data-label="
|
|
149
|
-
: html`<p class="muted u-mt0"
|
|
150
|
-
<h3 class="api-sub"
|
|
146
|
+
${region(tx('apip.endpoints'), html`${eps.length
|
|
147
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('apip.path')}</th><th>${tx('apip.kind')}</th><th>${tx('apip.methods_for', { role: role ?? '' })}</th></tr></thead>
|
|
148
|
+
<tbody>${eps.map((e) => html`<tr><td data-label="${tx('apip.path')}"><code>/${e.kind === 'function' ? 'rpc/' : ''}${e.name}</code></td><td data-label="${tx('apip.kind')}">${e.kind}</td><td data-label="${tx('apip.methods')}">${e.methods || html`<span class="muted">${tx('apip.none')}</span>`}</td></tr>`)}</tbody></table></div>`
|
|
149
|
+
: html`<p class="muted u-mt0">${tx.rich(role ? 'apip.nothing_yet' : 'apip.nothing_yet_no_role', { schema: html`<code>${API_SCHEMA}</code>` })}</p>`}
|
|
150
|
+
<h3 class="api-sub">${tx('apip.try_it')}</h3>
|
|
151
151
|
<pre class="source">${examples}</pre>
|
|
152
|
-
<p class="muted small"
|
|
152
|
+
<p class="muted small">${tx.rich('apip.reload', { sql: html`<code>notify pgrst, 'reload schema';</code>`, link: html`<a href="${url}/" target="_blank" rel="noopener">${url}/</a>` })}</p>`,
|
|
153
153
|
html`<span class="count">${eps.length}</span>`)}
|
|
154
154
|
${await clientsRegion(s, a, secret, !problem)}`;
|
|
155
|
-
return shell(s, `${a.name} REST API`, [['
|
|
155
|
+
return shell(s, `${a.name} REST API`, [[tx('apip.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('apip.rest_api')]], main);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
export async function apiRoutes(app: FastifyInstance) {
|
|
@@ -214,7 +214,7 @@ export async function apiRoutes(app: FastifyInstance) {
|
|
|
214
214
|
return send(reply.header('cache-control', 'no-store'), s, await apiPage(s, a, undefined, { name, clientId: r.client_id, secret: r.client_secret, rotated: false }));
|
|
215
215
|
} catch (e) {
|
|
216
216
|
const err = e as { code?: string; message: string };
|
|
217
|
-
flash(s, err.code === '23505' ? 'A client with this name already exists.' : err.code === '23514' ? '
|
|
217
|
+
flash(s, err.code === '23505' ? 'A client with this name already exists.' : err.code === '23514' ? tx('apip.check_the_name_lower_case') : err.message, 'error');
|
|
218
218
|
return back(reply, s, `${BASE}/apps/${a.id}/api`);
|
|
219
219
|
}
|
|
220
220
|
});
|
|
@@ -246,7 +246,7 @@ export async function apiRoutes(app: FastifyInstance) {
|
|
|
246
246
|
flash(s, active ? `${c.name} can get tokens again.` : `${c.name} is revoked: its tokens stop working now.`);
|
|
247
247
|
} else if (op === 'roles') {
|
|
248
248
|
await owner.query('update meta.api_client set roles = $2 where id = $1', [c.id, splitRoles(req.body?.roles ?? '')]);
|
|
249
|
-
flash(s,
|
|
249
|
+
flash(s, tx('apip.roles_saved', { name: c.name }));
|
|
250
250
|
} else if (op === 'delete') {
|
|
251
251
|
await owner.query('delete from meta.api_client where id = $1', [c.id]);
|
|
252
252
|
flash(s, `${c.name} deleted.`);
|