pgkiln 0.49.0 → 0.51.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 +3 -0
- package/db/migrations/119_workspace_home.sql +30 -0
- package/db/migrations/120_datasets.sql +87 -0
- package/docs/apex-feature-parity.md +7 -6
- package/docs/guide/01-installation.md +5 -0
- package/docs/guide/03-builder.md +140 -11
- package/docs/guide/04-pages-and-regions.md +25 -1
- package/docs/guide/12-development.md +47 -11
- package/examples/hr/hr_63_datasets.sql +29 -0
- package/package.json +1 -1
- package/public/app.css +6 -0
- package/public/builder.css +95 -3
- package/public/code-editor.css +32 -79
- package/public/code-editor.js +58 -3
- package/public/data-modeler.css +17 -14
- package/public/worksheet.css +14 -7
- package/public/worksheet.js +119 -3
- package/src/app.ts +5 -1
- package/src/builder/changelog.ts +1 -1
- package/src/builder/data-modeler.ts +1 -1
- package/src/builder/datasets.ts +472 -0
- package/src/builder/db-users.ts +2 -2
- package/src/builder/home.ts +12 -7
- package/src/builder/instance.ts +2 -0
- package/src/builder/object-browser.ts +4 -4
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/quicksql.ts +1 -1
- package/src/builder/reporter.ts +52 -3
- package/src/builder/routes.ts +3 -3
- package/src/builder/scheduling.ts +2 -2
- package/src/builder/scripts.ts +1 -1
- package/src/builder/sql-charts.ts +1 -1
- package/src/builder/sql-monitor.ts +1 -1
- package/src/builder/subscriptions.ts +1 -0
- package/src/builder/table-editor.ts +2 -2
- package/src/builder/ui.ts +7 -5
- package/src/builder/worksheet.ts +171 -23
- package/src/builder/workspace-home.ts +283 -0
- package/src/builder/workspaces.ts +7 -4
- package/src/datasets.ts +367 -0
- package/src/i18n/ar.ts +16 -0
- package/src/i18n/cs.ts +16 -0
- package/src/i18n/da.ts +16 -0
- package/src/i18n/de.ts +16 -0
- package/src/i18n/el.ts +16 -0
- package/src/i18n/es.ts +16 -0
- package/src/i18n/fi.ts +16 -0
- package/src/i18n/fr.ts +16 -0
- package/src/i18n/he.ts +16 -0
- package/src/i18n/it.ts +16 -0
- package/src/i18n/ja.ts +16 -0
- package/src/i18n/ko.ts +16 -0
- package/src/i18n/nb.ts +16 -0
- package/src/i18n/pl.ts +16 -0
- package/src/i18n/pt.ts +16 -0
- package/src/i18n/ru.ts +16 -0
- package/src/i18n/sv.ts +16 -0
- package/src/i18n/tr.ts +16 -0
- package/src/i18n/uk.ts +16 -0
- package/src/i18n/zh.ts +16 -0
- package/src/i18n.ts +32 -0
- package/src/instance.ts +1 -0
- package/src/release-check.ts +139 -0
- package/src/reporting-app.ts +101 -0
- package/src/runtime/data-reporter.ts +199 -32
- package/src/session.ts +7 -0
- package/src/sqlworksheet.ts +9 -6
- package/src/subscriptions.ts +2 -1
- package/src/worksheet-txn.ts +270 -0
- package/src/workspace-export.ts +24 -4
package/src/i18n/ko.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const ko: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "데이터를 선택해 보고서를 시작하세요. 열 선택, 필터, 합계가 있는 그룹, 차트를 추가한 뒤 저장하거나 공유할 수 있습니다.",
|
|
687
687
|
'reporter.columns_count': "열 {n}개",
|
|
688
688
|
'reporter.search': "보고서 검색",
|
|
689
|
+
'reporter.published_reports': "게시된 보고서",
|
|
690
|
+
'reporter.published_tag': "게시됨",
|
|
691
|
+
'reporter.publish': "게시",
|
|
692
|
+
'reporter.unpublish': "게시 취소",
|
|
693
|
+
'reporter.publish_on_save': "애플리케이션의 모든 사용자에게 게시",
|
|
694
|
+
'reporter.published': "보고서 \"{name}\"을(를) 게시했습니다.",
|
|
695
|
+
'reporter.unpublished': "보고서 \"{name}\"의 게시를 취소했습니다.",
|
|
696
|
+
'reporter.pin': "고정",
|
|
697
|
+
'reporter.unpin': "고정 해제",
|
|
698
|
+
'reporter.pinned': "고정됨",
|
|
699
|
+
'reporter.pinned_msg': "보고서 \"{name}\"을(를) 고정했습니다.",
|
|
700
|
+
'reporter.unpinned_msg': "보고서 \"{name}\"의 고정을 해제했습니다.",
|
|
701
|
+
'reporter.link': "이 보고서 링크",
|
|
702
|
+
'reporter.open_link': "열기",
|
|
703
|
+
'reporter.no_published': "아직 게시된 보고서가 없습니다.",
|
|
704
|
+
'reporter.welcome_viewer': "게시된 보고서를 여세요. 데이터는 항상 최신입니다.",
|
|
689
705
|
'reporter.changed': "{date} 변경됨",
|
|
690
706
|
'reporter.no_match': "검색과 일치하는 보고서가 없습니다.",
|
|
691
707
|
'reporter.chart_tag': "차트",
|
package/src/i18n/nb.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const nb: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Velg data for å starte en rapport: velg kolonner, filtrer, grupper med summer, legg til et diagram, og lagre eller del den.",
|
|
687
687
|
'reporter.columns_count': "{n} kolonner",
|
|
688
688
|
'reporter.search': "Søk i rapporter",
|
|
689
|
+
'reporter.published_reports': "Publiserte rapporter",
|
|
690
|
+
'reporter.published_tag': "publisert",
|
|
691
|
+
'reporter.publish': "Publiser",
|
|
692
|
+
'reporter.unpublish': "Avpubliser",
|
|
693
|
+
'reporter.publish_on_save': "Publiser for alle brukere av applikasjonen",
|
|
694
|
+
'reporter.published': "Rapporten «{name}» publisert.",
|
|
695
|
+
'reporter.unpublished': "Rapporten «{name}» avpublisert.",
|
|
696
|
+
'reporter.pin': "Fest",
|
|
697
|
+
'reporter.unpin': "Løsne",
|
|
698
|
+
'reporter.pinned': "Festet",
|
|
699
|
+
'reporter.pinned_msg': "Rapporten «{name}» festet.",
|
|
700
|
+
'reporter.unpinned_msg': "Rapporten «{name}» løsnet.",
|
|
701
|
+
'reporter.link': "Lenke til denne rapporten",
|
|
702
|
+
'reporter.open_link': "Åpne",
|
|
703
|
+
'reporter.no_published': "Ingen rapporter er publisert ennå.",
|
|
704
|
+
'reporter.welcome_viewer': "Åpne en publisert rapport: dataene er alltid oppdatert.",
|
|
689
705
|
'reporter.changed': "endret {date}",
|
|
690
706
|
'reporter.no_match': "Ingen rapporter samsvarer med søket.",
|
|
691
707
|
'reporter.chart_tag': "diagram",
|
package/src/i18n/pl.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const pl: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Wybierz dane, aby rozpocząć raport: kolumny, filtry, grupowanie z sumami i wykres, a potem zapisz go lub udostępnij.",
|
|
687
687
|
'reporter.columns_count': "Kolumny: {n}",
|
|
688
688
|
'reporter.search': "Szukaj raportów",
|
|
689
|
+
'reporter.published_reports': "Opublikowane raporty",
|
|
690
|
+
'reporter.published_tag': "opublikowany",
|
|
691
|
+
'reporter.publish': "Opublikuj",
|
|
692
|
+
'reporter.unpublish': "Cofnij publikację",
|
|
693
|
+
'reporter.publish_on_save': "Opublikuj dla wszystkich użytkowników aplikacji",
|
|
694
|
+
'reporter.published': "Raport „{name}” opublikowany.",
|
|
695
|
+
'reporter.unpublished': "Cofnięto publikację raportu „{name}”.",
|
|
696
|
+
'reporter.pin': "Przypnij",
|
|
697
|
+
'reporter.unpin': "Odepnij",
|
|
698
|
+
'reporter.pinned': "Przypięte",
|
|
699
|
+
'reporter.pinned_msg': "Raport „{name}” przypięty.",
|
|
700
|
+
'reporter.unpinned_msg': "Raport „{name}” odpięty.",
|
|
701
|
+
'reporter.link': "Link do tego raportu",
|
|
702
|
+
'reporter.open_link': "Otwórz",
|
|
703
|
+
'reporter.no_published': "Nie opublikowano jeszcze żadnych raportów.",
|
|
704
|
+
'reporter.welcome_viewer': "Otwórz opublikowany raport: jego dane są zawsze aktualne.",
|
|
689
705
|
'reporter.changed': "zmieniono {date}",
|
|
690
706
|
'reporter.no_match': "Żaden raport nie pasuje do wyszukiwania.",
|
|
691
707
|
'reporter.chart_tag': "wykres",
|
package/src/i18n/pt.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const pt: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Escolha os dados para começar um relatório: colunas, filtros, agrupamentos com totais e um gráfico; depois guarde-o ou partilhe-o.",
|
|
687
687
|
'reporter.columns_count': "{n} colunas",
|
|
688
688
|
'reporter.search': "Pesquisar relatórios",
|
|
689
|
+
'reporter.published_reports': "Relatórios publicados",
|
|
690
|
+
'reporter.published_tag': "publicado",
|
|
691
|
+
'reporter.publish': "Publicar",
|
|
692
|
+
'reporter.unpublish': "Despublicar",
|
|
693
|
+
'reporter.publish_on_save': "Publicar para todos os utilizadores da aplicação",
|
|
694
|
+
'reporter.published': "Relatório \"{name}\" publicado.",
|
|
695
|
+
'reporter.unpublished': "Relatório \"{name}\" despublicado.",
|
|
696
|
+
'reporter.pin': "Fixar",
|
|
697
|
+
'reporter.unpin': "Desafixar",
|
|
698
|
+
'reporter.pinned': "Fixados",
|
|
699
|
+
'reporter.pinned_msg': "Relatório \"{name}\" fixado.",
|
|
700
|
+
'reporter.unpinned_msg': "Relatório \"{name}\" desafixado.",
|
|
701
|
+
'reporter.link': "Ligação para este relatório",
|
|
702
|
+
'reporter.open_link': "Abrir",
|
|
703
|
+
'reporter.no_published': "Ainda não há relatórios publicados.",
|
|
704
|
+
'reporter.welcome_viewer': "Abra um relatório publicado: os dados estão sempre atualizados.",
|
|
689
705
|
'reporter.changed': "alterado em {date}",
|
|
690
706
|
'reporter.no_match': "Nenhum relatório corresponde à pesquisa.",
|
|
691
707
|
'reporter.chart_tag': "gráfico",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const ru: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Выберите данные для нового отчёта: столбцы, фильтры, группировку с итогами и диаграмму, затем сохраните его или поделитесь им.",
|
|
687
687
|
'reporter.columns_count': "Столбцов: {n}",
|
|
688
688
|
'reporter.search': "Поиск отчётов",
|
|
689
|
+
'reporter.published_reports': "Опубликованные отчёты",
|
|
690
|
+
'reporter.published_tag': "опубликован",
|
|
691
|
+
'reporter.publish': "Опубликовать",
|
|
692
|
+
'reporter.unpublish': "Снять с публикации",
|
|
693
|
+
'reporter.publish_on_save': "Опубликовать для всех пользователей приложения",
|
|
694
|
+
'reporter.published': "Отчёт «{name}» опубликован.",
|
|
695
|
+
'reporter.unpublished': "Отчёт «{name}» снят с публикации.",
|
|
696
|
+
'reporter.pin': "Закрепить",
|
|
697
|
+
'reporter.unpin': "Открепить",
|
|
698
|
+
'reporter.pinned': "Закреплённые",
|
|
699
|
+
'reporter.pinned_msg': "Отчёт «{name}» закреплён.",
|
|
700
|
+
'reporter.unpinned_msg': "Отчёт «{name}» откреплён.",
|
|
701
|
+
'reporter.link': "Ссылка на этот отчёт",
|
|
702
|
+
'reporter.open_link': "Открыть",
|
|
703
|
+
'reporter.no_published': "Опубликованных отчётов пока нет.",
|
|
704
|
+
'reporter.welcome_viewer': "Откройте опубликованный отчёт: его данные всегда актуальны.",
|
|
689
705
|
'reporter.changed': "изменён {date}",
|
|
690
706
|
'reporter.no_match': "Нет отчётов, подходящих под поиск.",
|
|
691
707
|
'reporter.chart_tag': "диаграмма",
|
package/src/i18n/sv.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const sv: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Välj data för att börja en rapport: välj kolumner, filtrera, gruppera med summor, lägg till ett diagram och spara eller dela den.",
|
|
687
687
|
'reporter.columns_count': "{n} kolumner",
|
|
688
688
|
'reporter.search': "Sök rapporter",
|
|
689
|
+
'reporter.published_reports': "Publicerade rapporter",
|
|
690
|
+
'reporter.published_tag': "publicerad",
|
|
691
|
+
'reporter.publish': "Publicera",
|
|
692
|
+
'reporter.unpublish': "Avpublicera",
|
|
693
|
+
'reporter.publish_on_save': "Publicera för alla användare av applikationen",
|
|
694
|
+
'reporter.published': "Rapporten \"{name}\" publicerad.",
|
|
695
|
+
'reporter.unpublished': "Rapporten \"{name}\" avpublicerad.",
|
|
696
|
+
'reporter.pin': "Fäst",
|
|
697
|
+
'reporter.unpin': "Lossa",
|
|
698
|
+
'reporter.pinned': "Fästa",
|
|
699
|
+
'reporter.pinned_msg': "Rapporten \"{name}\" fäst.",
|
|
700
|
+
'reporter.unpinned_msg': "Rapporten \"{name}\" lossad.",
|
|
701
|
+
'reporter.link': "Länk till den här rapporten",
|
|
702
|
+
'reporter.open_link': "Öppna",
|
|
703
|
+
'reporter.no_published': "Inga rapporter är publicerade ännu.",
|
|
704
|
+
'reporter.welcome_viewer': "Öppna en publicerad rapport: dess data är alltid aktuella.",
|
|
689
705
|
'reporter.changed': "ändrad {date}",
|
|
690
706
|
'reporter.no_match': "Inga rapporter matchar sökningen.",
|
|
691
707
|
'reporter.chart_tag': "diagram",
|
package/src/i18n/tr.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const tr: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Bir rapora başlamak için verileri seçin: sütunlar, filtreler, toplamlarla gruplama ve bir grafik ekleyin, sonra kaydedin veya paylaşın.",
|
|
687
687
|
'reporter.columns_count': "{n} sütun",
|
|
688
688
|
'reporter.search': "Raporlarda ara",
|
|
689
|
+
'reporter.published_reports': "Yayımlanan raporlar",
|
|
690
|
+
'reporter.published_tag': "yayımlandı",
|
|
691
|
+
'reporter.publish': "Yayımla",
|
|
692
|
+
'reporter.unpublish': "Yayımdan kaldır",
|
|
693
|
+
'reporter.publish_on_save': "Uygulamanın tüm kullanıcıları için yayımla",
|
|
694
|
+
'reporter.published': "\"{name}\" raporu yayımlandı.",
|
|
695
|
+
'reporter.unpublished': "\"{name}\" raporu yayımdan kaldırıldı.",
|
|
696
|
+
'reporter.pin': "Sabitle",
|
|
697
|
+
'reporter.unpin': "Sabitlemeyi kaldır",
|
|
698
|
+
'reporter.pinned': "Sabitlenenler",
|
|
699
|
+
'reporter.pinned_msg': "\"{name}\" raporu sabitlendi.",
|
|
700
|
+
'reporter.unpinned_msg': "\"{name}\" raporunun sabitlemesi kaldırıldı.",
|
|
701
|
+
'reporter.link': "Bu raporun bağlantısı",
|
|
702
|
+
'reporter.open_link': "Aç",
|
|
703
|
+
'reporter.no_published': "Henüz yayımlanmış rapor yok.",
|
|
704
|
+
'reporter.welcome_viewer': "Yayımlanmış bir raporu açın: verileri her zaman günceldir.",
|
|
689
705
|
'reporter.changed': "{date} tarihinde değişti",
|
|
690
706
|
'reporter.no_match': "Aramayla eşleşen rapor yok.",
|
|
691
707
|
'reporter.chart_tag': "grafik",
|
package/src/i18n/uk.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const uk: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Виберіть дані для нового звіту: стовпці, фільтри, групування з підсумками та діаграму, потім збережіть його або поділіться ним.",
|
|
687
687
|
'reporter.columns_count': "Стовпців: {n}",
|
|
688
688
|
'reporter.search': "Пошук звітів",
|
|
689
|
+
'reporter.published_reports': "Опубліковані звіти",
|
|
690
|
+
'reporter.published_tag': "опубліковано",
|
|
691
|
+
'reporter.publish': "Опублікувати",
|
|
692
|
+
'reporter.unpublish': "Зняти з публікації",
|
|
693
|
+
'reporter.publish_on_save': "Опублікувати для всіх користувачів застосунку",
|
|
694
|
+
'reporter.published': "Звіт «{name}» опубліковано.",
|
|
695
|
+
'reporter.unpublished': "Звіт «{name}» знято з публікації.",
|
|
696
|
+
'reporter.pin': "Закріпити",
|
|
697
|
+
'reporter.unpin': "Відкріпити",
|
|
698
|
+
'reporter.pinned': "Закріплені",
|
|
699
|
+
'reporter.pinned_msg': "Звіт «{name}» закріплено.",
|
|
700
|
+
'reporter.unpinned_msg': "Звіт «{name}» відкріплено.",
|
|
701
|
+
'reporter.link': "Посилання на цей звіт",
|
|
702
|
+
'reporter.open_link': "Відкрити",
|
|
703
|
+
'reporter.no_published': "Опублікованих звітів ще немає.",
|
|
704
|
+
'reporter.welcome_viewer': "Відкрийте опублікований звіт: його дані завжди актуальні.",
|
|
689
705
|
'reporter.changed': "змінено {date}",
|
|
690
706
|
'reporter.no_match': "Немає звітів, що відповідають пошуку.",
|
|
691
707
|
'reporter.chart_tag': "діаграма",
|
package/src/i18n/zh.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const zh: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "选择数据开始一个报表:选择列、筛选、按合计分组、添加图表,然后保存或共享。",
|
|
687
687
|
'reporter.columns_count': "{n} 列",
|
|
688
688
|
'reporter.search': "搜索报表",
|
|
689
|
+
'reporter.published_reports': "已发布的报表",
|
|
690
|
+
'reporter.published_tag': "已发布",
|
|
691
|
+
'reporter.publish': "发布",
|
|
692
|
+
'reporter.unpublish': "取消发布",
|
|
693
|
+
'reporter.publish_on_save': "向应用的所有用户发布",
|
|
694
|
+
'reporter.published': "报表“{name}”已发布。",
|
|
695
|
+
'reporter.unpublished': "报表“{name}”已取消发布。",
|
|
696
|
+
'reporter.pin': "固定",
|
|
697
|
+
'reporter.unpin': "取消固定",
|
|
698
|
+
'reporter.pinned': "已固定",
|
|
699
|
+
'reporter.pinned_msg': "报表“{name}”已固定。",
|
|
700
|
+
'reporter.unpinned_msg': "报表“{name}”已取消固定。",
|
|
701
|
+
'reporter.link': "此报表的链接",
|
|
702
|
+
'reporter.open_link': "打开",
|
|
703
|
+
'reporter.no_published': "还没有已发布的报表。",
|
|
704
|
+
'reporter.welcome_viewer': "打开已发布的报表:其数据始终是最新的。",
|
|
689
705
|
'reporter.changed': "修改于 {date}",
|
|
690
706
|
'reporter.no_match': "没有与搜索匹配的报表。",
|
|
691
707
|
'reporter.chart_tag': "图表",
|
package/src/i18n.ts
CHANGED
|
@@ -713,6 +713,22 @@ const en = {
|
|
|
713
713
|
'reporter.welcome_text': "Choose the data to start a report: pick columns, filter, group with totals, add a chart, then save it for later or share it.",
|
|
714
714
|
'reporter.columns_count': "{n} columns",
|
|
715
715
|
'reporter.search': "Search reports",
|
|
716
|
+
'reporter.published_reports': "Published reports",
|
|
717
|
+
'reporter.published_tag': "published",
|
|
718
|
+
'reporter.publish': "Publish",
|
|
719
|
+
'reporter.unpublish': "Unpublish",
|
|
720
|
+
'reporter.publish_on_save': "Publish for all users of the application",
|
|
721
|
+
'reporter.published': "Report \"{name}\" published.",
|
|
722
|
+
'reporter.unpublished': "Report \"{name}\" unpublished.",
|
|
723
|
+
'reporter.pin': "Pin",
|
|
724
|
+
'reporter.unpin': "Unpin",
|
|
725
|
+
'reporter.pinned': "Pinned",
|
|
726
|
+
'reporter.pinned_msg': "Report \"{name}\" pinned.",
|
|
727
|
+
'reporter.unpinned_msg': "Report \"{name}\" unpinned.",
|
|
728
|
+
'reporter.link': "Link to this report",
|
|
729
|
+
'reporter.open_link': "Open",
|
|
730
|
+
'reporter.no_published': "No reports are published yet.",
|
|
731
|
+
'reporter.welcome_viewer': "Open a published report: its data is always up to date.",
|
|
716
732
|
'reporter.changed': "changed {date}",
|
|
717
733
|
'reporter.no_match': "No reports match the search.",
|
|
718
734
|
'reporter.chart_tag': "chart",
|
|
@@ -1480,6 +1496,22 @@ const nl: Record<MessageKey, string> = {
|
|
|
1480
1496
|
'reporter.welcome_text': "Kies de gegevens om een rapport te beginnen: kies kolommen, filter, groepeer met totalen, voeg een grafiek toe en bewaar of deel het rapport.",
|
|
1481
1497
|
'reporter.columns_count': "{n} kolommen",
|
|
1482
1498
|
'reporter.search': "Rapporten zoeken",
|
|
1499
|
+
'reporter.published_reports': "Gepubliceerde rapporten",
|
|
1500
|
+
'reporter.published_tag': "gepubliceerd",
|
|
1501
|
+
'reporter.publish': "Publiceren",
|
|
1502
|
+
'reporter.unpublish': "Publicatie intrekken",
|
|
1503
|
+
'reporter.publish_on_save': "Publiceren voor alle gebruikers van de applicatie",
|
|
1504
|
+
'reporter.published': "Rapport \"{name}\" gepubliceerd.",
|
|
1505
|
+
'reporter.unpublished': "Publicatie van rapport \"{name}\" ingetrokken.",
|
|
1506
|
+
'reporter.pin': "Vastzetten",
|
|
1507
|
+
'reporter.unpin': "Losmaken",
|
|
1508
|
+
'reporter.pinned': "Vastgezet",
|
|
1509
|
+
'reporter.pinned_msg': "Rapport \"{name}\" vastgezet.",
|
|
1510
|
+
'reporter.unpinned_msg': "Rapport \"{name}\" losgemaakt.",
|
|
1511
|
+
'reporter.link': "Link naar dit rapport",
|
|
1512
|
+
'reporter.open_link': "Openen",
|
|
1513
|
+
'reporter.no_published': "Er zijn nog geen rapporten gepubliceerd.",
|
|
1514
|
+
'reporter.welcome_viewer': "Open een gepubliceerd rapport: de gegevens zijn altijd actueel.",
|
|
1483
1515
|
'reporter.changed': "gewijzigd {date}",
|
|
1484
1516
|
'reporter.no_match': "Geen rapporten gevonden.",
|
|
1485
1517
|
'reporter.chart_tag': "grafiek",
|
package/src/instance.ts
CHANGED
|
@@ -126,6 +126,7 @@ export const CONFIGURATION: readonly { env: string; secret?: boolean; default: s
|
|
|
126
126
|
{ env: 'OPENAI_API_KEY', secret: true, default: '(none)', help: 'Default key of OpenAI AI services' },
|
|
127
127
|
{ env: 'MAP_TILE_URL', default: 'OpenStreetMap', help: 'Tile server of map regions' },
|
|
128
128
|
{ env: 'LOG_LEVEL', default: 'info', help: 'Server log level' },
|
|
129
|
+
{ env: 'PGKILN_RELEASE_CHECK', default: 'off', help: 'Ask GitHub once a day for a newer pgkiln release (the Workspace home; administrators can also turn it on there)' },
|
|
129
130
|
];
|
|
130
131
|
|
|
131
132
|
/** A configuration value as the page shows it: secrets never, only whether they are set. */
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { owner } from './db.ts';
|
|
3
|
+
|
|
4
|
+
// (sprint 109, WS-1) The Workspace home's system message: "pgkiln X is
|
|
5
|
+
// available". Off by default: an administrator turns it on (on the Workspace
|
|
6
|
+
// home, stored as meta.setting 'release_check') or the server's environment
|
|
7
|
+
// does (PGKILN_RELEASE_CHECK=on). Only then does the server ask GitHub's
|
|
8
|
+
// releases API for the latest release, server-side, with a timeout and a
|
|
9
|
+
// size limit, at most once a day per server process (an hour after a
|
|
10
|
+
// failure). Pages never wait for it: they show what is cached and start a
|
|
11
|
+
// refresh in the background. The release's texts are data: the page escapes
|
|
12
|
+
// them, and only a plain version number and an https link are kept.
|
|
13
|
+
|
|
14
|
+
/** The running version (package.json). */
|
|
15
|
+
export const VERSION = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version;
|
|
16
|
+
|
|
17
|
+
export const RELEASE_API = 'https://api.github.com/repos/pgkiln/pgkiln/releases/latest';
|
|
18
|
+
const TIMEOUT_MS = 5000;
|
|
19
|
+
const MAX_BYTES = 1_000_000;
|
|
20
|
+
const FRESH_MS = 24 * 3600_000;
|
|
21
|
+
const RETRY_MS = 3600_000;
|
|
22
|
+
|
|
23
|
+
export interface Release {
|
|
24
|
+
version: string;
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
publishedAt: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let cache: { at: number; release: Release | null; error: string | null } | null = null;
|
|
31
|
+
let inflight: Promise<void> | null = null;
|
|
32
|
+
|
|
33
|
+
/** The address asked (PGKILN_RELEASE_URL for tests and mirrors; http(s) only). */
|
|
34
|
+
function releaseUrl() {
|
|
35
|
+
const u = process.env.PGKILN_RELEASE_URL?.trim();
|
|
36
|
+
return u && /^https?:\/\/[^\s]+$/.test(u) && URL.canParse(u) ? u : RELEASE_API;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Never ask the real GitHub from a test run (node --test sets NODE_TEST_CONTEXT in its test processes; CI has no
|
|
41
|
+
* outbound network guarantee): there only a PGKILN_RELEASE_URL (a local mock) is asked.
|
|
42
|
+
*/
|
|
43
|
+
function networkAllowed() {
|
|
44
|
+
return !process.env.NODE_TEST_CONTEXT || !!process.env.PGKILN_RELEASE_URL;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Is the check on? The builder's setting, else the environment variable; off by default. */
|
|
48
|
+
export async function releaseCheckEnabled(): Promise<{ on: boolean; from: 'builder' | 'environment' | 'default' }> {
|
|
49
|
+
const row = await owner.one<{ value: string }>(`select value from meta.setting where name = 'release_check'`).catch(() => null);
|
|
50
|
+
if (row?.value === 'on' || row?.value === 'off') return { on: row.value === 'on', from: 'builder' };
|
|
51
|
+
const env = (process.env.PGKILN_RELEASE_CHECK ?? '').trim().toLowerCase();
|
|
52
|
+
if (env) return { on: ['on', 'true', '1', 'yes'].includes(env), from: 'environment' };
|
|
53
|
+
return { on: false, from: 'default' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Turn the check on or off ('' = back to the environment variable or the default). */
|
|
57
|
+
export async function setReleaseCheck(value: 'on' | 'off' | '') {
|
|
58
|
+
if (value === '') await owner.query(`delete from meta.setting where name = 'release_check'`);
|
|
59
|
+
else await owner.query(`insert into meta.setting (name, value) values ('release_check', $1) on conflict (name) do update set value = excluded.value`, [value]);
|
|
60
|
+
if (value !== 'on') cache = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SEMVER = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/;
|
|
64
|
+
|
|
65
|
+
/** Is version a newer than b (plain x.y.z; anything else is not newer)? */
|
|
66
|
+
export function newerVersion(a: string, b: string) {
|
|
67
|
+
const x = SEMVER.exec(a);
|
|
68
|
+
const y = SEMVER.exec(b.replace(/[-+].*$/, ''));
|
|
69
|
+
if (!x || !y) return false;
|
|
70
|
+
for (let i = 1; i <= 3; i++) if (Number(x[i]) !== Number(y[i])) return Number(x[i]) > Number(y[i]);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The parts of a GitHub release the page uses, checked; null when it doesn't look like a release. */
|
|
75
|
+
export function parseRelease(doc: unknown): Release | null {
|
|
76
|
+
if (!doc || typeof doc !== 'object') return null;
|
|
77
|
+
const d = doc as Record<string, unknown>;
|
|
78
|
+
const m = typeof d.tag_name === 'string' ? SEMVER.exec(d.tag_name.trim()) : null;
|
|
79
|
+
if (!m) return null;
|
|
80
|
+
const version = `${Number(m[1])}.${Number(m[2])}.${Number(m[3])}`;
|
|
81
|
+
const name = typeof d.name === 'string' && d.name.trim() ? d.name.trim().slice(0, 200) : `pgkiln ${version}`;
|
|
82
|
+
const url = typeof d.html_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(d.html_url) && URL.canParse(d.html_url)
|
|
83
|
+
? d.html_url : `https://github.com/pgkiln/pgkiln/releases/tag/v${version}`;
|
|
84
|
+
const publishedAt = typeof d.published_at === 'string' && !Number.isNaN(Date.parse(d.published_at)) ? new Date(d.published_at).toISOString() : null;
|
|
85
|
+
return { version, name, url, publishedAt };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchLatest(): Promise<Release | null> {
|
|
89
|
+
const res = await fetch(releaseUrl(), {
|
|
90
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': `pgkiln/${VERSION}` },
|
|
91
|
+
redirect: 'error',
|
|
92
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
95
|
+
const reader = res.body.getReader();
|
|
96
|
+
const chunks: Uint8Array[] = [];
|
|
97
|
+
let size = 0;
|
|
98
|
+
for (;;) {
|
|
99
|
+
const { done, value } = await reader.read();
|
|
100
|
+
if (done) break;
|
|
101
|
+
size += value.byteLength;
|
|
102
|
+
if (size > MAX_BYTES) {
|
|
103
|
+
await reader.cancel();
|
|
104
|
+
throw new Error('response too large');
|
|
105
|
+
}
|
|
106
|
+
chunks.push(value);
|
|
107
|
+
}
|
|
108
|
+
return parseRelease(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Ask for the latest release when the check is on and the cache is stale (force: now). Never throws;
|
|
113
|
+
* concurrent calls share one request.
|
|
114
|
+
*/
|
|
115
|
+
export async function refreshRelease(force = false): Promise<void> {
|
|
116
|
+
if (!networkAllowed() || !(await releaseCheckEnabled()).on) return;
|
|
117
|
+
if (!force && cache && Date.now() - cache.at < (cache.error ? RETRY_MS : FRESH_MS)) return;
|
|
118
|
+
inflight ??= fetchLatest()
|
|
119
|
+
.then((release) => {
|
|
120
|
+
cache = { at: Date.now(), release, error: release ? null : 'not a release' };
|
|
121
|
+
})
|
|
122
|
+
.catch((e: Error) => {
|
|
123
|
+
cache = { at: Date.now(), release: cache?.release ?? null, error: e.name === 'TimeoutError' ? 'timed out' : e.message.slice(0, 200) };
|
|
124
|
+
})
|
|
125
|
+
.finally(() => {
|
|
126
|
+
inflight = null;
|
|
127
|
+
});
|
|
128
|
+
await inflight;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What is cached (the page shows it; it never waits for a request). */
|
|
132
|
+
export function cachedRelease() {
|
|
133
|
+
return cache;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Forget the cache (tests). */
|
|
137
|
+
export function resetReleaseCache() {
|
|
138
|
+
cache = null;
|
|
139
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import type { Client } from './db.ts';
|
|
3
|
+
import { owner } from './db.ts';
|
|
4
|
+
import type { DatasetRow } from './datasets.ts';
|
|
5
|
+
import { createApp, type CheckedApp } from './builder/newapp.ts';
|
|
6
|
+
|
|
7
|
+
// (sprint 110, migration 120) Reporting applications (APEX 26.1: Data Reporter → Create Reporting
|
|
8
|
+
// Application): a pgkiln application of type 'reporting' on one or more datasets of the workspace, with the
|
|
9
|
+
// Data Reporter region as its engine. Users sign in with accounts of the user directory (the workspace's
|
|
10
|
+
// sign-in); their roles decide what they may do:
|
|
11
|
+
//
|
|
12
|
+
// viewer runs published reports (every user with access, also without a role)
|
|
13
|
+
// editor builds, saves, publishes and unpublishes reports of their own
|
|
14
|
+
// administrator also unpublishes and deletes anyone's reports
|
|
15
|
+
//
|
|
16
|
+
// The application's database role reads only the datasets' tables and views (SELECT, nothing else), so every
|
|
17
|
+
// report runs through its grants and row level security.
|
|
18
|
+
|
|
19
|
+
export const REPORTING_ROLES = ['viewer', 'editor', 'administrator'] as const;
|
|
20
|
+
export const MAX_REPORTING_DATASETS = 10;
|
|
21
|
+
|
|
22
|
+
/** The Data Reporter region's settings of a reporting application on these datasets. */
|
|
23
|
+
export function reportingConfig(name: string, datasets: { name: string }[]) {
|
|
24
|
+
return {
|
|
25
|
+
sources: datasets.map((d) => ({ id: d.name, dataset: d.name })),
|
|
26
|
+
publishing: true,
|
|
27
|
+
edit_authz: 'EDITOR',
|
|
28
|
+
share_authz: 'EDITOR',
|
|
29
|
+
admin_authz: 'ADMIN',
|
|
30
|
+
welcome_title: name,
|
|
31
|
+
page_size: 25,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a reporting application in the caller's owner transaction: createApp() (schema = the first dataset's,
|
|
37
|
+
* the first user an administrator), then the type, the roles' authorization schemes, page 1 with the Data
|
|
38
|
+
* Reporter, and the role's grants narrowed to SELECT on the datasets' tables and views.
|
|
39
|
+
*/
|
|
40
|
+
export async function createReportingApp(c: Client, a: CheckedApp, datasets: DatasetRow[]): Promise<{ id: number; existingAccount: boolean }> {
|
|
41
|
+
if (!datasets.length) throw new Error('Choose at least one dataset.');
|
|
42
|
+
if (datasets.length > MAX_REPORTING_DATASETS) throw new Error(`A reporting application has at most ${MAX_REPORTING_DATASETS} datasets.`);
|
|
43
|
+
const app = await createApp(c, a);
|
|
44
|
+
const R = pg.escapeIdentifier(a.role);
|
|
45
|
+
// read only: nothing but SELECT on the datasets' objects (createApp granted the whole parsing schema)
|
|
46
|
+
const S = pg.escapeIdentifier(a.schema);
|
|
47
|
+
await c.query(`revoke all on all tables in schema ${S} from ${R}`);
|
|
48
|
+
await c.query(`revoke all on all sequences in schema ${S} from ${R}`);
|
|
49
|
+
await c.query(`revoke all on all functions in schema ${S} from ${R}`);
|
|
50
|
+
await c.query(`alter default privileges in schema ${S} revoke all on tables from ${R}`);
|
|
51
|
+
await c.query(`alter default privileges in schema ${S} revoke all on sequences from ${R}`);
|
|
52
|
+
await c.query(`alter default privileges in schema ${S} revoke all on functions from ${R}`);
|
|
53
|
+
for (const d of datasets) {
|
|
54
|
+
await c.query(`grant usage on schema ${pg.escapeIdentifier(d.schema_name)} to ${R}`);
|
|
55
|
+
for (const t of d.tables) {
|
|
56
|
+
const exists = (await c.query('select 1 from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2', [d.schema_name, t.table])).rowCount;
|
|
57
|
+
if (exists) await c.query(`grant select on ${pg.escapeIdentifier(d.schema_name)}.${pg.escapeIdentifier(t.table)} to ${R}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await c.query(`update meta.app set app_type = 'reporting', access_control = 'assigned', updated_at = now() where id = $1`, [app.id]);
|
|
62
|
+
await c.query(`update meta.authz_scheme set value = 'administrator', error_message = 'Only administrators of the reports can do this.' where app_id = $1 and name = 'ADMIN'`, [app.id]);
|
|
63
|
+
await c.query(
|
|
64
|
+
`insert into meta.authz_scheme (app_id, name, type, value, error_message)
|
|
65
|
+
values ($1, 'EDITOR', 'sql', 'meta.has_role(''editor'') or meta.has_role(''administrator'')', 'Only editors can change reports.')`,
|
|
66
|
+
[app.id],
|
|
67
|
+
);
|
|
68
|
+
await c.query(`update meta.app_access set roles = '{administrator}' where app_id = $1`, [app.id]);
|
|
69
|
+
|
|
70
|
+
// page 1: the Data Reporter instead of the hero
|
|
71
|
+
const page = (await c.query<{ id: number }>(`update meta.page set name = 'Reports', title = 'Reports' where app_id = $1 and page_no = 1 returning id`, [app.id])).rows[0];
|
|
72
|
+
await c.query('delete from meta.region where page_id = $1', [page.id]);
|
|
73
|
+
await c.query(`insert into meta.region (page_id, seq, title, type, columns, template, config) values ($1, 10, 'Reports', 'data_reporter', 12, 'standard', $2)`, [
|
|
74
|
+
page.id, JSON.stringify(reportingConfig(a.name, datasets)),
|
|
75
|
+
]);
|
|
76
|
+
await c.query(`update meta.nav_entry set label = 'Reports', icon = 'chart' where app_id = $1 and target_page = 1`, [app.id]);
|
|
77
|
+
return app;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** (sprint 110) The number of reporting applications of a workspace (the workspace home's summary; meta.reporting_app_count). */
|
|
81
|
+
export async function reportingAppCount(workspaceId: number): Promise<number> {
|
|
82
|
+
return (await owner.one<{ n: number }>('select meta.reporting_app_count($1) as n', [workspaceId]))?.n ?? 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The reporting applications of a workspace, with the datasets they use (from the region's settings). */
|
|
86
|
+
export async function reportingApps(workspaceId: number) {
|
|
87
|
+
return (
|
|
88
|
+
await owner.query<{ id: number; alias: string; name: string; datasets: string[]; users: number; reports: number }>(
|
|
89
|
+
`select a.id, a.alias, a.name,
|
|
90
|
+
coalesce((select array_agg(distinct s ->> 'dataset' order by s ->> 'dataset')
|
|
91
|
+
from meta.page p join meta.region r on r.page_id = p.id, jsonb_array_elements(case when jsonb_typeof(r.config -> 'sources') = 'array' then r.config -> 'sources' else '[]' end) s
|
|
92
|
+
where p.app_id = a.id and r.type = 'data_reporter' and s ? 'dataset'), '{}') as datasets,
|
|
93
|
+
(select count(*)::int from meta.app_access x where x.app_id = a.id) as users,
|
|
94
|
+
(select count(*)::int from meta.data_report d where d.app_id = a.id and d.shared) as reports
|
|
95
|
+
from meta.app a
|
|
96
|
+
where a.app_type = 'reporting' and meta.app_workspace(a.id) = $1
|
|
97
|
+
order by lower(a.name), a.id`,
|
|
98
|
+
[workspaceId],
|
|
99
|
+
)
|
|
100
|
+
).rows;
|
|
101
|
+
}
|