mirror of
https://github.com/penpot/penpot.git
synced 2026-07-23 06:28:14 +00:00
🎉 Add error reports API and CLI tool
Implement RPC methods for querying server error reports with pagination and filtering. Add CLI tool (tools/error-reports.mjs) for convenient access with table and JSON output formats. Extract profile-id from audit events and logging context for better error categorization. Build improved HREF using request path when available. AI-assisted-by: qwen3.7-plus
This commit is contained in:
parent
018d840bab
commit
2344ba22a6
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
# Penpot API configuration for error-reports CLI tool
|
||||
PENPOT_API_URI=http://localhost:3450
|
||||
PENPOT_ACCESS_TOKEN=your-access-token-here
|
||||
228
.serena/memories/scripts/error-reports.md
Normal file
228
.serena/memories/scripts/error-reports.md
Normal file
@ -0,0 +1,228 @@
|
||||
# Error Reports CLI Tool
|
||||
|
||||
`scripts/error-reports.mjs` is a Node.js CLI tool for querying Penpot error reports via the RPC API. Provides access to error logs with filtering, pagination, and multiple output formats.
|
||||
|
||||
## When to use
|
||||
|
||||
- Querying error reports from the database for debugging or analysis
|
||||
- Filtering errors by source, kind, tenant, or backend version
|
||||
- Exporting error data in JSON or table format
|
||||
- Investigating specific error reports by ID
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js with `commander` and `dotenv` packages installed (in root `package.json`)
|
||||
- Running Penpot backend with error-reports RPC endpoints
|
||||
- Access token with `error-reports:read` permission
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
```bash
|
||||
PENPOT_API_URI=http://localhost:3450
|
||||
PENPOT_ACCESS_TOKEN=<your-token>
|
||||
```
|
||||
|
||||
Grant the required permission to your access token:
|
||||
|
||||
```sql
|
||||
UPDATE access_token
|
||||
SET perms = ARRAY['error-reports:read']::text[],
|
||||
updated_at = now()
|
||||
WHERE id = '<token-uuid>';
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs <command> [options]
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
#### `list` - List error reports with pagination and filters
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs list [options]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `-l, --limit <n>` | Max items per page (max: 200) | `50` |
|
||||
| `--since <date>` | ISO timestamp (fetch errors before this date) | — |
|
||||
| `--since-id <uuid>` | Fetch errors before this ID (cursor pagination) | — |
|
||||
| `-s, --source <name>` | Filter by source (see source names below) | — |
|
||||
| `-p, --profile-id <uuid>` | Filter by profile ID | — |
|
||||
| `-k, --kind <kind>` | Filter by kind (string) | — |
|
||||
| `-t, --tenant <tenant>` | Filter by tenant (string) | — |
|
||||
| `--version <version>` | Filter by version | — |
|
||||
| `--hint <text>` | Filter by hint (ILIKE match) | — |
|
||||
| `-a, --all` | Fetch all pages automatically | `false` |
|
||||
| `-f, --format <type>` | Output format: `json` or `table` | `json` |
|
||||
| `--env <path>` | Custom .env file path | `.env` |
|
||||
| `-h, --help` | Show help message | — |
|
||||
|
||||
#### `get` - Get a single error report by ID
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs get [options]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Description | Required |
|
||||
|------|-------------|----------|
|
||||
| `--id <uuid>` | Error report ID | Yes (or --error-id) |
|
||||
| `--error-id <id>` | Error report error-id | Yes (or --id) |
|
||||
| `-f, --format <type>` | Output format: `json` or `table` | No (default: `json`) |
|
||||
| `--env <path>` | Custom .env file path | No (default: `.env`) |
|
||||
| `-h, --help` | Show help message | No |
|
||||
|
||||
## Source Names
|
||||
|
||||
The `--source` filter accepts these values:
|
||||
|
||||
- `logging`
|
||||
- `audit-log`
|
||||
- `rlimit`
|
||||
|
||||
## Examples
|
||||
|
||||
### List recent errors
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --limit 10
|
||||
```
|
||||
|
||||
### Filter by source
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --source audit-log --limit 20
|
||||
```
|
||||
|
||||
### Filter by kind
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --kind exception-page
|
||||
```
|
||||
|
||||
### Filter by tenant
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --tenant production
|
||||
```
|
||||
|
||||
### Filter by version
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --version 2.1.0
|
||||
```
|
||||
|
||||
### Search by hint (partial match)
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --hint "NullPointerException"
|
||||
```
|
||||
|
||||
### Fetch all errors with pagination
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format json
|
||||
```
|
||||
|
||||
### Get specific error by ID
|
||||
```bash
|
||||
./scripts/error-reports.mjs get --id 550e8400-e29b-41d4-a716-446655440000
|
||||
```
|
||||
|
||||
### Output as JSON
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --limit 5 --format json
|
||||
```
|
||||
|
||||
### Combine filters
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --source audit-log --kind exception-page --tenant production --limit 50
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
### Table (default)
|
||||
Human-readable table format for terminal display:
|
||||
|
||||
```
|
||||
Found 15 error reports
|
||||
|
||||
ID | Created At | Source | Profile ID | Kind | Hint
|
||||
-------------------------------------+---------------------+-----------+--------------------------------------+----------------+------------------
|
||||
550e8400-e29b-41d4-a716-446655440000 | 2026-01-20 10:30:00 | audit-log | e98bb95f-573d-8137-8008-252580aa456d | exception-page | Error description
|
||||
abc12345-e29b-41d4-a716-446655440001 | 2026-01-20 10:29:00 | logging | - | error | Another error that is very long and ne...
|
||||
|
||||
More results: use --since 2026-01-20T10:28:00Z --since-id def45678-e29b-41d4-a716-446655440002
|
||||
```
|
||||
|
||||
### JSON
|
||||
Returns structured JSON with error details and pagination metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"createdAt": "2026-01-20T10:30:00Z",
|
||||
"source": "audit-log",
|
||||
"profileId": "e98bb95f-573d-8137-8008-252580aa456d",
|
||||
"kind": "exception-page",
|
||||
"tenant": "production",
|
||||
"version": "2.1.0",
|
||||
"hint": "Error description"
|
||||
}
|
||||
],
|
||||
"nextSince": "2026-01-20T10:29:00Z",
|
||||
"nextId": "next-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
### Manual pagination
|
||||
Use `--since` and `--since-id` with values from `nextSince` and `nextId` in the response:
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --limit 50
|
||||
# Use nextSince and nextId from response
|
||||
./scripts/error-reports.mjs list --limit 50 --since "2026-01-20T10:29:00Z" --since-id "next-uuid"
|
||||
```
|
||||
|
||||
### Automatic pagination
|
||||
Use `--all` to fetch all pages automatically:
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all
|
||||
```
|
||||
|
||||
## Key principles
|
||||
|
||||
- **Authentication required** - Uses access token with `error-reports:read` permission
|
||||
- **API endpoint configurable** - Set via `PENPOT_API_URI` in `.env` file
|
||||
- **Table is default format** - Use `--format json` for structured JSON output
|
||||
- **Pagination is automatic with --all** - Fetches all pages without manual cursor management
|
||||
- **Filters are combinable** - All filter options can be used together
|
||||
- **Both flag formats supported** - `--option=value` and `--option value` both work
|
||||
|
||||
## Error handling
|
||||
|
||||
The tool provides helpful error messages for common issues:
|
||||
|
||||
- **Missing configuration**: Shows setup instructions for `.env` file
|
||||
- **Authentication errors (401)**: Indicates invalid or expired token
|
||||
- **Authorization errors (403)**: Indicates missing `error-reports:read` permission
|
||||
- **RPC errors**: Displays error code and message from the API
|
||||
|
||||
## Integration with other scripts
|
||||
|
||||
- **jq**: Pipe JSON output to `jq` for further processing
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format json | jq '.items[] | {id, kind, hint}'
|
||||
```
|
||||
- **grep/search**: Filter output by specific patterns
|
||||
- **Redirect**: Save output to files for analysis
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format json > errors.json
|
||||
```
|
||||
@ -10,9 +10,10 @@ penpot - error list
|
||||
<a href="/dbg"> [BACK]</a>
|
||||
<h1>Error reports (last 300)</h1>
|
||||
|
||||
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
|
||||
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
|
||||
<a class="{% if version = 5 %}strong{% endif %}" href="?version=5">[RLIMIT REPORTS]</a>
|
||||
<a class="{% if source = 0 %}strong{% endif %}" href="?source=0">[ALL ERRORS]</a>
|
||||
<a class="{% if source = 3 %}strong{% endif %}" href="?source=3">[BACKEND ERRORS]</a>
|
||||
<a class="{% if source = 4 %}strong{% endif %}" href="?source=4">[FRONTEND ERRORS]</a>
|
||||
<a class="{% if source = 5 %}strong{% endif %}" href="?source=5">[RLIMIT REPORTS]</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="horizontal-list">
|
||||
|
||||
@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
||||
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
|
||||
<div>[<a href="#head">head</a>]</div>
|
||||
<div>[<a href="#props">props</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
|
||||
@ -6,11 +6,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
||||
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
|
||||
<div>[<a href="#head">head</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
{% if report %}
|
||||
<div>[<a href="#report">report</a>]</div>
|
||||
{% if trace %}
|
||||
<div>[<a href="#trace">trace</a>]</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<main>
|
||||
@ -20,7 +20,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
||||
<div class="table-val">
|
||||
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
|
||||
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
|
||||
<h2><span class="not-important">Origin:</span> <br/> {{origin}}</h2>
|
||||
<h2><span class="not-important">Kind:</span> <br/> {{kind}}</h2>
|
||||
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
@ -33,11 +33,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if report %}
|
||||
{% if trace %}
|
||||
<div class="table-row multiline">
|
||||
<div id="report" class="table-key">REPORT:</div>
|
||||
<div id="trace" class="table-key">TRACE:</div>
|
||||
<div class="table-val">
|
||||
<pre>{{report}}</pre>
|
||||
<pre>{{trace}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -6,10 +6,10 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
||||
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
|
||||
<div>[<a href="#head">head</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
<div>[<a href="#result">result</a>]</div>
|
||||
<div>[<a href="#value">value</a>]</div>
|
||||
</nav>
|
||||
<main>
|
||||
<div class="table">
|
||||
@ -30,9 +30,9 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
|
||||
</div>
|
||||
|
||||
<div class="table-row multiline">
|
||||
<div id="result" class="table-key">RESULT: </div>
|
||||
<div id="value" class="table-key">VALUE: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{result}}</pre>
|
||||
<pre>{{value}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -43,7 +43,6 @@
|
||||
(let [{:keys [type claims]} (get request ::http/auth-data)]
|
||||
(if (= :token type)
|
||||
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
|
||||
;; FIXME: revisit this, this data looks unused
|
||||
(handler (cond-> request
|
||||
(some? perms)
|
||||
(assoc ::perms perms)
|
||||
|
||||
@ -230,25 +230,28 @@
|
||||
(-> (io/resource "app/templates/error-report.v3.tmpl")
|
||||
(tmpl/render (-> content
|
||||
(assoc :id id)
|
||||
(assoc :version 3)
|
||||
(assoc :source 3)
|
||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
||||
|
||||
(render-template-v4 [{:keys [content id created-at]}]
|
||||
(-> (io/resource "app/templates/error-report.v4.tmpl")
|
||||
(tmpl/render (-> content
|
||||
(assoc :id id)
|
||||
(assoc :version 4)
|
||||
(assoc :source 4)
|
||||
(assoc :kind (or (:kind content) (:origin content)))
|
||||
(assoc :trace (or (:trace content) (:report content)))
|
||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
||||
|
||||
(render-template-v5 [{:keys [content id created-at]}]
|
||||
(-> (io/resource "app/templates/error-report.v5.tmpl")
|
||||
(tmpl/render (-> content
|
||||
(assoc :id id)
|
||||
(assoc :version 5)
|
||||
(assoc :source 5)
|
||||
(assoc :value (or (:value content) (:result content)))
|
||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
|
||||
|
||||
(if-let [report (get-report request)]
|
||||
(let [result (case (:version report)
|
||||
(let [result (case (:source report)
|
||||
1 (render-template-v1 report)
|
||||
2 (render-template-v2 report)
|
||||
3 (render-template-v3 report)
|
||||
@ -265,18 +268,19 @@
|
||||
"SELECT id, created_at,
|
||||
content->>'~:hint' AS hint
|
||||
FROM server_error_report
|
||||
WHERE version = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 300")
|
||||
WHERE (version = ? OR source = ? OR ? = 0)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 300")
|
||||
|
||||
(defn- error-list-handler
|
||||
[{:keys [::db/pool]} {:keys [params]}]
|
||||
(let [version (or (some-> (get params :version) parse-long) 3)
|
||||
items (->> (db/exec! pool [sql:error-reports version])
|
||||
(map #(update % :created-at ct/format-inst :rfc1123)))]
|
||||
(let [source (or (some-> (get params :source) parse-long) 3)
|
||||
items (->> (db/exec! pool [sql:error-reports source source source])
|
||||
(map #(update % :created-at ct/format-inst :rfc1123)))]
|
||||
|
||||
{::yres/status 200
|
||||
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
|
||||
(tmpl/render {:items items :version version}))
|
||||
(tmpl/render {:items items :source source}))
|
||||
::yres/headers {"content-type" "text/html; charset=utf-8"
|
||||
"x-robots-tag" "noindex"}}))
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
@ -30,11 +31,12 @@
|
||||
(defonce enabled (atom true))
|
||||
|
||||
(defn- persist-on-database!
|
||||
[pool id version report]
|
||||
[pool id source report]
|
||||
(when-not (db/read-only? pool)
|
||||
(db/insert! pool :server-error-report
|
||||
{:id id
|
||||
:version version
|
||||
:source source
|
||||
:version source ;; backward compatibility with old code that reads version column
|
||||
:content (db/tjson report)})))
|
||||
|
||||
(defn- concurrent-exception?
|
||||
@ -56,19 +58,27 @@
|
||||
(assoc :backend/version (:full cf/version))
|
||||
(assoc :logger/name logger)
|
||||
(assoc :logger/level level)
|
||||
(dissoc :request/params :value :params :data))]
|
||||
(dissoc :request/params :value :params :data))
|
||||
|
||||
href (if-let [path (:request/path context)]
|
||||
(str (u/join (cf/get :public-uri) path))
|
||||
(str (cf/get :public-uri)))]
|
||||
|
||||
(merge
|
||||
{:context (-> (into (sorted-map) ctx)
|
||||
(pp/pprint-str :length 50))
|
||||
:props (pp/pprint-str props :length 50)
|
||||
:hint (or (when-let [message (ex-message cause)]
|
||||
(if-let [props-hint (:hint props)]
|
||||
(str props-hint ": " message)
|
||||
message))
|
||||
@message)
|
||||
:trace (or (::trace record)
|
||||
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
|
||||
{:context (-> (into (sorted-map) ctx)
|
||||
(pp/pprint-str :length 50))
|
||||
:props (pp/pprint-str props :length 50)
|
||||
:hint (or (when-let [message (ex-message cause)]
|
||||
(if-let [props-hint (:hint props)]
|
||||
(str props-hint ": " message)
|
||||
message))
|
||||
@message)
|
||||
:trace (or (::trace record)
|
||||
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))
|
||||
:tenant (cf/get :tenant)
|
||||
:version (:full cf/version)
|
||||
:profile-id (some-> (:request/profile-id context) str)
|
||||
:href href}
|
||||
|
||||
(when-let [params (or (:request/params context) (:params context))]
|
||||
{:params (pp/pprint-str params :length 20 :level 20)})
|
||||
@ -97,7 +107,7 @@
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||
|
||||
(defn- audit-event->report
|
||||
[{:keys [context props ip-addr] :as record}]
|
||||
[{:keys [context props ip-addr profile-id] :as record}]
|
||||
(let [context
|
||||
(reduce-kv (fn [context k v]
|
||||
(let [k' (keyword "frontend" (name k))]
|
||||
@ -115,12 +125,15 @@
|
||||
(assoc :backend/version (:full cf/version))
|
||||
(assoc :frontend/ip-addr ip-addr))]
|
||||
|
||||
{:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :length 50))
|
||||
:origin (:name record)
|
||||
:href (get props :href)
|
||||
:hint (get props :hint)
|
||||
:report (get props :report)}))
|
||||
{:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :length 50))
|
||||
:kind (:name record)
|
||||
:profile-id (some-> profile-id str)
|
||||
:href (get props :href)
|
||||
:hint (get props :hint)
|
||||
:trace (get props :report)
|
||||
:tenant (cf/get :tenant)
|
||||
:version (:full cf/version)}))
|
||||
|
||||
(defn- handle-audit-event
|
||||
"Convert the log record into a report object and persist it on the database"
|
||||
@ -153,10 +166,13 @@
|
||||
(-> (into (sorted-map) result)
|
||||
(dissoc ::rlimit/method)))))]
|
||||
|
||||
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
|
||||
:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :length 50))
|
||||
:result (pp/pprint-str result :length 50)}))
|
||||
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
|
||||
:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :length 50))
|
||||
:value (pp/pprint-str result :length 50)
|
||||
:tenant (cf/get :tenant)
|
||||
:version (:full cf/version)
|
||||
:href (str (cf/get :public-uri))}))
|
||||
|
||||
(defn- handle-rlimit-event
|
||||
"Convert the log record into a report object and persist it on the database"
|
||||
|
||||
@ -680,7 +680,7 @@
|
||||
(if-let [sns (namespace o)]
|
||||
(do (require (symbol sns))
|
||||
(test/test-vars [(resolve o)]))
|
||||
(test/test-ns o)))))
|
||||
(test/test-ns o)))))
|
||||
|
||||
(defn -main
|
||||
[& _args]
|
||||
|
||||
@ -493,7 +493,10 @@
|
||||
:fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")}
|
||||
|
||||
{:name "0151-mod-file-tagged-object-thumbnail-table"
|
||||
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}
|
||||
|
||||
{:name "0152-rename-version-and-add-indexes-to-server-error-report"
|
||||
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
-- Add source column (keep version column as-is for backward compatibility)
|
||||
ALTER TABLE server_error_report
|
||||
ADD COLUMN source integer;
|
||||
|
||||
-- Trigger function to sync version -> source (backward compatibility with old code)
|
||||
CREATE OR REPLACE FUNCTION server_error_report__sync_version_to_source()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.version IS NOT NULL AND NEW.source IS NULL THEN
|
||||
NEW.source := NEW.version;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger fires on INSERT or UPDATE OF version column
|
||||
CREATE TRIGGER server_error_report__sync_version_to_source__tgr
|
||||
BEFORE INSERT OR UPDATE OF version ON server_error_report
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION server_error_report__sync_version_to_source();
|
||||
|
||||
-- Backfill existing rows
|
||||
UPDATE server_error_report SET source = version WHERE source IS NULL;
|
||||
|
||||
-- Drop old version index
|
||||
DROP INDEX IF EXISTS server_error_report__version__idx;
|
||||
|
||||
-- Create new source index
|
||||
CREATE INDEX server_error_report__source__idx
|
||||
ON server_error_report (source);
|
||||
|
||||
-- Content-based indexes
|
||||
CREATE INDEX server_error_report__content_kind__idx
|
||||
ON server_error_report (COALESCE(content->>'~:kind', content->>'~:origin'));
|
||||
|
||||
CREATE INDEX server_error_report__content_tenant__idx
|
||||
ON server_error_report ((content->>'~:tenant'));
|
||||
|
||||
CREATE INDEX server_error_report__content_version__idx
|
||||
ON server_error_report ((content->>'~:version'));
|
||||
|
||||
-- Index for pagination
|
||||
CREATE INDEX server_error_report__created_at_id__idx
|
||||
ON server_error_report (created_at DESC, id DESC);
|
||||
@ -41,6 +41,7 @@
|
||||
[app.util.cache :as cache]
|
||||
[app.util.inet :as inet]
|
||||
[app.util.services :as sv]
|
||||
[clojure.set :as set]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
@ -102,8 +103,10 @@
|
||||
session-id (yreq/get-header request "x-session-id")
|
||||
|
||||
key-id (get request ::http/auth-key-id)
|
||||
profile-id (or (::session/profile-id request)
|
||||
(::actoken/profile-id request)
|
||||
session-pid (::session/profile-id request)
|
||||
token-pid (::actoken/profile-id request)
|
||||
profile-id (or session-pid
|
||||
token-pid
|
||||
(if key-id uuid/zero nil))
|
||||
|
||||
ip-addr (inet/parse-request request)
|
||||
@ -116,7 +119,15 @@
|
||||
(assoc ::session-id (some-> session-id uuid/parse*))
|
||||
(assoc ::cond/key etag)
|
||||
(cond-> (uuid? profile-id)
|
||||
(assoc ::profile-id profile-id)))
|
||||
(assoc ::profile-id profile-id))
|
||||
(cond-> (uuid? session-pid)
|
||||
(assoc ::auth-type :session))
|
||||
(cond-> (and (not (uuid? session-pid))
|
||||
(uuid? token-pid))
|
||||
(-> (assoc ::auth-type :token)
|
||||
(assoc ::token-perms (set (::actoken/perms request #{})))))
|
||||
(cond-> key-id
|
||||
(assoc ::auth-key-id key-id)))
|
||||
|
||||
data (with-meta data
|
||||
{::http/request request})
|
||||
@ -151,13 +162,40 @@
|
||||
|
||||
(defn- wrap-authentication
|
||||
[_ f mdata]
|
||||
(fn [cfg params]
|
||||
(let [profile-id (::profile-id params)]
|
||||
(if (and (::auth mdata true) (not (uuid? profile-id)))
|
||||
(ex/raise :type :authentication
|
||||
:code :authentication-required
|
||||
:hint "authentication required for this endpoint")
|
||||
(f cfg params)))))
|
||||
(let [required-auth? (::auth mdata true)
|
||||
required-auth-type (::auth-type mdata)
|
||||
required-perms (into #{} (::perms mdata))]
|
||||
(fn [cfg params]
|
||||
(let [profile-id (::profile-id params)
|
||||
auth-type (::auth-type params)
|
||||
token-perms (set (::token-perms params #{}))]
|
||||
(cond
|
||||
(and required-auth? (not (uuid? profile-id)))
|
||||
(ex/raise :type :authentication
|
||||
:code :authentication-required
|
||||
:hint "authentication required for this endpoint")
|
||||
|
||||
(and (= required-auth-type :token)
|
||||
(not= auth-type :token))
|
||||
(ex/raise :type :authorization
|
||||
:code :token-auth-required
|
||||
:hint "access token authentication required for this endpoint")
|
||||
|
||||
(and (seq required-perms)
|
||||
(not= auth-type :token))
|
||||
(ex/raise :type :authorization
|
||||
:code :token-auth-required
|
||||
:hint "access token authentication required for this endpoint")
|
||||
|
||||
(and (seq required-perms)
|
||||
(not (set/subset? required-perms token-perms)))
|
||||
(ex/raise :type :authorization
|
||||
:code :missing-perms
|
||||
:hint "missing required permissions"
|
||||
:required required-perms)
|
||||
|
||||
:else
|
||||
(f cfg params))))))
|
||||
|
||||
(defn- wrap-db-transaction
|
||||
[_ f mdata]
|
||||
@ -332,6 +370,7 @@
|
||||
'app.rpc.commands.binfile
|
||||
'app.rpc.commands.comments
|
||||
'app.rpc.commands.demo
|
||||
'app.rpc.commands.error-reports
|
||||
'app.rpc.commands.files
|
||||
'app.rpc.commands.files-create
|
||||
'app.rpc.commands.files-share
|
||||
|
||||
@ -29,6 +29,10 @@
|
||||
AND type = 'mcp'")
|
||||
|
||||
(defn create-access-token
|
||||
"Create an access token with empty perms.
|
||||
|
||||
Elevated permissions (e.g. error-reports:read) are not assignable via
|
||||
the public API; grant them with SQL or `repl:grant-access-token-perm`."
|
||||
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
|
||||
(let [token-id (uuid/next)
|
||||
expires-at (some-> expiration (ct/in-future))
|
||||
@ -61,6 +65,27 @@
|
||||
[cfg profile-id name expiration]
|
||||
(db/tx-run! cfg create-access-token profile-id name expiration))
|
||||
|
||||
(def ^:private sql:grant-access-token-perm
|
||||
"UPDATE access_token
|
||||
SET perms = (
|
||||
SELECT ARRAY(
|
||||
SELECT DISTINCT unnest(perms || ARRAY[?]::text[])
|
||||
)
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE id = ?
|
||||
RETURNING id, perms")
|
||||
|
||||
(defn repl:grant-access-token-perm
|
||||
"Append a permission string to an access token (operator/SQL path).
|
||||
|
||||
Example: (repl:grant-access-token-perm cfg token-id \"error-reports:read\")"
|
||||
[cfg token-id perm]
|
||||
(db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [row (db/exec-one! conn [sql:grant-access-token-perm perm token-id])]
|
||||
(some-> row (update :perms db/decode-pgarray #{}))))))
|
||||
|
||||
(def ^:private schema:create-access-token
|
||||
[:map {:title "create-access-token"}
|
||||
[:name [:string {:max 250 :min 1}]]
|
||||
|
||||
180
backend/src/app/rpc/commands/error_reports.clj
Normal file
180
backend/src/app/rpc/commands/error_reports.clj
Normal file
@ -0,0 +1,180 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.rpc.commands.error-reports
|
||||
"RPC methods for listing and fetching server error reports.
|
||||
|
||||
Access is restricted to access-token authentication with the
|
||||
`error-reports:read` permission. Grant via SQL (or REPL helper):
|
||||
|
||||
UPDATE access_token
|
||||
SET perms = ARRAY['error-reports:read']::text[],
|
||||
updated_at = now()
|
||||
WHERE id = '<token-uuid>';
|
||||
|
||||
Call with: Authorization: Token <jwt>"
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:private max-limit 200)
|
||||
(def ^:private default-limit 50)
|
||||
|
||||
(def ^:private source-names
|
||||
{1 "legacy-v1"
|
||||
2 "legacy-v2"
|
||||
3 "logging"
|
||||
4 "audit-log"
|
||||
5 "rlimit"})
|
||||
|
||||
(defn- source->name
|
||||
[source]
|
||||
(get source-names source (str "unknown-" source)))
|
||||
|
||||
(defn- name->source
|
||||
[name]
|
||||
(some (fn [[k v]] (when (= v name) k)) source-names))
|
||||
|
||||
(def ^:private schema:error-report-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:created-at ct/schema:inst]
|
||||
[:source ::sm/text]
|
||||
[:profile-id {:optional true} ::sm/text]
|
||||
[:kind {:optional true} ::sm/text]
|
||||
[:tenant {:optional true} ::sm/text]
|
||||
[:version {:optional true} ::sm/text]
|
||||
[:hint {:optional true} ::sm/text]])
|
||||
|
||||
(def ^:private schema:get-error-reports-params
|
||||
[:map {:title "get-error-reports-params"}
|
||||
[:since {:optional true} ct/schema:inst]
|
||||
[:since-id {:optional true} ::sm/uuid]
|
||||
[:limit {:optional true}
|
||||
[:and ::sm/int [:fn #(<= 1 % max-limit)]]]
|
||||
[:source {:optional true} ::sm/text]
|
||||
[:profile-id {:optional true} ::sm/text]
|
||||
[:kind {:optional true} ::sm/text]
|
||||
[:tenant {:optional true} ::sm/text]
|
||||
[:version {:optional true} ::sm/text]
|
||||
[:hint {:optional true} ::sm/text]])
|
||||
|
||||
(def ^:private schema:get-error-reports-result
|
||||
[:map
|
||||
[:items [:vector schema:error-report-summary]]
|
||||
[:next-since {:optional true} ct/schema:inst]
|
||||
[:next-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(def ^:private schema:error-report
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:created-at ct/schema:inst]
|
||||
[:source ::sm/text]
|
||||
[:profile-id {:optional true} ::sm/text]
|
||||
[:kind {:optional true} ::sm/text]
|
||||
[:tenant {:optional true} ::sm/text]
|
||||
[:version {:optional true} ::sm/text]
|
||||
[:hint {:optional true} ::sm/text]
|
||||
[:report {:optional true} ::sm/text]
|
||||
[:href {:optional true} ::sm/text]
|
||||
[:context {:optional true} ::sm/text]])
|
||||
|
||||
(def ^:private schema:get-error-report-params
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def ^:private base-list-sql
|
||||
(str "SELECT id, created_at, source, "
|
||||
"COALESCE(content->>'~:kind', content->>'~:origin') AS kind, "
|
||||
"content->>'~:tenant' AS tenant, "
|
||||
"content->>'~:version' AS version, "
|
||||
"content->>'~:hint' AS hint, "
|
||||
"content->>'~:profile-id' AS profile_id "
|
||||
"FROM server_error_report"))
|
||||
|
||||
(defn- build-list-query
|
||||
[{:keys [since since-id source profile-id kind tenant version hint limit]
|
||||
:or {limit default-limit}}]
|
||||
(let [source-id (when source (name->source source))
|
||||
clauses (keep identity
|
||||
[(when source-id
|
||||
{:where "source = ?" :params [source-id]})
|
||||
(when profile-id
|
||||
{:where "content->>'~:profile-id' = ?"
|
||||
:params [profile-id]})
|
||||
(when kind
|
||||
{:where "COALESCE(content->>'~:kind', content->>'~:origin') = ?"
|
||||
:params [kind]})
|
||||
(when tenant
|
||||
{:where "content->>'~:tenant' = ?"
|
||||
:params [tenant]})
|
||||
(when version
|
||||
{:where "content->>'~:version' = ?"
|
||||
:params [version]})
|
||||
(when hint
|
||||
{:where "content->>'~:hint' ILIKE ?"
|
||||
:params [(str "%" hint "%")]})
|
||||
(when since
|
||||
{:where "(created_at, id) < (?::timestamptz, ?::uuid)"
|
||||
:params [since (or since-id uuid/zero)]})])
|
||||
sql-parts (map :where clauses)
|
||||
sql-params (mapcat :params clauses)
|
||||
sql (str base-list-sql
|
||||
(when (seq sql-parts)
|
||||
(str " WHERE " (str/join " AND " sql-parts)))
|
||||
" ORDER BY created_at DESC, id DESC"
|
||||
" LIMIT ?")]
|
||||
(into [sql] (concat sql-params [limit]))))
|
||||
|
||||
(sv/defmethod ::get-error-reports
|
||||
{::doc/added "2.20"
|
||||
::rpc/auth-type :token
|
||||
::rpc/perms #{"error-reports:read"}
|
||||
::sm/params schema:get-error-reports-params
|
||||
::sm/result schema:get-error-reports-result}
|
||||
[cfg params]
|
||||
(let [limit (min (or (:limit params) default-limit) max-limit)
|
||||
params (assoc params :limit (inc limit))
|
||||
[sql & sql-args] (build-list-query params)
|
||||
rows (db/exec! cfg (into [sql] sql-args))]
|
||||
(if (seq rows)
|
||||
(let [items (->> (take limit rows)
|
||||
(mapv #(-> %
|
||||
(update :source source->name)
|
||||
d/without-nils)))
|
||||
last-item (peek items)
|
||||
has-more? (> (count rows) limit)]
|
||||
{:items items
|
||||
:next-since (when has-more? (:created-at last-item))
|
||||
:next-id (when has-more? (:id last-item))})
|
||||
{:items []})))
|
||||
|
||||
(sv/defmethod ::get-error-report
|
||||
{::doc/added "2.20"
|
||||
::rpc/auth-type :token
|
||||
::rpc/perms #{"error-reports:read"}
|
||||
::sm/params schema:get-error-report-params
|
||||
::sm/result schema:error-report}
|
||||
[cfg {:keys [id]}]
|
||||
(if-let [report (db/get-by-id cfg :server-error-report id {::db/check-deleted false})]
|
||||
(let [content (db/decode-transit-pgobject (:content report))]
|
||||
(-> report
|
||||
(dissoc :content)
|
||||
(merge content)
|
||||
(update :source source->name)
|
||||
(assoc :kind (or (:kind content) (:origin content)))
|
||||
(d/without-nils)))
|
||||
(ex/raise :type :not-found
|
||||
:code :report-not-found
|
||||
:hint (str "error report " id " not found"))))
|
||||
@ -142,7 +142,7 @@
|
||||
(ig/init-key key opts)))
|
||||
|
||||
(defmethod ig/suspend-key! ::urepl
|
||||
[_ name]
|
||||
[_ _]
|
||||
(l/inf :hint "keep urepl server"))
|
||||
|
||||
;; --- PREPL
|
||||
@ -180,7 +180,7 @@
|
||||
(ig/init-key key opts)))
|
||||
|
||||
(defmethod ig/suspend-key! ::prepl
|
||||
[_ name]
|
||||
[_ _]
|
||||
(l/inf :hint "keep prepl server"))
|
||||
|
||||
;; --- NREPL
|
||||
@ -209,5 +209,5 @@
|
||||
(ig/init-key key opts)))
|
||||
|
||||
(defmethod ig/suspend-key! ::nrepl
|
||||
[_ server]
|
||||
[_ _]
|
||||
(l/inf :hint "keep nrepl server"))
|
||||
|
||||
@ -536,12 +536,12 @@
|
||||
[id]
|
||||
(let [id (h/parse-uuid id)]
|
||||
(db/tx-run! sys/system (fn [cfg]
|
||||
(-> (db/update! cfg :file
|
||||
{:has-media-trimmed true}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(db/get-update-count)
|
||||
(pos?))))))
|
||||
(-> (db/update! cfg :file
|
||||
{:has-media-trimmed true}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(db/get-update-count)
|
||||
(pos?))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; DELETE/RESTORE OBJECTS (WITH CASCADE, SOFT)
|
||||
|
||||
@ -29,8 +29,7 @@
|
||||
(t/testing "create access token without expiration date"
|
||||
(let [params {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "token 1"
|
||||
:perms ["get-profile"]}
|
||||
:name "token 1"}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
@ -40,13 +39,25 @@
|
||||
(t/is (contains? result :id))
|
||||
(t/is (contains? result :created-at))
|
||||
(t/is (contains? result :updated-at))
|
||||
(t/is (contains? result :token)))))
|
||||
(t/is (contains? result :token))
|
||||
(t/is (not (contains? result :perms))))))
|
||||
|
||||
(t/testing "create access token ignores client-supplied perms"
|
||||
(let [params {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "token ignored-perms"
|
||||
:perms ["error-reports:read"]}
|
||||
out (th/command! params)]
|
||||
(t/is (nil? (:error out)))
|
||||
(let [result (:result out)
|
||||
row (th/db-get :access-token {:id (:id result)})]
|
||||
(t/is (not (contains? result :perms)))
|
||||
(t/is (= [] (db/decode-pgarray (:perms row) []))))))
|
||||
|
||||
(t/testing "create access token with expiration date in the future"
|
||||
(let [params {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "token 1"
|
||||
:perms ["get-profile"]
|
||||
:expiration "130h"}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
@ -64,7 +75,6 @@
|
||||
(let [params {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "token 1"
|
||||
:perms ["get-profile"]
|
||||
:expiration "-130h"}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
@ -85,11 +95,12 @@
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(let [[result :as results] (:result out)]
|
||||
(t/is (= 3 (count results)))
|
||||
(t/is (= 4 (count results)))
|
||||
(t/is (contains? result :id))
|
||||
(t/is (contains? result :created-at))
|
||||
(t/is (contains? result :updated-at))
|
||||
(t/is (not (contains? result :token))))))
|
||||
(t/is (not (contains? result :token)))
|
||||
(t/is (not (contains? result :perms))))))
|
||||
|
||||
(t/testing "delete access token"
|
||||
(let [params {::th/type :delete-access-token
|
||||
@ -107,14 +118,13 @@
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(let [results (:result out)]
|
||||
(t/is (= 2 (count results))))))
|
||||
(t/is (= 3 (count results))))))
|
||||
|
||||
(t/testing "get mcp token"
|
||||
(let [_ (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "token 1"
|
||||
:perms ["get-profile"]})
|
||||
:name "token 1"})
|
||||
{:keys [error result]}
|
||||
(th/command! {::th/type :get-current-mcp-token
|
||||
::rpc/profile-id (:id prof)})]
|
||||
@ -126,16 +136,14 @@
|
||||
(let [;; Create a regular token
|
||||
regular-out (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "regular token"
|
||||
:perms ["get-profile"]})
|
||||
:name "regular token"})
|
||||
regular-token (:result regular-out)
|
||||
|
||||
;; Create an MCP token
|
||||
mcp-out (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "mcp token"
|
||||
:perms []})
|
||||
:name "mcp token"})
|
||||
mcp-token (:result mcp-out)
|
||||
|
||||
;; Fetch all tokens
|
||||
@ -163,24 +171,21 @@
|
||||
first-out (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "first mcp"
|
||||
:perms []})
|
||||
:name "first mcp"})
|
||||
first-mcp (:result first-out)
|
||||
|
||||
;; Create second MCP token
|
||||
second-out (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "second mcp"
|
||||
:perms []})
|
||||
:name "second mcp"})
|
||||
second-mcp (:result second-out)
|
||||
|
||||
;; Create third MCP token
|
||||
third-out (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "third mcp"
|
||||
:perms []})
|
||||
:name "third mcp"})
|
||||
third-mcp (:result third-out)
|
||||
|
||||
;; Fetch all tokens
|
||||
|
||||
251
backend/test/backend_tests/rpc_commands_error_reports_test.clj
Normal file
251
backend/test/backend_tests/rpc_commands_error_reports_test.clj
Normal file
@ -0,0 +1,251 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en Espana SL
|
||||
|
||||
(ns backend-tests.rpc-commands-error-reports-test
|
||||
(:require
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(def ^:private error-reports-read #{"error-reports:read"})
|
||||
|
||||
(defn- insert-report!
|
||||
"Insert a raw error report row. content is a plain map (will be tjson-encoded)."
|
||||
[sys {:keys [id source content created-at]
|
||||
:or {created-at (ct/now)}}]
|
||||
(th/db-insert! :server-error-report
|
||||
{:id (or id (uuid/next))
|
||||
:source source
|
||||
:created-at created-at
|
||||
:content (db/tjson content)}))
|
||||
|
||||
(defn- token-cmd
|
||||
"Invoke an RPC method as an access token with the given perms."
|
||||
[profile params & {:keys [perms] :or {perms error-reports-read}}]
|
||||
(th/command! (assoc params
|
||||
::rpc/profile-id (:id profile)
|
||||
::rpc/auth-type :token
|
||||
::rpc/token-perms perms)))
|
||||
|
||||
(defn- session-cmd
|
||||
"Invoke an RPC method as a session-authenticated profile."
|
||||
[profile params]
|
||||
(th/command! (assoc params
|
||||
::rpc/profile-id (:id profile)
|
||||
::rpc/auth-type :session)))
|
||||
|
||||
;; --- Auth tests
|
||||
|
||||
(t/deftest get-error-reports-requires-authentication
|
||||
(let [out (th/command! {::th/type :get-error-reports})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :authentication (th/ex-type (:error out))))
|
||||
(t/is (= :authentication-required (th/ex-code (:error out))))))
|
||||
|
||||
(t/deftest get-error-reports-requires-token-auth
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
out (session-cmd profile {::th/type :get-error-reports})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :authorization (th/ex-type (:error out))))
|
||||
(t/is (= :token-auth-required (th/ex-code (:error out))))))
|
||||
|
||||
(t/deftest get-error-reports-requires-perm
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
out (token-cmd profile {::th/type :get-error-reports} :perms #{})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :authorization (th/ex-type (:error out))))
|
||||
(t/is (= :missing-perms (th/ex-code (:error out))))))
|
||||
|
||||
(t/deftest get-error-reports-allows-token-with-perm
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
out (token-cmd profile {::th/type :get-error-reports})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (vector? (:items (:result out))))
|
||||
(t/is (zero? (count (:items (:result out)))))))
|
||||
|
||||
;; --- List shape
|
||||
|
||||
(t/deftest get-error-reports-returns-summary-shape
|
||||
(let [id (uuid/next)
|
||||
profile (th/create-profile* 1 {:is-active true})]
|
||||
(insert-report! th/*system*
|
||||
{:id id
|
||||
:source 3
|
||||
:content {:hint "test error"
|
||||
:tenant "devenv"
|
||||
:version "2.20.0-devenv"}})
|
||||
(let [out (token-cmd profile {::th/type :get-error-reports})
|
||||
result (:result out)]
|
||||
(t/is (th/success? out))
|
||||
(t/is (vector? (:items result)))
|
||||
(t/is (= 1 (count (:items result))))
|
||||
(let [item (first (:items result))]
|
||||
(t/is (= id (:id item)))
|
||||
(t/is (= "logging" (:source item)))
|
||||
(t/is (= "test error" (:hint item)))
|
||||
(t/is (= "devenv" (:tenant item)))
|
||||
(t/is (= "2.20.0-devenv" (:version item)))
|
||||
(t/is (nil? (:content item)))
|
||||
(t/is (nil? (:kind item)))))))
|
||||
|
||||
;; --- Filters
|
||||
|
||||
(t/deftest get-error-reports-filter-by-source
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "backend error"}})
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "frontend error"}})
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 5 :content {:hint "rlimit error"}})
|
||||
(let [out (token-cmd profile {::th/type :get-error-reports :source "audit-log"})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (count (:items (:result out)))))
|
||||
(t/is (= "audit-log" (:source (first (:items (:result out)))))))))
|
||||
|
||||
(t/deftest get-error-reports-filter-by-kind
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "page err" :kind "exception-page"}})
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "unhandled" :kind "unhandled-exception"}})
|
||||
(let [out (token-cmd profile {::th/type :get-error-reports :kind "exception-page"})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (count (:items (:result out)))))
|
||||
(t/is (= "exception-page" (:kind (first (:items (:result out)))))))))
|
||||
|
||||
(t/deftest get-error-reports-filter-by-tenant
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "a" :tenant "tenant-a"}})
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "b" :tenant "tenant-b"}})
|
||||
(let [out (token-cmd profile {::th/type :get-error-reports :tenant "tenant-a"})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (count (:items (:result out)))))
|
||||
(t/is (= "tenant-a" (:tenant (first (:items (:result out)))))))))
|
||||
|
||||
(t/deftest get-error-reports-filter-by-hint-ilike
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "NullPointerException in render"}})
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "Timeout connecting to DB"}})
|
||||
(let [out (token-cmd profile {::th/type :get-error-reports :hint "null"})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (count (:items (:result out)))))
|
||||
(t/is (str/includes? (:hint (first (:items (:result out)))) "Null")))))
|
||||
|
||||
;; --- Kind normalization (old ~:origin vs new ~:kind)
|
||||
|
||||
(t/deftest get-error-reports-kind-normalization
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "old shape" :origin "old-origin"}})
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "new shape" :kind "new-kind"}})
|
||||
;; Both should appear in unfiltered list
|
||||
(let [out (token-cmd profile {::th/type :get-error-reports})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 2 (count (:items (:result out))))))
|
||||
;; Filtering by kind should match old ~:origin
|
||||
(let [out (token-cmd profile {::th/type :get-error-reports :kind "old-origin"})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (count (:items (:result out)))))
|
||||
(t/is (= "old shape" (:hint (first (:items (:result out)))))))
|
||||
;; Filtering by kind should match new ~:kind
|
||||
(let [out (token-cmd profile {::th/type :get-error-reports :kind "new-kind"})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= 1 (count (:items (:result out)))))
|
||||
(t/is (= "new shape" (:hint (first (:items (:result out)))))))))
|
||||
|
||||
;; --- Pagination
|
||||
|
||||
(t/deftest get-error-reports-pagination
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
t1 (ct/in-past "10m")
|
||||
t2 (ct/in-past "9m")
|
||||
t3 (ct/in-past "8m")
|
||||
t4 (ct/in-past "7m")]
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "1"} :created-at t1})
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "2"} :created-at t2})
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "3"} :created-at t3})
|
||||
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "4"} :created-at t4})
|
||||
;; Page 1: newest 2
|
||||
(let [out (token-cmd profile {::th/type :get-error-reports :limit 2})]
|
||||
(t/is (th/success? out))
|
||||
(let [{:keys [items next-since next-id]} (:result out)]
|
||||
(t/is (= 2 (count items)))
|
||||
(t/is (some? next-since))
|
||||
(t/is (some? next-id))
|
||||
(t/is (= "4" (:hint (first items))))
|
||||
(t/is (= "3" (:hint (second items))))
|
||||
;; Page 2: next 2, using since and since-id from page 1
|
||||
(let [out2 (token-cmd profile {::th/type :get-error-reports :limit 2 :since next-since :since-id next-id})]
|
||||
(t/is (th/success? out2))
|
||||
(let [{:keys [items next-since]} (:result out2)]
|
||||
(t/is (= 2 (count items)))
|
||||
(t/is (nil? next-since))
|
||||
(t/is (= "2" (:hint (first items))))
|
||||
(t/is (= "1" (:hint (second items))))))))))
|
||||
|
||||
(t/deftest get-error-reports-pagination-same-timestamp
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
t (ct/in-past "5m")
|
||||
id1 (uuid/next)
|
||||
id2 (uuid/next)
|
||||
id3 (uuid/next)]
|
||||
(insert-report! th/*system* {:id id1 :source 3 :content {:hint "1"} :created-at t})
|
||||
(insert-report! th/*system* {:id id2 :source 3 :content {:hint "2"} :created-at t})
|
||||
(insert-report! th/*system* {:id id3 :source 3 :content {:hint "3"} :created-at t})
|
||||
;; Page 1: first 2 of the 3 same-timestamp rows
|
||||
(let [out (token-cmd profile {::th/type :get-error-reports :limit 2})]
|
||||
(t/is (th/success? out))
|
||||
(let [{:keys [items next-since next-id]} (:result out)]
|
||||
(t/is (= 2 (count items)))
|
||||
(t/is (some? next-since))
|
||||
(t/is (some? next-id))
|
||||
;; Page 2: should return the remaining row, not skip it
|
||||
(let [out2 (token-cmd profile {::th/type :get-error-reports :limit 2 :since next-since :since-id next-id})]
|
||||
(t/is (th/success? out2))
|
||||
(let [{:keys [items next-since]} (:result out2)]
|
||||
(t/is (= 1 (count items)))
|
||||
(t/is (nil? next-since))))))))
|
||||
|
||||
;; --- Single fetch
|
||||
|
||||
(t/deftest get-error-report-requires-token-auth
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
id (uuid/next)]
|
||||
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report" :tenant "devenv"}})
|
||||
(let [out (session-cmd profile {::th/type :get-error-report :id id})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :authorization (th/ex-type (:error out))))
|
||||
(t/is (= :token-auth-required (th/ex-code (:error out)))))))
|
||||
|
||||
(t/deftest get-error-report-requires-perm
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
id (uuid/next)]
|
||||
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report"}})
|
||||
(let [out (token-cmd profile {::th/type :get-error-report :id id} :perms #{})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :authorization (th/ex-type (:error out))))
|
||||
(t/is (= :missing-perms (th/ex-code (:error out)))))))
|
||||
|
||||
(t/deftest get-error-report-success
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
id (uuid/next)]
|
||||
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report" :tenant "devenv"}})
|
||||
(let [out (token-cmd profile {::th/type :get-error-report :id id})]
|
||||
(t/is (th/success? out))
|
||||
(let [result (:result out)]
|
||||
(t/is (= id (:id result)))
|
||||
(t/is (= "logging" (:source result)))
|
||||
(t/is (= "single report" (:hint result)))
|
||||
(t/is (= "devenv" (:tenant result)))))))
|
||||
|
||||
(t/deftest get-error-report-not-found
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
out (token-cmd profile {::th/type :get-error-report :id (uuid/next)})]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out))))
|
||||
(t/is (= :report-not-found (th/ex-code (:error out))))))
|
||||
@ -14,6 +14,8 @@
|
||||
"@playwright/mcp": "^0.0.76",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^26.0.1",
|
||||
"commander": "^14.0.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"esbuild": "^0.28.1",
|
||||
"mdts": "^0.20.3",
|
||||
"nrepl-client": "^0.3.0",
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -17,6 +17,12 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^26.0.1
|
||||
version: 26.0.1
|
||||
commander:
|
||||
specifier: ^14.0.1
|
||||
version: 14.0.3
|
||||
dotenv:
|
||||
specifier: ^16.4.7
|
||||
version: 16.6.1
|
||||
esbuild:
|
||||
specifier: ^0.28.1
|
||||
version: 0.28.1
|
||||
@ -319,6 +325,10 @@ packages:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
dotenv@16.6.1:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -943,6 +953,8 @@ snapshots:
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
|
||||
340
scripts/error-reports.mjs
Executable file
340
scripts/error-reports.mjs
Executable file
@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import dotenv from "dotenv";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
function loadConfig(envPath) {
|
||||
const envFile = envPath || ".env";
|
||||
|
||||
if (existsSync(envFile)) {
|
||||
dotenv.config({ path: envFile });
|
||||
} else if (envPath) {
|
||||
console.error(`Error: .env file not found at ${envPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = {
|
||||
apiUrl: process.env.PENPOT_API_URI,
|
||||
accessToken: process.env.PENPOT_ACCESS_TOKEN
|
||||
};
|
||||
|
||||
if (!config.apiUrl) {
|
||||
console.error("Error: PENPOT_API_URI not set");
|
||||
console.error("\nCreate a .env file with:");
|
||||
console.error(" PENPOT_API_URI=http://localhost:3450");
|
||||
console.error(" PENPOT_ACCESS_TOKEN=<your-token>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!config.accessToken) {
|
||||
console.error("Error: PENPOT_ACCESS_TOKEN not set");
|
||||
console.error("\nCreate a .env file with:");
|
||||
console.error(" PENPOT_API_URI=http://localhost:3450");
|
||||
console.error(" PENPOT_ACCESS_TOKEN=<your-token>");
|
||||
console.error("\nGrant permission to your token:");
|
||||
console.error(" UPDATE access_token SET perms = ARRAY['error-reports:read']::text[]");
|
||||
console.error(" WHERE id = '<token-uuid>';");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RPC Client
|
||||
// ============================================================================
|
||||
|
||||
async function rpcCall(config, method, params = {}) {
|
||||
const url = `${config.apiUrl}/api/main/methods/${method}`;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": `Token ${config.accessToken}`
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.cause?.code === "ECONNREFUSED") {
|
||||
console.error("Error: Cannot connect to server (connection refused)");
|
||||
console.error("The Penpot backend server is not running or not reachable.");
|
||||
console.error("\nCheck that:");
|
||||
console.error(" 1. The backend server is running");
|
||||
console.error(" 2. PENPOT_API_URI in .env points to the correct URL");
|
||||
console.error(` Current URI: ${config.apiUrl}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.cause?.code === "ENOTFOUND") {
|
||||
console.error("Error: Cannot resolve server hostname");
|
||||
console.error(`The hostname in PENPOT_API_URI is not valid: ${config.apiUrl}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw new Error(`Network error: ${err.message}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = { message: response.statusText };
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
console.error("Error: Authentication failed (401)");
|
||||
console.error("Your access token may be invalid or expired.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
console.error("Error: Authorization failed (403)");
|
||||
console.error("Your access token lacks the required permission: error-reports:read");
|
||||
console.error("\nGrant permission:");
|
||||
console.error(" UPDATE access_token SET perms = ARRAY['error-reports:read']::text[]");
|
||||
console.error(" WHERE id = '<token-uuid>';");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (response.status === 502) {
|
||||
console.error("Error: Server is down (502 Bad Gateway)");
|
||||
console.error("The Penpot backend server is not responding.");
|
||||
console.error("\nCheck that:");
|
||||
console.error(" 1. The backend server is running");
|
||||
console.error(" 2. PENPOT_API_URI in .env points to the correct URL");
|
||||
console.error(` Current URI: ${config.apiUrl}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (response.status === 503) {
|
||||
console.error("Error: Service unavailable (503)");
|
||||
console.error("The Penpot backend server is temporarily unavailable.");
|
||||
console.error("Please wait a moment and try again.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (response.status === 504) {
|
||||
console.error("Error: Gateway timeout (504)");
|
||||
console.error("The Penpot backend server did not respond in time.");
|
||||
console.error("\nCheck that:");
|
||||
console.error(" 1. The backend server is running and responsive");
|
||||
console.error(" 2. PENPOT_API_URI in .env points to the correct URL");
|
||||
console.error(` Current URI: ${config.apiUrl}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const code = errorData.code || "unknown";
|
||||
const message = errorData.message || errorData.hint || "Unknown error";
|
||||
throw new Error(`RPC error [${code}]: ${message}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Output Formatting
|
||||
// ============================================================================
|
||||
|
||||
function formatJson(data) {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function formatTable(data, type) {
|
||||
if (type === "list") {
|
||||
return formatListTable(data);
|
||||
} else if (type === "get") {
|
||||
return formatGetTable(data);
|
||||
}
|
||||
return formatJson(data);
|
||||
}
|
||||
|
||||
function formatListTable(data, hasMore = false) {
|
||||
if (!data.items || data.items.length === 0) {
|
||||
return "No error reports found.";
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
lines.push(`Found ${data.items.length} error reports`);
|
||||
lines.push("");
|
||||
|
||||
const headers = ["ID", "Created At", "Source", "Profile ID", "Kind", "Hint"];
|
||||
const rows = data.items.map(item => [
|
||||
item.id || "-",
|
||||
item.createdAt || "-",
|
||||
item.source || "-",
|
||||
item.profileId || "-",
|
||||
item.kind || "-",
|
||||
truncateHint(item.hint, 60)
|
||||
]);
|
||||
|
||||
const colWidths = headers.map((h, i) => {
|
||||
const maxData = Math.max(...rows.map(r => (r[i] || "").toString().length));
|
||||
return Math.max(h.length, maxData);
|
||||
});
|
||||
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(" | ");
|
||||
const separator = colWidths.map(w => "-".repeat(w)).join("-+-");
|
||||
const dataLines = rows.map(row =>
|
||||
row.map((cell, i) => (cell || "-").toString().padEnd(colWidths[i])).join(" | ")
|
||||
);
|
||||
|
||||
lines.push(headerLine);
|
||||
lines.push(separator);
|
||||
lines.push(...dataLines);
|
||||
|
||||
if (hasMore && data.nextSince && data.nextId) {
|
||||
lines.push("");
|
||||
lines.push(`More results: use --since ${data.nextSince} --since-id ${data.nextId}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function truncateHint(text, maxLength) {
|
||||
if (!text) return "-";
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
function formatGetTable(data) {
|
||||
const lines = [];
|
||||
lines.push(`ID: ${data.id || "(none)"}`);
|
||||
lines.push(`Created At: ${data.createdAt || "(none)"}`);
|
||||
lines.push(`Source: ${data.source || "(none)"}`);
|
||||
lines.push(`Profile ID: ${data.profileId || "(none)"}`);
|
||||
lines.push(`Kind: ${data.kind || "(none)"}`);
|
||||
lines.push(`Version: ${data.version || "(none)"}`);
|
||||
lines.push(`Hint: ${data.hint || "(none)"}`);
|
||||
lines.push(`HREF: ${data.href || "(none)"}`);
|
||||
lines.push(`--- Report ---`);
|
||||
|
||||
if (data.report) {
|
||||
const indentedReport = data.report.split("\n").map(line => ` ${line}`).join("\n");
|
||||
lines.push(indentedReport);
|
||||
} else {
|
||||
lines.push(" (none)");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Commands
|
||||
// ============================================================================
|
||||
|
||||
async function cmdList(config, args) {
|
||||
const params = {};
|
||||
|
||||
if (args.limit !== undefined) params.limit = args.limit;
|
||||
if (args.since) params.since = args.since;
|
||||
if (args.sinceId) params["since-id"] = args.sinceId;
|
||||
if (args.source !== undefined) params.source = args.source;
|
||||
if (args.profileId) params["profile-id"] = args.profileId;
|
||||
if (args.kind) params.kind = args.kind;
|
||||
if (args.tenant) params.tenant = args.tenant;
|
||||
if (args.version) params.version = args.version;
|
||||
if (args.hint) params.hint = args.hint;
|
||||
|
||||
let allItems = [];
|
||||
let lastResult = null;
|
||||
let hasMore = true;
|
||||
let sinceParam = params.since;
|
||||
let sinceIdParam = params["since-id"];
|
||||
|
||||
while (hasMore) {
|
||||
const currentParams = { ...params };
|
||||
if (sinceParam) currentParams.since = sinceParam;
|
||||
if (sinceIdParam) currentParams["since-id"] = sinceIdParam;
|
||||
|
||||
lastResult = await rpcCall(config, "get-error-reports", currentParams);
|
||||
|
||||
allItems = [...allItems, ...lastResult.items];
|
||||
|
||||
if (args.all && lastResult.nextSince && lastResult.nextId) {
|
||||
sinceParam = lastResult.nextSince;
|
||||
sinceIdParam = lastResult.nextId;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
items: allItems
|
||||
};
|
||||
|
||||
if (!args.all && lastResult) {
|
||||
output.nextSince = lastResult.nextSince;
|
||||
output.nextId = lastResult.nextId;
|
||||
}
|
||||
|
||||
if (args.format === "table") {
|
||||
const hasMore = !args.all && lastResult.nextSince && lastResult.nextId;
|
||||
console.log(formatListTable(output, hasMore));
|
||||
} else {
|
||||
console.log(formatJson(output));
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdGet(config, args) {
|
||||
const params = args.id ? { id: args.id } : { id: args.errorId };
|
||||
const result = await rpcCall(config, "get-error-report", params);
|
||||
|
||||
if (args.format === "table") {
|
||||
console.log(formatTable(result, "get"));
|
||||
} else {
|
||||
console.log(formatJson(result));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI Setup with Commander
|
||||
// ============================================================================
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("error-reports")
|
||||
.description("Query Penpot error reports via RPC API")
|
||||
.version("1.0.0");
|
||||
|
||||
program
|
||||
.command("list")
|
||||
.description("List error reports with pagination and filters")
|
||||
.option("-l, --limit <n>", "Max items per page (default: 50, max: 200)", (value) => parseInt(value, 10), 50)
|
||||
.option("--since <date>", "ISO timestamp (fetch errors before this date)")
|
||||
.option("--since-id <uuid>", "Fetch errors before this ID (cursor pagination)")
|
||||
.option("-s, --source <name>", "Filter by source (legacy-v1, legacy-v2, logging, audit-log, rlimit)")
|
||||
.option("-p, --profile-id <uuid>", "Filter by profile ID")
|
||||
.option("-k, --kind <kind>", "Filter by kind (string)")
|
||||
.option("-t, --tenant <tenant>", "Filter by tenant (string)")
|
||||
.option("--version <version>", "Filter by version")
|
||||
.option("--hint <text>", "Filter by hint (ILIKE match)")
|
||||
.option("-a, --all", "Fetch all pages automatically", false)
|
||||
.option("-f, --format <type>", "Output format (json|table)", "table")
|
||||
.option("--env <path>", "Custom .env file path")
|
||||
.action(async (options) => {
|
||||
const config = loadConfig(options.env);
|
||||
await cmdList(config, options);
|
||||
});
|
||||
|
||||
program
|
||||
.command("get")
|
||||
.description("Get a single error report by ID")
|
||||
.requiredOption("--id <uuid>", "Error report ID")
|
||||
.option("--error-id <id>", "Error report error-id")
|
||||
.option("-f, --format <type>", "Output format (json|table)", "table")
|
||||
.option("--env <path>", "Custom .env file path")
|
||||
.action(async (options) => {
|
||||
const config = loadConfig(options.env);
|
||||
await cmdGet(config, options);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
Loading…
x
Reference in New Issue
Block a user