mirror of
https://github.com/penpot/penpot.git
synced 2026-07-24 23:18:06 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
c467d98c9e
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
|
||||
@ -1,21 +1,17 @@
|
||||
---
|
||||
description: Review a commit (defaults to the last commit) with the code-review-and-quality skill across all five axes
|
||||
agent: plan
|
||||
subtask: true
|
||||
---
|
||||
Act as a senior software engineer and perform a thorough code review.
|
||||
|
||||
You are performing a code review of a git commit. You MUST conduct it using the **`code-review-and-quality`** skill (the five-axis review: correctness, readability, architecture, security, performance).
|
||||
## Instructions
|
||||
|
||||
The user may specify a commit or revision range as an argument ($ARGUMENTS). If no argument is given, default to reviewing the **last commit** (`HEAD`, i.e. the changes introduced by `HEAD` vs its parent).
|
||||
|
||||
Workflow:
|
||||
|
||||
1. Determine the target to review:
|
||||
- If the user provided a revision/range in $ARGUMENTS, use it.
|
||||
- Otherwise, default to the last commit: review `HEAD` (the diff of `HEAD` against `HEAD~1`).
|
||||
2. Inspect the change with `git show <target>` / `git diff <target>~1 <target>` and `git log -1 --stat <target>` to understand the intent and the files touched.
|
||||
3. Invoke the **`code-review-and-quality`** skill and review the commit across all five axes. Categorize every finding as Critical / Required / Optional / Nit / FYI, and lead with correctness and security.
|
||||
4. For each finding, state the axis it belongs to, the severity, and a concrete suggested fix (propose the structural remedy, not just the problem).
|
||||
5. Conclude with a clear verdict: **Approve** (ready to merge) or **Request changes** (issues that must be addressed), and summarize the highest-leverage items.
|
||||
1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format.
|
||||
2. Determine the diff or code to review from the provided context.
|
||||
3. Read the diff and the surrounding context for each changed file.
|
||||
4. Review across all five axes: correctness, readability, architecture, security, performance.
|
||||
5. Produce the review using the **Review Output** format from the skill (Summary → Critical/High → Other Findings → Refactoring → Testing Recommendations → Positive Observations → Final Verdict).
|
||||
6. For each finding: state the severity (Critical / High / Medium / Low / Suggestion), identify the file and line, describe failure circumstances, and propose a concrete fix.
|
||||
7. Do not invent problems. Every finding must be real and actionable.
|
||||
|
||||
Do not modify any code and do not create a commit — this command only reviews.
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
@ -19,9 +19,18 @@ Multi-dimensional code review with quality gates. Every change gets reviewed bef
|
||||
- When refactoring existing code
|
||||
- After any bug fix (review both the fix and the regression test)
|
||||
|
||||
## Core Principles
|
||||
|
||||
These principles underpin every axis. When in doubt, default to them.
|
||||
|
||||
- **DRY (Don't Repeat Yourself):** Every piece of knowledge has one authoritative representation. If the same logic appears in two places, extract it into a shared helper, model, or type. Reviewers: flag duplicated logic as a required change — it's not "just similar," it's drift that will diverge.
|
||||
- **KISS (Keep It Simple, Stupid):** The simplest solution that works is the best solution. Complexity must earn its place. Reviewers: if you need more than one sentence to explain what a piece of code does, it's too complex — push for simplification before merge.
|
||||
- **YAGNI (You Aren't Gonna Need It):** Don't add abstractions, hooks, or generalizations for hypothetical future use cases. Generalize on the third occurrence, not the first. Reviewers: delete speculative generality.
|
||||
- **Don't invent problems:** Do not manufacture issues to produce more feedback. Every finding must be a real risk, a real readability barrier, or a real architectural concern — not a hypothetical or a stylistic preference disguised as a problem.
|
||||
|
||||
## The Five-Axis Review
|
||||
|
||||
Every review evaluates code across these dimensions:
|
||||
Every review evaluates code across these dimensions.
|
||||
|
||||
### 1. Correctness
|
||||
|
||||
@ -39,14 +48,13 @@ Can another engineer (or agent) understand this code without the author explaini
|
||||
|
||||
- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)
|
||||
- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?
|
||||
- Is the code organized logically (related code grouped, clear module boundaries)?
|
||||
- Are there any "clever" tricks that should be simplified?
|
||||
- **Could this be done in fewer lines?** (1000 lines where 100 suffice is a failure)
|
||||
- **Are abstractions earning their complexity?** (Don't generalize until the third use case)
|
||||
- Would comments help clarify non-obvious intent? (But don't comment obvious code.)
|
||||
- Are there dead code artifacts: no-op variables (`_unused`), backwards-compat shims, or `// removed` comments?
|
||||
- **Is a new conditional bolted onto an unrelated flow?** That's a design smell, not a nit — push the logic into its own helper, state, or policy instead of tangling an existing path.
|
||||
- **Do repeated conditionals on the same shape appear?** They signal a missing model or dispatcher. A "temporary" branch is usually permanent debt.
|
||||
- **KISS check:** Is this the simplest approach that solves the problem? A 20-line straightforward function beats a 5-line clever one that requires a comment to explain.
|
||||
- Could this be done in fewer lines? (1000 lines where 100 suffice is a failure)
|
||||
- Are abstractions earning their complexity? (Don't generalize until the third use case)
|
||||
- Is a new conditional bolted onto an unrelated flow? Push the logic into its own helper, state, or policy.
|
||||
- Do repeated conditionals on the same shape appear? They signal a missing model or dispatcher.
|
||||
- Are there dead code artifacts: no-op variables, backwards-compat shims, or `// removed` comments?
|
||||
|
||||
### 3. Architecture
|
||||
|
||||
@ -54,16 +62,17 @@ Does the change fit the system's design?
|
||||
|
||||
- Does it follow existing patterns or introduce a new one? If new, is it justified?
|
||||
- Does it maintain clean module boundaries?
|
||||
- Is there code duplication that should be shared?
|
||||
- **DRY check:** Is there existing code that does the same thing? Reuse the canonical helper instead of writing a near-duplicate. If two branches do nearly the same thing, collapse them.
|
||||
- Are dependencies flowing in the right direction (no circular dependencies)?
|
||||
- Is the abstraction level appropriate (not over-engineered, not too coupled)?
|
||||
- **Does this refactor reduce complexity or just relocate it?** Count the concepts a reader must hold to follow the change. If a "cleaner" version leaves that count unchanged, it isn't cleaner — prefer the restructuring that makes whole branches, modes, or layers disappear over one that re-centralizes the same logic. Prefer deleting an abstraction to polishing it.
|
||||
- **Is feature-specific logic leaking into a shared or general-purpose module?** Keep logic in its owning layer, reuse the existing canonical helper instead of a near-duplicate, and don't normalize architectural drift.
|
||||
- **Are type boundaries explicit?** Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks that paper over an unclear invariant — making the boundary explicit often makes the surrounding control flow simpler.
|
||||
- Does this refactor reduce complexity or just relocate it? Count the concepts a reader must hold. Prefer the restructuring that makes whole branches disappear over one that re-centralizes the same logic. Prefer deleting an abstraction to polishing it.
|
||||
- Is feature-specific logic leaking into a shared or general-purpose module?
|
||||
- Are type boundaries explicit? Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks.
|
||||
- **Structural remedies:** When you flag a problem, propose the move — not just the problem. Replace conditionals with dispatchers, collapse duplicate branches, separate orchestration from business logic, extract helpers, split large files. Prefer the remedy that removes moving pieces over one that spreads the same complexity around.
|
||||
|
||||
### 4. Security
|
||||
|
||||
For detailed security guidance, see `security-and-hardening`. Does the change introduce vulnerabilities?
|
||||
For detailed security guidance, see `security-and-hardening`.
|
||||
|
||||
- Is user input validated and sanitized?
|
||||
- Are secrets kept out of code, logs, and version control?
|
||||
@ -72,12 +81,9 @@ For detailed security guidance, see `security-and-hardening`. Does the change in
|
||||
- Are outputs encoded to prevent XSS?
|
||||
- Are dependencies from trusted sources with no known vulnerabilities?
|
||||
- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?
|
||||
- Are external data flows validated at system boundaries before use in logic or rendering?
|
||||
|
||||
### 5. Performance
|
||||
|
||||
Does the change introduce performance problems?
|
||||
|
||||
- Any N+1 query patterns?
|
||||
- Any unbounded loops or unconstrained data fetching?
|
||||
- Any synchronous operations that should be async?
|
||||
@ -85,24 +91,66 @@ Does the change introduce performance problems?
|
||||
- Any missing pagination on list endpoints?
|
||||
- Any large objects created in hot paths?
|
||||
|
||||
## Structural Remedies
|
||||
## Review Process
|
||||
|
||||
When you flag a structural problem, propose the move — not just the problem. A review that only says "this is complex" leaves the author guessing. Reach for a named restructuring:
|
||||
1. **Understand the intent** — What is this change trying to accomplish? What spec or task does it implement?
|
||||
2. **Review tests first** — Tests reveal intent and coverage. Do they test behavior, not implementation details? Are edge cases covered?
|
||||
3. **Review the implementation** — Walk through each file with the five axes in mind.
|
||||
4. **Categorize findings** — Label every comment with its severity:
|
||||
|
||||
- **Replace a chain of conditionals** with a typed model or an explicit dispatcher.
|
||||
- **Collapse duplicate branches** into a single clearer flow.
|
||||
- **Separate orchestration from business logic** so each reads on its own.
|
||||
- **Move feature-specific logic** out of a shared module into the package that owns the concept.
|
||||
- **Reuse the canonical helper** instead of a bespoke near-duplicate.
|
||||
- **Make a type boundary explicit** so downstream branching disappears.
|
||||
- **Delete a pass-through wrapper** that adds indirection without clarifying the API.
|
||||
- **Extract a helper, or split a large file** into focused modules.
|
||||
| Prefix | Meaning | Author Action |
|
||||
|--------|---------|---------------|
|
||||
| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality |
|
||||
| **High:** | Required change | Must address before merge |
|
||||
| **Medium:** | Should fix | Strongly recommended, not a blocker |
|
||||
| **Low:** | Minor, optional | Author may ignore — formatting, style preferences |
|
||||
| **Suggestion:** | Worth considering | Not required, but improves the code |
|
||||
|
||||
Prefer the remedy that removes moving pieces over one that spreads the same complexity around.
|
||||
For each finding, describe the circumstances under which it could fail: specific inputs, load conditions, timing, or user actions that trigger the problem. "This crashes when input is null" is actionable; "this might crash" is not.
|
||||
|
||||
Lead with what matters: correctness and security first, then structural issues, then everything else. A few high-conviction comments beat a long list.
|
||||
|
||||
5. **Verify the verification** — What tests were run? Did the build pass? Was the change tested manually? Screenshots for UI changes?
|
||||
|
||||
## Review Output
|
||||
|
||||
Structure every review using this format:
|
||||
|
||||
### Summary
|
||||
|
||||
Briefly explain what the code does and give an overall assessment.
|
||||
|
||||
### Critical and High-Priority Issues
|
||||
|
||||
List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
|
||||
|
||||
### Other Findings
|
||||
|
||||
List medium- and low-priority issues, including maintainability and design concerns.
|
||||
|
||||
### Suggested Refactoring
|
||||
|
||||
Provide focused code changes or revised snippets. Preserve existing behavior unless a behavior change is explicitly justified.
|
||||
|
||||
### Testing Recommendations
|
||||
|
||||
Identify missing tests and describe specific test cases, including edge cases and failure scenarios.
|
||||
|
||||
### Positive Observations
|
||||
|
||||
Mention implementation choices that are clear, safe, efficient, or well designed. This is not fluff — it reinforces good patterns and tells the author what to keep doing.
|
||||
|
||||
### Final Verdict
|
||||
|
||||
Choose one:
|
||||
|
||||
- **Approve** — Ready to merge
|
||||
- **Approve with minor changes** — Good to merge after addressing low/medium issues
|
||||
- **Request changes** — Critical or high issues must be resolved before merge
|
||||
|
||||
## Change Sizing
|
||||
|
||||
Small, focused changes are easier to review, faster to merge, and safer to deploy. Target these sizes:
|
||||
Small, focused changes are easier to review, faster to merge, and safer to deploy.
|
||||
|
||||
```
|
||||
~100 lines changed → Good. Reviewable in one sitting.
|
||||
@ -110,11 +158,9 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo
|
||||
~1000 lines changed → Too large. Split it.
|
||||
```
|
||||
|
||||
**Watch file size, not just diff size.** A small diff can still push a file past a healthy boundary — around 1000 *total* lines in a single file (distinct from the ~1000 *changed*-lines threshold above) is a common inspection signal, not a hard cap. When a change materially grows an already-large file, ask whether to extract helpers, subcomponents, or modules *first*, before piling more on. Decompose, then add.
|
||||
**Watch file size, not just diff size.** Around 1000 *total* lines in a single file is a common inspection signal. When a change materially grows an already-large file, decompose first.
|
||||
|
||||
**What counts as "one change":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.
|
||||
|
||||
**Splitting strategies when a change is too large:**
|
||||
**Splitting strategies:**
|
||||
|
||||
| Strategy | How | When |
|
||||
|----------|-----|------|
|
||||
@ -123,164 +169,17 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo
|
||||
| **Horizontal** | Create shared code/stubs first, then consumers | Layered architecture |
|
||||
| **Vertical** | Break into smaller full-stack slices of the feature | Feature work |
|
||||
|
||||
**When large changes are acceptable:** Complete file deletions and automated refactoring where the reviewer only needs to verify intent, not every line.
|
||||
|
||||
**Separate refactoring from feature work.** A change that refactors existing code and adds new behavior is two changes — submit them separately. Small cleanups (variable renaming) can be included at reviewer discretion.
|
||||
**Separate refactoring from feature work.** A change that refactors and adds new behavior is two changes — submit them separately.
|
||||
|
||||
## Change Descriptions
|
||||
|
||||
Every change needs a description that stands alone in version control history.
|
||||
- **First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC."
|
||||
- **Body:** What is changing and why. Include context and reasoning not visible in the code itself.
|
||||
- **Anti-patterns:** "Fix bug," "Fix build," "Add patch," "Phase 1."
|
||||
|
||||
**First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC." Must be informative enough that someone searching history can understand the change without reading the diff.
|
||||
## Dependencies
|
||||
|
||||
**Body:** What is changing and why. Include context, decisions, and reasoning not visible in the code itself. Link to bug numbers, benchmark results, or design docs where relevant. Acknowledge approach shortcomings when they exist.
|
||||
|
||||
**Anti-patterns:** "Fix bug," "Fix build," "Add patch," "Moving code from A to B," "Phase 1," "Add convenience functions."
|
||||
|
||||
## Review Process
|
||||
|
||||
### Step 1: Understand the Context
|
||||
|
||||
Before looking at code, understand the intent:
|
||||
|
||||
```
|
||||
- What is this change trying to accomplish?
|
||||
- What spec or task does it implement?
|
||||
- What is the expected behavior change?
|
||||
```
|
||||
|
||||
### Step 2: Review the Tests First
|
||||
|
||||
Tests reveal intent and coverage:
|
||||
|
||||
```
|
||||
- Do tests exist for the change?
|
||||
- Do they test behavior (not implementation details)?
|
||||
- Are edge cases covered?
|
||||
- Do tests have descriptive names?
|
||||
- Would the tests catch a regression if the code changed?
|
||||
```
|
||||
|
||||
### Step 3: Review the Implementation
|
||||
|
||||
Walk through the code with the five axes in mind:
|
||||
|
||||
```
|
||||
For each file changed:
|
||||
1. Correctness: Does this code do what the test says it should?
|
||||
2. Readability: Can I understand this without help?
|
||||
3. Architecture: Does this fit the system?
|
||||
4. Security: Any vulnerabilities?
|
||||
5. Performance: Any bottlenecks?
|
||||
```
|
||||
|
||||
### Step 4: Categorize Findings
|
||||
|
||||
Label every comment with its severity so the author knows what's required vs optional:
|
||||
|
||||
| Prefix | Meaning | Author Action |
|
||||
|--------|---------|---------------|
|
||||
| *(no prefix)* | Required change | Must address before merge |
|
||||
| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality |
|
||||
| **Nit:** | Minor, optional | Author may ignore — formatting, style preferences |
|
||||
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
|
||||
| **FYI** | Informational only | No action needed — context for future reference |
|
||||
|
||||
This prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.
|
||||
|
||||
**Lead with what matters.** Order findings by leverage: correctness and security first, then structural regressions and missed simplifications, then everything else. Don't bury a real issue under cosmetic nits — a few high-conviction comments beat a long list. If you have one structural problem and ten nits, the structural problem *is* the review.
|
||||
|
||||
### Step 5: Verify the Verification
|
||||
|
||||
Check the author's verification story:
|
||||
|
||||
```
|
||||
- What tests were run?
|
||||
- Did the build pass?
|
||||
- Was the change tested manually?
|
||||
- Are there screenshots for UI changes?
|
||||
- Is there a before/after comparison?
|
||||
```
|
||||
|
||||
## Multi-Model Review Pattern
|
||||
|
||||
Use different models for different review perspectives:
|
||||
|
||||
```
|
||||
Model A writes the code
|
||||
│
|
||||
▼
|
||||
Model B reviews for correctness and architecture
|
||||
│
|
||||
▼
|
||||
Model A addresses the feedback
|
||||
│
|
||||
▼
|
||||
Human makes the final call
|
||||
```
|
||||
|
||||
This catches issues that a single model might miss — different models have different blind spots.
|
||||
|
||||
**Example prompt for a review agent:**
|
||||
```
|
||||
Review this code change for correctness, security, and adherence to
|
||||
our project conventions. The spec says [X]. The change should [Y].
|
||||
Flag any issues as Critical, Required, Optional, or Nit.
|
||||
```
|
||||
|
||||
## Dead Code Hygiene
|
||||
|
||||
After any refactoring or implementation change, check for orphaned code:
|
||||
|
||||
1. Identify code that is now unreachable or unused
|
||||
2. List it explicitly
|
||||
3. **Ask before deleting:** "Should I remove these now-unused elements: [list]?"
|
||||
|
||||
Don't leave dead code lying around — it confuses future readers and agents. But don't silently delete things you're not sure about. When in doubt, ask.
|
||||
|
||||
```
|
||||
DEAD CODE IDENTIFIED:
|
||||
- formatLegacyDate() in src/utils/date.ts — replaced by formatDate()
|
||||
- OldTaskCard component in src/components/ — replaced by TaskCard
|
||||
- LEGACY_API_URL constant in src/config.ts — no remaining references
|
||||
→ Safe to remove these?
|
||||
```
|
||||
|
||||
## Review Speed
|
||||
|
||||
Slow reviews block entire teams. The cost of context-switching to review is less than the waiting cost imposed on others.
|
||||
|
||||
- **Respond within one business day** — this is the maximum, not the target
|
||||
- **Ideal cadence:** Respond shortly after a review request arrives, unless deep in focused coding. A typical change should complete multiple review rounds in a single day
|
||||
- **Prioritize fast individual responses** over quick final approval. Quick feedback reduces frustration even if multiple rounds are needed
|
||||
- **Large changes:** Ask the author to split them rather than reviewing one massive changeset
|
||||
|
||||
## Handling Disagreements
|
||||
|
||||
When resolving review disputes, apply this hierarchy:
|
||||
|
||||
1. **Technical facts and data** override opinions and preferences
|
||||
2. **Style guides** are the absolute authority on style matters
|
||||
3. **Software design** must be evaluated on engineering principles, not personal preference
|
||||
4. **Codebase consistency** is acceptable if it doesn't degrade overall health
|
||||
|
||||
**Don't accept "I'll clean it up later."** Experience shows deferred cleanup rarely happens. Require cleanup before submission unless it's a genuine emergency. If surrounding issues can't be addressed in this change, require filing a bug with self-assignment.
|
||||
|
||||
## Honesty in Review
|
||||
|
||||
When reviewing code — whether written by you, another agent, or a human:
|
||||
|
||||
- **Don't rubber-stamp.** "LGTM" without evidence of review helps no one.
|
||||
- **Don't soften real issues.** "This might be a minor concern" when it's a bug that will hit production is dishonest.
|
||||
- **Quantify problems when possible.** "This N+1 query will add ~50ms per item in the list" is better than "this could be slow."
|
||||
- **Push back on approaches with clear problems.** Sycophancy is a failure mode in reviews. If the implementation has issues, say so directly and propose alternatives.
|
||||
- **Accept override gracefully.** If the author has full context and disagrees, defer to their judgment. Comment on code, not people — reframe personal critiques to focus on the code itself.
|
||||
|
||||
## Dependency Discipline
|
||||
|
||||
Part of code review is dependency review:
|
||||
|
||||
**Before adding any dependency:**
|
||||
Before adding any dependency:
|
||||
|
||||
1. Does the existing stack solve this? (Often it does.)
|
||||
2. How large is the dependency? (Check bundle impact.)
|
||||
@ -290,67 +189,14 @@ Part of code review is dependency review:
|
||||
|
||||
**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.
|
||||
|
||||
**Upgrading an existing dependency** is a code change like any other, and the riskiest upgrades are the ones merged in bulk with a message like "bump deps." Review them with the same discipline:
|
||||
**Upgrading dependencies:**
|
||||
|
||||
1. **Read the changelog, not just the version number.** Semver is a promise the maintainer may not have kept — a "patch" can carry a behavioral change. For a major bump, read the migration notes and find what breaks.
|
||||
2. **One dependency per change.** Upgrade and merge them individually (or in small related groups). When a bulk bump breaks the build, you've lost which package did it; a single-package change makes the cause obvious and the revert clean.
|
||||
3. **Let the tests decide.** The upgrade is verified by a green suite before *and* after, not by "it installed." If coverage around the dependency's behavior is thin, that gap is the real finding — add a test first.
|
||||
4. **Mind the transitive graph.** Most installed packages are ones nobody chose directly. Review the lockfile diff, not just `package.json`; a single direct bump can pull in dozens of indirect changes.
|
||||
5. **Keep the lockfile honest.** Commit it, review its diff, and never hand-edit it. The lockfile is the thing that actually pins what ships.
|
||||
- Read the changelog, not just the version number. Semver is a promise the maintainer may not have kept.
|
||||
- One dependency per change. When a bulk bump breaks the build, you've lost which package did it.
|
||||
- Let the tests decide — a green suite before *and* after, not just "it installed."
|
||||
- Review the lockfile diff, not just `package.json`. Commit it and never hand-edit it.
|
||||
|
||||
For triaging `npm audit` findings and supply-chain risk (typosquatting, compromised maintainers), follow the `security-and-hardening` skill — this section covers the upgrade *workflow*, that one covers the security verdict.
|
||||
|
||||
## The Review Checklist
|
||||
|
||||
```markdown
|
||||
## Review: [PR/Change title]
|
||||
|
||||
### Context
|
||||
- [ ] I understand what this change does and why
|
||||
|
||||
### Correctness
|
||||
- [ ] Change matches spec/task requirements
|
||||
- [ ] Edge cases handled
|
||||
- [ ] Error paths handled
|
||||
- [ ] Tests cover the change adequately
|
||||
|
||||
### Readability
|
||||
- [ ] Names are clear and consistent
|
||||
- [ ] Logic is straightforward
|
||||
- [ ] No unnecessary complexity
|
||||
|
||||
### Architecture
|
||||
- [ ] Follows existing patterns
|
||||
- [ ] No unnecessary coupling or dependencies
|
||||
- [ ] Appropriate abstraction level
|
||||
- [ ] Refactors reduce complexity rather than relocate it
|
||||
- [ ] No feature logic in shared modules; file stays within a healthy size
|
||||
|
||||
### Security
|
||||
- [ ] No secrets in code
|
||||
- [ ] Input validated at boundaries
|
||||
- [ ] No injection vulnerabilities
|
||||
- [ ] Auth checks in place
|
||||
- [ ] External data sources treated as untrusted
|
||||
|
||||
### Performance
|
||||
- [ ] No N+1 patterns
|
||||
- [ ] No unbounded operations
|
||||
- [ ] Pagination on list endpoints
|
||||
|
||||
### Verification
|
||||
- [ ] Tests pass
|
||||
- [ ] Build succeeds
|
||||
- [ ] Manual verification done (if applicable)
|
||||
|
||||
### Verdict
|
||||
- [ ] **Approve** — Ready to merge
|
||||
- [ ] **Request changes** — Issues must be addressed
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- For detailed security review guidance, see `security-and-hardening`
|
||||
For supply-chain risk triage, follow the `security-and-hardening` skill.
|
||||
|
||||
## Common Rationalizations
|
||||
|
||||
@ -358,13 +204,16 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
|
||||
|---|---|
|
||||
| "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. |
|
||||
| "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. |
|
||||
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. Require cleanup before merge, not after. |
|
||||
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. |
|
||||
| "AI-generated code is probably fine" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong. |
|
||||
| "The tests pass, so it's good" | Tests are necessary but not sufficient. They don't catch architecture problems, security issues, or readability concerns. |
|
||||
| "The refactor makes it cleaner" | Relocating complexity isn't reducing it. If the reader still holds the same number of concepts, the structure didn't improve — look for the version where branches disappear. |
|
||||
| "It's only a small addition to this file" | Small diffs still push files past a healthy size and bolt branches onto unrelated flows. Judge the resulting structure, not the diff size. |
|
||||
| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog; semver doesn't guarantee no breakage. |
|
||||
| "I'll upgrade everything in one PR to save time" | A bulk bump that breaks the build hides which package did it. One dependency per change keeps the cause and the revert clean. |
|
||||
| "The tests pass, so it's good" | Tests are necessary but not sufficient. They don't catch architecture, security, or readability problems. |
|
||||
| "The refactor makes it cleaner" | Relocating complexity isn't reducing it. If the reader still holds the same number of concepts, the structure didn't improve. |
|
||||
| "It's only a small addition to this file" | Small diffs still push files past healthy size and bolt branches onto unrelated flows. |
|
||||
| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog. |
|
||||
| "I'll upgrade everything in one PR" | A bulk bump hides which package broke the build. One per change. |
|
||||
| "It's duplicated but it's only two places" | Two becomes three becomes five. Extract now, before the copies diverge. |
|
||||
| "The abstraction is future-proof" | YAGNI. Delete speculative generality — generalize on the third occurrence, not the first. |
|
||||
| "It's clever but efficient" | Cleverness is a readability tax. If it needs a comment to understand, simplify it. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
@ -374,14 +223,11 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
|
||||
- Security-sensitive changes without security-focused review
|
||||
- Large PRs that are "too big to review properly" (split them)
|
||||
- No regression tests with bug fix PRs
|
||||
- Review comments without severity labels — makes it unclear what's required vs optional
|
||||
- Accepting "I'll fix it later" — it never happens
|
||||
- A refactor that moves code around without reducing the number of concepts a reader must hold
|
||||
- A change that grows an already-large file instead of decomposing it
|
||||
- New conditionals scattered into unrelated code paths (a missing abstraction)
|
||||
- A bespoke helper that duplicates an existing canonical one, or feature logic placed in a shared module
|
||||
- A bulk "bump dependencies" PR with no changelog review and no per-package isolation
|
||||
- A lockfile change that's hand-edited, uncommitted, or merged without reviewing its diff
|
||||
- A bespoke helper that duplicates an existing canonical one
|
||||
- A bulk "bump dependencies" PR with no changelog review
|
||||
|
||||
## Verification
|
||||
|
||||
@ -392,6 +238,18 @@ After review is complete:
|
||||
- [ ] Tests pass
|
||||
- [ ] Build succeeds
|
||||
- [ ] The verification story is documented (what changed, how it was verified)
|
||||
- [ ] Dependency upgrades were reviewed against their changelog, isolated per package, and verified by a green suite with the lockfile diff reviewed
|
||||
- [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite
|
||||
|
||||
**Presumptive blockers:** surface and propose the simpler design for each of these; escalate to Required only when the change actively makes structure worse: a refactor that relocates complexity instead of reducing it; a change that pushes a file past the size boundary with no decomposition; feature logic added to a shared module; a near-duplicate of an existing canonical helper; a silent fallback that hides an unclear invariant.
|
||||
## Multi-Model Review Pattern
|
||||
|
||||
Use different models for different review perspectives:
|
||||
|
||||
```
|
||||
Model A writes the code → Model B reviews → Model A addresses feedback → Human makes the final call
|
||||
```
|
||||
|
||||
Different models have different blind spots.
|
||||
|
||||
## See Also
|
||||
|
||||
- For detailed security review guidance, see `security-and-hardening`
|
||||
|
||||
@ -52,7 +52,7 @@ module. You can read it from `mem:<MODULE>/core`
|
||||
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
|
||||
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.
|
||||
|
||||
# Dev tools
|
||||
# Dev Scripts (scripts/)
|
||||
|
||||
- `scripts/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
|
||||
Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases.
|
||||
|
||||
286
.serena/memories/scripts/error-reports.md
Normal file
286
.serena/memories/scripts/error-reports.md
Normal file
@ -0,0 +1,286 @@
|
||||
# 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, NDJSON, or table format
|
||||
- Computing error statistics (top signatures, per-host breakdown, hourly distribution)
|
||||
- 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` |
|
||||
| `--from <date>` | ISO timestamp — oldest boundary (items after this) | — |
|
||||
| `--to <date>` | ISO timestamp — newest boundary (items before this) | — |
|
||||
| `--since <date>` | ISO timestamp — explicit cursor for manual pagination | — |
|
||||
| `--since-id <uuid>` | Fetch errors after 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 (streams output) | `false` |
|
||||
| `-f, --format <type>` | Output format: `json`, `table`, or `ndjson` | `table` |
|
||||
| `--normalize-hints` | Normalize hints by stripping dynamic values | `false` |
|
||||
| `-o, --output <file>` | Write output to file instead of stdout | — |
|
||||
| `--env <path>` | Custom .env file path | `.env` |
|
||||
| `-h, --help` | Show help message | — |
|
||||
|
||||
**Streaming behavior:** With `--all` or `--format ndjson`, items are printed as they arrive (no buffering). `--all` + `table` prints rows immediately. `--all` + `json` streams NDJSON (one JSON object per line).
|
||||
|
||||
#### `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: `table`) |
|
||||
| `--env <path>` | Custom .env file path | No (default: `.env`) |
|
||||
| `-h, --help` | Show help message | No |
|
||||
|
||||
#### `stats` - Compute error report statistics
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs stats [options]
|
||||
```
|
||||
|
||||
Reads from `--input <file>`, stdin (piped), or fetches from API. Computes aggregations by signature, host, tenant, version, source, kind, and hour.
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--from <date>` | Start of interval (ISO timestamp) | — |
|
||||
| `--to <date>` | End of interval (ISO timestamp) | — |
|
||||
| `--limit <n>` | Items per page when fetching from API | `200` |
|
||||
| `--input <file>` | Read from local JSON/NDJSON file instead of API | — |
|
||||
| `-f, --format <type>` | Output format: `json` or `table` | `table` |
|
||||
| `--env <path>` | Custom .env file path | `.env` |
|
||||
|
||||
## Source Names
|
||||
|
||||
The `--source` filter accepts these values:
|
||||
|
||||
- `logging`
|
||||
- `audit-log`
|
||||
- `rlimit`
|
||||
|
||||
## Hint Normalization
|
||||
|
||||
With `--normalize-hints` (or always in `stats`), hints are normalized by stripping dynamic values:
|
||||
|
||||
1. URIs (`https://...`) → `<uri>`
|
||||
2. UUIDs (8-4-4-4-12 hex) → `<uuid>`
|
||||
3. Elapsed times (`7.5s`, `2m3.027s`) → `<elapsed>`
|
||||
4. Numeric IDs in parentheses `(12345)` → `(<id>)`
|
||||
|
||||
## Examples
|
||||
|
||||
### List recent errors
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --limit 10
|
||||
```
|
||||
|
||||
### Time-range query (today)
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z --all
|
||||
```
|
||||
|
||||
### Stream all errors as NDJSON
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format ndjson > errors.ndjson
|
||||
```
|
||||
|
||||
### Save to file with --output
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format json -o errors.json
|
||||
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Stats from API
|
||||
```bash
|
||||
./scripts/error-reports.mjs stats --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z
|
||||
```
|
||||
|
||||
### Stats from file
|
||||
```bash
|
||||
./scripts/error-reports.mjs stats --input errors.json
|
||||
```
|
||||
|
||||
### Stats from pipe
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format json | ./scripts/error-reports.mjs stats
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
### Table (default)
|
||||
Human-readable table format for terminal display. With `--all`, rows stream as they arrive.
|
||||
|
||||
### JSON
|
||||
Single page: `{items: [...], nextSince, nextId}`. With `--all`: NDJSON (one JSON object per line).
|
||||
|
||||
### NDJSON
|
||||
One JSON object per line, always streaming. Pipe-friendly: `| jq -c '.hint'`, `| wc -l`.
|
||||
|
||||
## Pagination
|
||||
|
||||
The server returns items in **ascending** order (oldest first). Cursor pagination uses `--since` / `--since-id` to fetch the next page of newer items.
|
||||
|
||||
### 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 (streams output):
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all
|
||||
```
|
||||
|
||||
### Time-range queries
|
||||
Use `--from` and `--to` to bound the query. These map to the server's `--since` and `--until` parameters:
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --from 2026-07-20T00:00:00Z --to 2026-07-23T23:59:59Z --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, `--format ndjson` for streaming
|
||||
- **Streaming with --all** - Items print as they arrive, no buffering
|
||||
- **Filters are combinable** - All filter options can be used together
|
||||
- **Both flag formats supported** - `--option=value` and `--option value` both work
|
||||
- **Ascending order** - Server returns oldest items first (changed from DESC)
|
||||
|
||||
## 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 NDJSON output to `jq` for further processing
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format ndjson | jq -c '{id, hint}'
|
||||
```
|
||||
- **stats from pipe**: Fetch data once, compute stats
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format json -o errors.json
|
||||
./scripts/error-reports.mjs stats --input errors.json
|
||||
```
|
||||
- **stats from NDJSON pipe**: Works with NDJSON format too
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format ndjson | ./scripts/error-reports.mjs stats
|
||||
```
|
||||
- **grep/search**: Filter output by specific patterns
|
||||
- **--output**: Save to file without shell redirection
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
|
||||
```
|
||||
@ -29,7 +29,7 @@ bb scripts/paren-repair --help
|
||||
|
||||
## Native Tool Available (opencode)
|
||||
|
||||
A native opencode tool `paren-repair` is available at `.opencode/tools/paren-repair.ts`.
|
||||
A native opencode tool `paren-repair` is available at `.opencode/scripts/paren-repair.ts`.
|
||||
The LLM can call it directly with:
|
||||
- `files`: Array of file paths to fix
|
||||
- `code`: Code string to fix via stdin
|
||||
|
||||
33
CHANGES.md
33
CHANGES.md
@ -59,49 +59,26 @@
|
||||
- Render guides in WebGL [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
|
||||
- Add configurable resource limits to ImageMagick image processing [#10223](https://github.com/penpot/penpot/issues/10223) (PR: [#10240](https://github.com/penpot/penpot/pull/10240))
|
||||
- Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274))
|
||||
- Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
|
||||
- Add color variants and positioning to selection size badge [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
|
||||
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
|
||||
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
|
||||
- Add separate internal URI for exporter to handle Docker deployments where internal and public URIs differ [#10627](https://github.com/penpot/penpot/issues/10627) (PR: [#10630](https://github.com/penpot/penpot/pull/10630))
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix LDAP provider params schema typo (`bind-passwor` → `bind-password`) introduced during the `clojure.spec` → `malli` migration; the schema slot now matches the runtime key actually read by `prepare-params` (`:password (:bind-password cfg)`) and `try-connectivity` (`(:bind-password cfg)`), so a wrong type for the password no longer slips through unvalidated
|
||||
- Fix `login-with-ldap` silently dropping its error message on the `ldap-not-initialized` restriction (typo `:hide` → `:hint`); the message `"ldap auth provider is not initialized"` now actually surfaces in logs and error responses instead of being discarded into an unread key
|
||||
- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD
|
||||
- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838)
|
||||
- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582)
|
||||
- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526)
|
||||
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
|
||||
- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924)
|
||||
- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639)
|
||||
- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786)
|
||||
- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841)
|
||||
- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631)
|
||||
- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906)
|
||||
- Update onboarding image [Taiga #13864](https://tree.taiga.io/project/penpot/issue/13864)
|
||||
- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871)
|
||||
- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923)
|
||||
- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930)
|
||||
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
|
||||
- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959)
|
||||
- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960)
|
||||
- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984)
|
||||
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
|
||||
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
|
||||
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
|
||||
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @jack-stormentswe) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
|
||||
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @davidv399) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
|
||||
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
|
||||
- Fix Storybook UI missing scrollbar (by @MilosM348) [#6049](https://github.com/penpot/penpot/issues/6049) (PR: [#9319](https://github.com/penpot/penpot/pull/9319))
|
||||
- Fix font selector missing intermediate font weights for Source Sans Pro and similar fonts (by @dhgoal) [#7378](https://github.com/penpot/penpot/issues/7378) (PR: [#9247](https://github.com/penpot/penpot/pull/9247))
|
||||
- Fix plugin API `typography.remove()` passing wrong parameter format (by @leonaIee) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
|
||||
- Fix plugin API `typography.remove()` passing wrong parameter format (by @peter-rango) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
|
||||
- Fix plugin API fills and strokes array elements being read-only (by @RenzoMXD) [#8357](https://github.com/penpot/penpot/issues/8357) (PR: [#9161](https://github.com/penpot/penpot/pull/9161))
|
||||
- Fix "Show Guides" shortcut not working on German keyboards (by @RenzoMXD) [#8423](https://github.com/penpot/penpot/issues/8423) (PR: [#9209](https://github.com/penpot/penpot/pull/9209))
|
||||
- Fix token validation failing when a malformed token exists in the Component category [#9010](https://github.com/penpot/penpot/issues/9010) (PR: [#9025](https://github.com/penpot/penpot/pull/9025), [#9825](https://github.com/penpot/penpot/pull/9825))
|
||||
- Fix Docker frontend image missing CSS reference (by @NativeTeachingAidsB) [#9135](https://github.com/penpot/penpot/issues/9135) (PR: [#9840](https://github.com/penpot/penpot/pull/9840))
|
||||
- Fix MCP media upload error and SVG data URI image parsing (by @claytonlin1110) [#9164](https://github.com/penpot/penpot/issues/9164) (PR: [#9201](https://github.com/penpot/penpot/pull/9201))
|
||||
- Fix lost-update race on team features during concurrent file creation (by @JPette1783) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
|
||||
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @jack-stormentswe) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
|
||||
- Fix lost-update race on team features during concurrent file creation (by @Lobster-0429) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
|
||||
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @davidv399) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
|
||||
- Fix crash when creating or editing tokens named "white" or "black" [#9256](https://github.com/penpot/penpot/issues/9256) (PR: [#9034](https://github.com/penpot/penpot/pull/9034))
|
||||
- Fix conditional use-ctx hook violation in shape-wrapper (by @Dexterity104) [#9280](https://github.com/penpot/penpot/issues/9280) (PR: [#9281](https://github.com/penpot/penpot/pull/9281))
|
||||
- Make ShapeImageIds byte conversion fallible to prevent panics (by @Dexterity104) [#9282](https://github.com/penpot/penpot/issues/9282) (PR: [#9283](https://github.com/penpot/penpot/pull/9283))
|
||||
|
||||
@ -104,24 +104,20 @@
|
||||
[]
|
||||
(try
|
||||
(main/start)
|
||||
:started
|
||||
(catch Throwable cause
|
||||
(ex/print-throwable cause))))
|
||||
|
||||
(defn- stop
|
||||
[]
|
||||
(main/stop)
|
||||
:stopped)
|
||||
(main/stop))
|
||||
|
||||
(defn restart
|
||||
[]
|
||||
(stop)
|
||||
(repl/refresh :after 'user/start))
|
||||
(main/restart))
|
||||
|
||||
(defn restart-all
|
||||
[]
|
||||
(stop)
|
||||
(repl/refresh-all :after 'user/start))
|
||||
(main/restart-all))
|
||||
|
||||
;; (defn compression-bench
|
||||
;; [data]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -42,6 +42,7 @@ export PENPOT_FLAGS="\
|
||||
enable-smtp \
|
||||
enable-prepl-server \
|
||||
enable-urepl-server \
|
||||
enable-nrepl-server \
|
||||
enable-rpc-climit \
|
||||
enable-rpc-rlimit \
|
||||
enable-quotes \
|
||||
|
||||
@ -253,6 +253,8 @@
|
||||
[:urepl-port {:optional true} ::sm/int]
|
||||
[:prepl-host {:optional true} :string]
|
||||
[:prepl-port {:optional true} ::sm/int]
|
||||
[:nrepl-host {:optional true} :string]
|
||||
[:nrepl-port {:optional true} ::sm/int]
|
||||
|
||||
[:file-data-backend {:optional true} [:enum "db" "legacy-db" "storage"]]
|
||||
|
||||
|
||||
@ -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"}}))
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
(assoc :request/user-agent (yreq/get-header request "user-agent"))
|
||||
(assoc :request/ip-addr (inet/parse-request request))
|
||||
(assoc :request/profile-id (get claims :uid))
|
||||
(assoc :request/auth-data auth)
|
||||
(assoc :request/auth-data (dissoc auth :token))
|
||||
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
|
||||
|
||||
(defmulti handle-error
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
[app.storage.gc-deleted :as-alias sto.gc-deleted]
|
||||
[app.storage.gc-touched :as-alias sto.gc-touched]
|
||||
[app.storage.s3 :as-alias sto.s3]
|
||||
[app.system :as sys]
|
||||
[app.util.cron]
|
||||
[app.worker :as-alias wrk]
|
||||
[app.worker.executor]
|
||||
@ -45,7 +46,6 @@
|
||||
[clojure.tools.namespace.repl :as repl]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[nrepl.server :as nrepl]
|
||||
[promesa.exec :as px])
|
||||
(:gen-class))
|
||||
|
||||
@ -444,13 +444,17 @@
|
||||
::http.client/client (ig/ref ::http.client/client)
|
||||
::setup/props (ig/ref ::setup/props)}
|
||||
|
||||
[::srepl/urepl ::srepl/server]
|
||||
{::srepl/port (cf/get :urepl-port 6062)
|
||||
::srepl/host (cf/get :urepl-host "localhost")}
|
||||
::srepl/urepl
|
||||
{:port (cf/get :urepl-port 6062)
|
||||
:host (cf/get :urepl-host "localhost")}
|
||||
|
||||
[::srepl/prepl ::srepl/server]
|
||||
{::srepl/port (cf/get :prepl-port 6063)
|
||||
::srepl/host (cf/get :prepl-host "localhost")}
|
||||
::srepl/prepl
|
||||
{:port (cf/get :prepl-port 6063)
|
||||
:host (cf/get :prepl-host "localhost")}
|
||||
|
||||
::srepl/nrepl
|
||||
{:port (cf/get :nrepl-port 6064)
|
||||
:host (cf/get :nrepl-host "localhost")}
|
||||
|
||||
::setup/templates {}
|
||||
|
||||
@ -584,42 +588,70 @@
|
||||
::db/pool (ig/ref ::db/pool)}})
|
||||
|
||||
|
||||
(def system nil)
|
||||
|
||||
(defn start
|
||||
[]
|
||||
(cf/validate!)
|
||||
(ig/load-namespaces (merge system-config worker-config))
|
||||
(alter-var-root #'system (fn [sys]
|
||||
(when sys (ig/halt! sys))
|
||||
(-> system-config
|
||||
(cond-> (contains? cf/flags :backend-worker)
|
||||
(merge worker-config))
|
||||
(ig/expand)
|
||||
(ig/init))))
|
||||
(alter-var-root #'app.system/system
|
||||
(fn [sys]
|
||||
(some-> sys not-empty ig/halt!)
|
||||
(-> system-config
|
||||
(cond-> (contains? cf/flags :backend-worker)
|
||||
(merge worker-config))
|
||||
(ig/expand)
|
||||
(ig/init))))
|
||||
|
||||
(l/inf :hint "welcome to penpot"
|
||||
:flags (str/join "," (map name cf/flags))
|
||||
:worker? (contains? cf/flags :backend-worker)
|
||||
:version (:full cf/version)))
|
||||
:version (:full cf/version))
|
||||
:start)
|
||||
|
||||
(defn resume
|
||||
[]
|
||||
(cf/validate!)
|
||||
(ig/load-namespaces (merge system-config worker-config))
|
||||
(alter-var-root #'app.system/system
|
||||
(fn [sys]
|
||||
(let [config (-> system-config
|
||||
(cond-> (contains? cf/flags :backend-worker)
|
||||
(merge worker-config))
|
||||
(ig/expand))]
|
||||
(if-let [sys (not-empty sys)]
|
||||
(ig/resume config sys)
|
||||
(ig/init config)))))
|
||||
:resume)
|
||||
|
||||
(defn start-custom
|
||||
[config]
|
||||
(ig/load-namespaces config)
|
||||
(alter-var-root #'system (fn [sys]
|
||||
(when sys (ig/halt! sys))
|
||||
(-> config
|
||||
(ig/expand)
|
||||
(ig/init)))))
|
||||
(alter-var-root #'app.system/system
|
||||
(fn [sys]
|
||||
(some-> sys not-empty ig/halt!)
|
||||
(-> config
|
||||
(ig/expand)
|
||||
(ig/init)))))
|
||||
|
||||
(defn stop
|
||||
[]
|
||||
(alter-var-root #'system (fn [sys]
|
||||
(when sys (ig/halt! sys))
|
||||
nil)))
|
||||
(alter-var-root #'app.system/system
|
||||
(fn [sys]
|
||||
(some-> sys not-empty ig/halt!)
|
||||
{}))
|
||||
:stop)
|
||||
|
||||
(defn suspend
|
||||
[]
|
||||
(alter-var-root #'app.system/system
|
||||
(fn [sys]
|
||||
(some-> sys not-empty ig/suspend!)
|
||||
sys))
|
||||
:suspend)
|
||||
|
||||
(defn restart
|
||||
[]
|
||||
(stop)
|
||||
(repl/refresh :after 'app.main/start))
|
||||
(suspend)
|
||||
(repl/refresh :after 'app.main/resume))
|
||||
|
||||
(defn restart-all
|
||||
[]
|
||||
@ -646,15 +678,15 @@
|
||||
(test/test-vars [(resolve o)]))
|
||||
(test/test-ns o)))))
|
||||
|
||||
(repl/disable-reload! (find-ns 'integrant.core))
|
||||
|
||||
(defn -main
|
||||
[& _args]
|
||||
(try
|
||||
(let [p (promise)]
|
||||
(l/inf :hint "start nrepl server" :port 6064)
|
||||
(nrepl/start-server :bind "0.0.0.0" :port 6064)
|
||||
(ex/ignoring
|
||||
(repl/disable-reload! (find-ns 'integrant.core))
|
||||
(repl/disable-reload! (find-ns 'app.system))
|
||||
(repl/disable-reload! (find-ns 'app.common.debug)))
|
||||
|
||||
(let [p (promise)]
|
||||
(start)
|
||||
(deref p))
|
||||
(catch Throwable cause
|
||||
|
||||
@ -496,7 +496,10 @@
|
||||
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}
|
||||
|
||||
{:name "0152-improve-uuid-defaults-and-drop-extension"
|
||||
:fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.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]
|
||||
@ -339,6 +377,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}]]
|
||||
|
||||
185
backend/src/app/rpc/commands/error_reports.clj
Normal file
185
backend/src/app/rpc/commands/error_reports.clj
Normal file
@ -0,0 +1,185 @@
|
||||
;; 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]
|
||||
[:until {:optional true} ct/schema:inst]])
|
||||
|
||||
(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 until 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)]})
|
||||
(when until
|
||||
{:where "(created_at, id) < (?::timestamptz, ?::uuid)"
|
||||
:params [until 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 ASC, id ASC"
|
||||
" 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)))
|
||||
(assoc :version (:version content))
|
||||
(d/without-nils)))
|
||||
(ex/raise :type :not-found
|
||||
:code :report-not-found
|
||||
:hint (str "error report " id " not found"))))
|
||||
@ -19,7 +19,8 @@
|
||||
[clojure.core :as c]
|
||||
[clojure.core.server :as ccs]
|
||||
[clojure.main :as cm]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig]
|
||||
[nrepl.server :as nrepl]))
|
||||
|
||||
(defn- repl-init
|
||||
[]
|
||||
@ -107,37 +108,106 @@
|
||||
(finally
|
||||
(remove-tap tapfn))))))
|
||||
|
||||
;; --- State initialization
|
||||
;; --- UREPL
|
||||
|
||||
(defmethod ig/assert-key ::server
|
||||
(defmethod ig/assert-key ::urepl
|
||||
[_ params]
|
||||
(assert (int? (::port params)) "expected valid port")
|
||||
(assert (string? (::host params)) "expected valid host"))
|
||||
(assert (int? (:port params)) "expected valid port")
|
||||
(assert (string? (:host params)) "expected valid host"))
|
||||
|
||||
(defmethod ig/expand-key ::server
|
||||
[[type :as k] v]
|
||||
{k (assoc v ::flag (keyword (str (name type) "-server")))})
|
||||
(defmethod ig/init-key ::urepl
|
||||
[_ {:keys [:port :host] :as cfg}]
|
||||
(when (contains? cf/flags :urepl-server)
|
||||
|
||||
(defmethod ig/init-key ::server
|
||||
[[type _] {:keys [::flag ::port ::host] :as cfg}]
|
||||
(when (contains? cf/flags flag)
|
||||
|
||||
(l/inf :hint "initializing repl server"
|
||||
:name (name type)
|
||||
:port port
|
||||
:host host)
|
||||
|
||||
(let [accept (case type
|
||||
::prepl 'app.srepl/json-repl
|
||||
::urepl 'app.srepl/user-repl)
|
||||
(l/inf :hint "init urepl server" :host host :port port)
|
||||
(let [accept 'app.srepl/user-repl
|
||||
params {:address host
|
||||
:port port
|
||||
:name (name type)
|
||||
:name "urepl"
|
||||
:accept accept}]
|
||||
|
||||
(ccs/start-server params)
|
||||
(assoc params :type type))))
|
||||
"urepl")))
|
||||
|
||||
(defmethod ig/halt-key! ::server
|
||||
(defmethod ig/halt-key! ::urepl
|
||||
[_ name]
|
||||
(some-> name ccs/stop-server))
|
||||
|
||||
(defmethod ig/resume-key ::urepl
|
||||
[key opts _ old-name]
|
||||
(if old-name
|
||||
(do
|
||||
(l/inf :hint "keep urepl server")
|
||||
old-name)
|
||||
(ig/init-key key opts)))
|
||||
|
||||
(defmethod ig/suspend-key! ::urepl
|
||||
[_ _]
|
||||
(l/inf :hint "keep urepl server"))
|
||||
|
||||
;; --- PREPL
|
||||
|
||||
(defmethod ig/assert-key ::prepl
|
||||
[_ params]
|
||||
(some-> params :name ccs/stop-server))
|
||||
(assert (int? (:port params)) "expected valid port")
|
||||
(assert (string? (:host params)) "expected valid host"))
|
||||
|
||||
(defmethod ig/init-key ::prepl
|
||||
[_ {:keys [:port :host] :as cfg}]
|
||||
(when (contains? cf/flags :prepl-server)
|
||||
|
||||
(l/inf :hint "init prepl server" :host host :port port)
|
||||
(let [accept 'app.srepl/json-repl
|
||||
params {:address host
|
||||
:port port
|
||||
:name "prepl"
|
||||
:accept accept}]
|
||||
|
||||
(ccs/start-server params)
|
||||
"prepl")))
|
||||
|
||||
|
||||
(defmethod ig/halt-key! ::prepl
|
||||
[_ name]
|
||||
(some-> name ccs/stop-server))
|
||||
|
||||
(defmethod ig/resume-key ::prepl
|
||||
[key opts _ old-name]
|
||||
(if old-name
|
||||
(do
|
||||
(l/inf :hint "keep prepl server")
|
||||
old-name)
|
||||
(ig/init-key key opts)))
|
||||
|
||||
(defmethod ig/suspend-key! ::prepl
|
||||
[_ _]
|
||||
(l/inf :hint "keep prepl server"))
|
||||
|
||||
;; --- NREPL
|
||||
|
||||
(defmethod ig/assert-key ::nrepl
|
||||
[_ params]
|
||||
(assert (int? (:port params)) "expected valid port")
|
||||
(assert (string? (:host params)) "expected valid host"))
|
||||
|
||||
(defmethod ig/init-key ::nrepl
|
||||
[_ {:keys [:port :host] :as cfg}]
|
||||
(when (contains? cf/flags :nrepl-server)
|
||||
(l/inf :hint "init nrepl server" :host host :port port)
|
||||
(nrepl/start-server :bind host :port port)))
|
||||
|
||||
(defmethod ig/halt-key! ::nrepl
|
||||
[_ server]
|
||||
(some-> server nrepl/stop-server))
|
||||
|
||||
(defmethod ig/resume-key ::nrepl
|
||||
[key opts _ old-server]
|
||||
(if old-server
|
||||
(do
|
||||
(l/inf :hint "keep nrepl server")
|
||||
old-server)
|
||||
(ig/init-key key opts)))
|
||||
|
||||
(defmethod ig/suspend-key! ::nrepl
|
||||
[_ _]
|
||||
(l/inf :hint "keep nrepl server"))
|
||||
|
||||
@ -9,18 +9,18 @@
|
||||
[app.binfile.v2 :as binfile.v2]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.main :as main]
|
||||
[app.srepl.helpers :as h]
|
||||
[app.system :as sys]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defn export-team!
|
||||
[team-id]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(binfile.v2/export-team! main/system team-id)))
|
||||
(binfile.v2/export-team! sys/system team-id)))
|
||||
|
||||
(defn import-team!
|
||||
[path & {:keys [owner rollback?] :or {rollback? true}}]
|
||||
(db/tx-run! (assoc main/system ::db/rollback rollback?)
|
||||
(db/tx-run! (assoc sys/system ::db/rollback rollback?)
|
||||
(fn [cfg]
|
||||
(let [team (binfile.v2/import-team! cfg path)
|
||||
owner (cond
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
|
||||
(defn- get-current-system
|
||||
[]
|
||||
(or (deref (requiring-resolve 'app.main/system))
|
||||
(or (deref (requiring-resolve 'app.system/system))
|
||||
(deref (requiring-resolve 'user/system))))
|
||||
|
||||
(defmulti ^:private exec-command ::cmd)
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
[app.features.file-snapshots :as fsnap]
|
||||
[app.main :as main]))
|
||||
[app.system :as sys]))
|
||||
|
||||
(def ^:dynamic *system* nil)
|
||||
|
||||
@ -37,13 +37,13 @@
|
||||
(defn get-file
|
||||
"Get the migrated data of one file."
|
||||
([id]
|
||||
(get-file (or *system* main/system) id))
|
||||
(get-file (or *system* sys/system) id))
|
||||
([system id]
|
||||
(db/run! system bfc/get-file id)))
|
||||
|
||||
(defn get-raw-file
|
||||
"Get the migrated data of one file."
|
||||
([id] (get-raw-file (or *system* main/system) id))
|
||||
([id] (get-raw-file (or *system* sys/system) id))
|
||||
([system id]
|
||||
(db/run! system
|
||||
(fn [system]
|
||||
|
||||
@ -27,7 +27,6 @@
|
||||
[app.features.file-snapshots :as fsnap]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.main :as main]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc.commands.auth :as auth]
|
||||
[app.rpc.commands.files :as files]
|
||||
@ -37,6 +36,7 @@
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.srepl.helpers :as h]
|
||||
[app.srepl.procs.file-repair :as procs.file-repair]
|
||||
[app.system :as sys]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.worker :as wrk]
|
||||
@ -58,14 +58,14 @@
|
||||
|
||||
(defn print-tasks
|
||||
[]
|
||||
(let [tasks (:app.worker/registry main/system)]
|
||||
(let [tasks (:app.worker/registry sys/system)]
|
||||
(pp/pprint (keys tasks) :level 200)))
|
||||
|
||||
(defn run-task!
|
||||
([tname]
|
||||
(run-task! tname {}))
|
||||
([tname params]
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task tname)
|
||||
(assoc ::wrk/params params)))))
|
||||
|
||||
@ -73,14 +73,14 @@
|
||||
([name]
|
||||
(schedule-task! name {}))
|
||||
([name params]
|
||||
(wrk/submit! (-> main/system
|
||||
(wrk/submit! (-> sys/system
|
||||
(assoc ::wrk/task name)
|
||||
(assoc ::wrk/params params)))))
|
||||
|
||||
(defn send-test-email!
|
||||
[destination]
|
||||
(assert (string? destination) "destination should be provided")
|
||||
(-> main/system
|
||||
(-> sys/system
|
||||
(assoc ::wrk/task :sendmail)
|
||||
(assoc ::wrk/params {:body "test email"
|
||||
:subject "test email"
|
||||
@ -89,7 +89,7 @@
|
||||
|
||||
(defn resend-email-verification-email!
|
||||
[email]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [email (profile/clean-email email)
|
||||
profile (profile/get-profile-by-email conn email)]
|
||||
@ -103,7 +103,7 @@
|
||||
"Mark the profile blocked and removes all the http sessiones
|
||||
associated with the profile-id."
|
||||
[email]
|
||||
(some-> main/system
|
||||
(some-> sys/system
|
||||
(db/tx-run!
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(when-let [profile (db/get* conn :profile
|
||||
@ -117,7 +117,7 @@
|
||||
"Mark the profile blocked and removes all the http sessiones
|
||||
associated with the profile-id."
|
||||
[email]
|
||||
(some-> main/system
|
||||
(some-> sys/system
|
||||
(db/tx-run!
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(when-let [profile (db/get* conn :profile
|
||||
@ -135,7 +135,7 @@
|
||||
(assert (string? email) "expected email")
|
||||
(assert (string? password) "expected password")
|
||||
|
||||
(some-> main/system
|
||||
(some-> sys/system
|
||||
(db/tx-run!
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(let [password (derive-password password)
|
||||
@ -156,7 +156,7 @@
|
||||
:hint (str "feature '" feature "' not supported")))
|
||||
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [team (-> (db/get conn :team {:id team-id})
|
||||
(update :features db/decode-pgarray #{}))
|
||||
@ -175,7 +175,7 @@
|
||||
:hint (str "feature '" feature "' not supported")))
|
||||
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [team (-> (db/get conn :team {:id team-id})
|
||||
(update :features db/decode-pgarray #{}))
|
||||
@ -216,7 +216,7 @@
|
||||
:code :incorrect-level
|
||||
:hint (str "level '" level "' not supported")))
|
||||
|
||||
(let [{:keys [::mbus/msgbus ::db/pool]} main/system
|
||||
(let [{:keys [::mbus/msgbus ::db/pool]} sys/system
|
||||
|
||||
send
|
||||
(fn [dest]
|
||||
@ -321,7 +321,7 @@
|
||||
collectable file-changes entry."
|
||||
[& {:keys [file-id label]}]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [cfg]
|
||||
(let [file (bfc/get-file cfg file-id :realize? true)]
|
||||
(fsnap/create! cfg file {:label label :created-by "admin"}))))))
|
||||
@ -330,7 +330,7 @@
|
||||
[file-id & {:keys [label id]}]
|
||||
(let [file-id (h/parse-uuid file-id)
|
||||
snapshot-id (some-> id h/parse-uuid)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(cond
|
||||
(uuid? snapshot-id)
|
||||
@ -348,7 +348,7 @@
|
||||
(defn list-file-snapshots!
|
||||
[file-id & {:as _}]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [cfg]
|
||||
(->> (fsnap/get-visible-snapshots cfg file-id)
|
||||
(print-table [:label :id :revn :created-at :created-by]))))))
|
||||
@ -356,7 +356,7 @@
|
||||
(defn take-team-snapshot!
|
||||
[team-id & {:keys [label rollback?] :or {rollback? true}}]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(-> (assoc main/system ::db/rollback rollback?)
|
||||
(-> (assoc sys/system ::db/rollback rollback?)
|
||||
(db/tx-run! h/take-team-snapshot! team-id label))))
|
||||
|
||||
(defn restore-team-snapshot!
|
||||
@ -364,7 +364,7 @@
|
||||
exists for all files; if is not the case, an exception is raised."
|
||||
[team-id label & {:keys [rollback?] :or {rollback? true}}]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(-> (assoc main/system ::db/rollback rollback?)
|
||||
(-> (assoc sys/system ::db/rollback rollback?)
|
||||
(db/tx-run! h/restore-team-snapshot! team-id label))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -376,7 +376,7 @@
|
||||
all contents of a file. Returns a list of errors."
|
||||
[file-id]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! (assoc main/system ::db/rollback true)
|
||||
(db/tx-run! (assoc sys/system ::db/rollback true)
|
||||
(fn [system]
|
||||
(let [file (bfc/get-file system file-id)
|
||||
libs (bfc/get-resolved-file-libraries system file)]
|
||||
@ -387,7 +387,7 @@
|
||||
all contents of a file. Returns a list of errors."
|
||||
[file-id]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! (assoc main/system ::db/rollback true)
|
||||
(db/tx-run! (assoc sys/system ::db/rollback true)
|
||||
(fn [system]
|
||||
(try
|
||||
(let [file (bfc/get-file system file-id)]
|
||||
@ -405,7 +405,7 @@
|
||||
(defn repair-file!
|
||||
"Repair the list of errors detected by validation."
|
||||
[file-id & {:keys [rollback?] :or {rollback? true} :as options}]
|
||||
(let [system (assoc main/system ::db/rollback rollback?)
|
||||
(let [system (assoc sys/system ::db/rollback rollback?)
|
||||
file-id (h/parse-uuid file-id)
|
||||
options (assoc options ::h/with-libraries? true)]
|
||||
(db/tx-run! system h/process-file! file-id procs.file-repair/repair-file options)))
|
||||
@ -415,7 +415,7 @@
|
||||
The function receives the decoded and migrated file data."
|
||||
[file-id update-fn & {:keys [rollback?] :or {rollback? true} :as opts}]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! (assoc main/system ::db/rollback rollback?)
|
||||
(db/tx-run! (assoc sys/system ::db/rollback rollback?)
|
||||
(fn [system]
|
||||
(binding [h/*system* system
|
||||
db/*conn* (db/get-connection system)]
|
||||
@ -461,7 +461,7 @@
|
||||
(when-let [[index item] (sp/<! in-ch)]
|
||||
(l/dbg :hint "process item" :worker-id worker-id :index index :item item)
|
||||
(try
|
||||
(-> main/system
|
||||
(-> sys/system
|
||||
(assoc ::db/rollback rollback?)
|
||||
(db/tx-run! (fn [system]
|
||||
(binding [h/*system* system
|
||||
@ -506,7 +506,7 @@
|
||||
(doall))]
|
||||
|
||||
(try
|
||||
(db/tx-run! main/system process-items)
|
||||
(db/tx-run! sys/system process-items)
|
||||
|
||||
;; Await threads termination
|
||||
(doseq [thread threads]
|
||||
@ -535,13 +535,13 @@
|
||||
(defn mark-file-as-trimmed
|
||||
[id]
|
||||
(let [id (h/parse-uuid id)]
|
||||
(db/tx-run! main/system (fn [cfg]
|
||||
(-> (db/update! cfg :file
|
||||
{:has-media-trimmed true}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(db/get-update-count)
|
||||
(pos?))))))
|
||||
(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?))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; DELETE/RESTORE OBJECTS (WITH CASCADE, SOFT)
|
||||
@ -553,14 +553,14 @@
|
||||
(let [file-id (h/parse-uuid file-id)
|
||||
tnow (ct/now)]
|
||||
|
||||
(audit/insert main/system
|
||||
(audit/insert sys/system
|
||||
{:name "delete-file"
|
||||
:type "action"
|
||||
:props {:id file-id}
|
||||
:context {:triggered-by "srepl"
|
||||
:cause "explicit call to delete-file!"}
|
||||
:tracked-at tnow})
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :file
|
||||
:deleted-at tnow
|
||||
@ -571,7 +571,7 @@
|
||||
"Mark a file and all related objects as not deleted"
|
||||
[file-id]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(when-let [file (db/get* system :file
|
||||
{:id file-id}
|
||||
@ -593,7 +593,7 @@
|
||||
(let [project-id (h/parse-uuid project-id)
|
||||
tnow (ct/now)]
|
||||
|
||||
(audit/insert main/system
|
||||
(audit/insert sys/system
|
||||
{:name "delete-project"
|
||||
:type "action"
|
||||
:props {:id project-id}
|
||||
@ -601,7 +601,7 @@
|
||||
:cause "explicit call to delete-project!"}
|
||||
:tracked-at tnow})
|
||||
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :project
|
||||
:deleted-at tnow
|
||||
@ -625,7 +625,7 @@
|
||||
"Mark a project and all related objects as not deleted"
|
||||
[project-id]
|
||||
(let [project-id (h/parse-uuid project-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [system]
|
||||
(when-let [project (db/get* system :project
|
||||
{:id project-id}
|
||||
@ -645,7 +645,7 @@
|
||||
(let [team-id (h/parse-uuid team-id)
|
||||
tnow (ct/now)]
|
||||
|
||||
(audit/insert main/system
|
||||
(audit/insert sys/system
|
||||
{:name "delete-team"
|
||||
:type "action"
|
||||
:props {:id team-id}
|
||||
@ -653,7 +653,7 @@
|
||||
:cause "explicit call to delete-profile!"}
|
||||
:tracked-at tnow})
|
||||
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :team
|
||||
:deleted-at tnow
|
||||
@ -681,7 +681,7 @@
|
||||
"Mark a team and all related objects as not deleted"
|
||||
[team-id]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [system]
|
||||
(when-let [team (some-> (db/get* system :team
|
||||
{:id team-id}
|
||||
@ -702,14 +702,14 @@
|
||||
(let [profile-id (h/parse-uuid profile-id)
|
||||
tnow (ct/now)]
|
||||
|
||||
(audit/insert main/system
|
||||
(audit/insert sys/system
|
||||
{:name "delete-profile"
|
||||
:type "action"
|
||||
:context {:triggered-by "srepl"
|
||||
:cause "explicit call to delete-profile!"}
|
||||
:tracked-at tnow})
|
||||
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :profile
|
||||
:deleted-at tnow
|
||||
@ -720,7 +720,7 @@
|
||||
"Mark a team and all related objects as not deleted"
|
||||
[profile-id]
|
||||
(let [profile-id (h/parse-uuid profile-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [system]
|
||||
(when-let [profile (some-> (db/get* system :profile
|
||||
{:id profile-id}
|
||||
@ -793,9 +793,9 @@
|
||||
|
||||
(defn process-deleted-profiles-cascade
|
||||
[]
|
||||
(->> (db/exec! main/system ["select id, deleted_at from profile where deleted_at is not null"])
|
||||
(->> (db/exec! sys/system ["select id, deleted_at from profile where deleted_at is not null"])
|
||||
(run! (fn [{:keys [id deleted-at]}]
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :profile
|
||||
:deleted-at deleted-at
|
||||
@ -803,9 +803,9 @@
|
||||
|
||||
(defn process-deleted-teams-cascade
|
||||
[]
|
||||
(->> (db/exec! main/system ["select id, deleted_at from team where deleted_at is not null"])
|
||||
(->> (db/exec! sys/system ["select id, deleted_at from team where deleted_at is not null"])
|
||||
(run! (fn [{:keys [id deleted-at]}]
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :team
|
||||
:deleted-at deleted-at
|
||||
@ -813,9 +813,9 @@
|
||||
|
||||
(defn process-deleted-projects-cascade
|
||||
[]
|
||||
(->> (db/exec! main/system ["select id, deleted_at from project where deleted_at is not null"])
|
||||
(->> (db/exec! sys/system ["select id, deleted_at from project where deleted_at is not null"])
|
||||
(run! (fn [{:keys [id deleted-at]}]
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :project
|
||||
:deleted-at deleted-at
|
||||
@ -823,9 +823,9 @@
|
||||
|
||||
(defn process-deleted-files-cascade
|
||||
[]
|
||||
(->> (db/exec! main/system ["select id, deleted_at from file where deleted_at is not null"])
|
||||
(->> (db/exec! sys/system ["select id, deleted_at from file where deleted_at is not null"])
|
||||
(run! (fn [{:keys [id deleted-at]}]
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :file
|
||||
:deleted-at deleted-at
|
||||
@ -842,7 +842,7 @@
|
||||
(assert (string? client-id) "expected a valid client-id")
|
||||
(assert (string? client-secret) "expected a valid client-secret")
|
||||
(assert (string? domain) "expected a valid domain")
|
||||
(db/insert! main/system :sso-provider
|
||||
(db/insert! sys/system :sso-provider
|
||||
{:id (uuid/next)
|
||||
:type "oidc"
|
||||
:client-id client-id
|
||||
@ -856,7 +856,7 @@
|
||||
|
||||
(defn decode-session-token
|
||||
[token]
|
||||
(session/decode-token main/system token))
|
||||
(session/decode-token sys/system token))
|
||||
|
||||
(defn instrument-var
|
||||
[var]
|
||||
@ -881,7 +881,7 @@
|
||||
(defn duplicate-team
|
||||
[team-id & {:keys [name]}]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
|
||||
(let [team (-> (assoc cfg ::bfc/timestamp (ct/now))
|
||||
|
||||
9
backend/src/app/system.clj
Normal file
9
backend/src/app/system.clj
Normal file
@ -0,0 +1,9 @@
|
||||
;; 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.system)
|
||||
|
||||
(defonce system nil)
|
||||
@ -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: oldest 2 (ASC order)
|
||||
(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 (= "1" (:hint (first items))))
|
||||
(t/is (= "2" (: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 (= "3" (:hint (first items))))
|
||||
(t/is (= "4" (: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))))))
|
||||
@ -66,7 +66,7 @@ RUN set -eux; \
|
||||
|
||||
FROM base AS setup-opencode
|
||||
|
||||
ENV OPENCODE_VERSION=1.18.2
|
||||
ENV OPENCODE_VERSION=1.18.4
|
||||
|
||||
RUN set -ex; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
|
||||
@ -86,6 +86,7 @@ http {
|
||||
set $real_mtype "$upstream_http_x_mtype";
|
||||
|
||||
proxy_set_header Host "$redirect_host";
|
||||
proxy_set_header Authorization "";
|
||||
proxy_hide_header etag;
|
||||
proxy_hide_header x-amz-id-2;
|
||||
proxy_hide_header x-amz-request-id;
|
||||
|
||||
@ -94,6 +94,7 @@ http {
|
||||
proxy_buffering off;
|
||||
|
||||
proxy_set_header Host "$redirect_host";
|
||||
proxy_set_header Authorization "";
|
||||
proxy_hide_header etag;
|
||||
proxy_hide_header x-amz-id-2;
|
||||
proxy_hide_header x-amz-request-id;
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
|
||||
"fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
|
||||
"fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w",
|
||||
"lint:clj": "clj-kondo --parallel --lint ../common/src src/",
|
||||
"lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/",
|
||||
"lint:js": "exit 0",
|
||||
"lint:scss": "pnpm exec stylelint '{src,resources}/**/*.scss'",
|
||||
"build:test": "pnpm run build:wasm && clojure -M:dev:shadow-cljs compile test",
|
||||
|
||||
@ -1023,6 +1023,9 @@
|
||||
(rx/concat
|
||||
(rx/merge
|
||||
(->> modif-stream
|
||||
;; Sample at a fixed cadence to cap re-renders, mirroring the
|
||||
;; drag/resize/rotation paths throttled in #10560.
|
||||
(rx/sample mconst/move-sample-time)
|
||||
(rx/map #(dwm/set-wasm-modifiers % {:ignore-snap-pixel true})))
|
||||
|
||||
(->> modif-stream
|
||||
@ -1031,17 +1034,28 @@
|
||||
(rx/of (nudge-selected-shapes direction shift?)))
|
||||
(rx/of (finish-transform))))
|
||||
|
||||
(rx/concat
|
||||
(rx/merge
|
||||
(->> move-events
|
||||
(rx/scan #(gpt/add %1 mov-vec) (gpt/point 0 0))
|
||||
(rx/map #(dwm/create-modif-tree selected (ctm/move-modifiers %)))
|
||||
(rx/map #(dwm/set-modifiers % false true))
|
||||
(rx/take-until stopper))
|
||||
(rx/of (nudge-selected-shapes direction shift?)))
|
||||
(let [modif-stream
|
||||
(->> move-events
|
||||
(rx/scan #(gpt/add %1 mov-vec) (gpt/point 0 0))
|
||||
(rx/map #(dwm/create-modif-tree selected (ctm/move-modifiers %)))
|
||||
(rx/take-until stopper))]
|
||||
(rx/concat
|
||||
(rx/merge
|
||||
(->> modif-stream
|
||||
;; Sample at a fixed cadence to cap re-renders, mirroring the
|
||||
;; drag/resize/rotation paths throttled in #10560.
|
||||
(rx/sample mconst/move-sample-time)
|
||||
(rx/map #(dwm/set-modifiers % false true)))
|
||||
;; Un-sampled final write ensures the modifiers atom holds the
|
||||
;; exact cumulative position before `apply-modifiers` commits,
|
||||
;; even if `sample` drops the tail value on completion.
|
||||
(->> modif-stream
|
||||
(rx/last)
|
||||
(rx/map #(dwm/set-modifiers % false true)))
|
||||
(rx/of (nudge-selected-shapes direction shift?)))
|
||||
|
||||
(rx/of (dwm/apply-modifiers)
|
||||
(finish-transform)))))
|
||||
(rx/of (dwm/apply-modifiers)
|
||||
(finish-transform))))))
|
||||
(rx/empty))))))
|
||||
|
||||
(defn move-selected
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"Generic error handling"
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.main.data.auth :as da]
|
||||
[app.main.data.event :as ev]
|
||||
@ -134,13 +134,14 @@
|
||||
(with-out-str
|
||||
(println "Context:")
|
||||
(println "--------------------")
|
||||
(println "Hint: " (or (:hint data) (ex-message cause) "--"))
|
||||
(println "Prof ID: " (str (or profile-id "--")))
|
||||
(println "Team ID: " (str (or team-id "--")))
|
||||
(println "Timestamp:" (ct/format-inst (ct/now) :rfc1123))
|
||||
(println "Hint: " (or (:hint data) (ex-message cause) "--"))
|
||||
(println "Prof ID: " (str (or profile-id "--")))
|
||||
(println "Team ID: " (str (or team-id "--")))
|
||||
(when-let [file-id (or (:file-id data) file-id)]
|
||||
(println "File ID: " (str file-id)))
|
||||
(println "Version: " (:full cf/version))
|
||||
(println "HREF: " (rt/get-current-href))
|
||||
(println "File ID: " (str file-id)))
|
||||
(println "Version: " (:full cf/version))
|
||||
(println "HREF: " (rt/get-current-href))
|
||||
(println)
|
||||
|
||||
(println
|
||||
@ -149,7 +150,7 @@
|
||||
|
||||
(println "Last events:")
|
||||
(println "--------------------")
|
||||
(pp/pprint @st/last-events {:length 200})
|
||||
(println (st/format-last-events))
|
||||
(println)))
|
||||
(catch :default cause
|
||||
(.error js/console "error on generating report" cause)
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns app.main.store
|
||||
(:require
|
||||
[app.common.logging :as log]
|
||||
[app.common.time :as ct]
|
||||
[app.util.object :as obj]
|
||||
[app.util.timers :as tm]
|
||||
[beicon.v2.core :as rx]
|
||||
@ -94,6 +95,7 @@
|
||||
(rx/filter #(not (contains? omitset %)))
|
||||
(rx/map str)
|
||||
(rx/pipe (rxo/distinct-contiguous))
|
||||
(rx/map (fn [event] {:name event :t (ct/now)}))
|
||||
(rx/scan (fn [buffer event]
|
||||
(cond-> (conj buffer event)
|
||||
(> (count buffer) 50)
|
||||
@ -102,6 +104,29 @@
|
||||
(rx/subs! #(reset! buffer (vec %))))
|
||||
buffer))
|
||||
|
||||
(defn format-last-events
|
||||
"Render the `last-events` buffer as a multi-line string with the
|
||||
wall-clock time of each event and the delta (ms) since the previous
|
||||
entry. The first entry has no delta. Useful for embedding in error
|
||||
reports."
|
||||
([] (format-last-events @last-events))
|
||||
([events]
|
||||
(let [lines
|
||||
(loop [prev-t nil
|
||||
xs (seq events)
|
||||
out (transient [])]
|
||||
(if xs
|
||||
(let [{:keys [name t]} (first xs)
|
||||
iso (ct/format-inst t :iso)
|
||||
tail (if prev-t
|
||||
(str " (+" (ct/diff-ms prev-t t) "ms)")
|
||||
"")]
|
||||
(recur t
|
||||
(next xs)
|
||||
(conj! out (str iso tail " " name))))
|
||||
(persistent! out)))]
|
||||
(str/join "\n" lines))))
|
||||
|
||||
(defn emit!
|
||||
([] nil)
|
||||
([event]
|
||||
|
||||
@ -133,18 +133,21 @@
|
||||
::mf/private true}
|
||||
[{:keys [team-id children]}]
|
||||
(mf/with-effect [team-id]
|
||||
(st/emit! (dtm/initialize-team team-id))
|
||||
(fn []
|
||||
(st/emit! (dtm/finalize-team team-id))))
|
||||
(when (uuid? team-id)
|
||||
(st/emit! (dtm/initialize-team team-id))
|
||||
(fn []
|
||||
(st/emit! (dtm/finalize-team team-id)))))
|
||||
|
||||
(let [{:keys [permissions] :as team} (mf/deref refs/team)]
|
||||
(when (= team-id (:id team))
|
||||
[:> (mf/provider ctx/current-team-id) {:value team-id}
|
||||
[:> (mf/provider ctx/permissions) {:value permissions}
|
||||
[:> (mf/provider ctx/can-edit?) {:value (:can-edit permissions)}
|
||||
;; The `:key` is mandatory here because we want to reinitialize
|
||||
;; all dom tree instead of simple rerender.
|
||||
[:* {:key (str team-id)} children]]]])))
|
||||
(if-not (uuid? team-id)
|
||||
nil
|
||||
(let [{:keys [permissions] :as team} (mf/deref refs/team)]
|
||||
(when (= team-id (:id team))
|
||||
[:> (mf/provider ctx/current-team-id) {:value team-id}
|
||||
[:> (mf/provider ctx/permissions) {:value permissions}
|
||||
[:> (mf/provider ctx/can-edit?) {:value (:can-edit permissions)}
|
||||
;; The `:key` is mandatory here because we want to reinitialize
|
||||
;; all dom tree instead of simple rerender.
|
||||
[:* {:key (str team-id)} children]]]]))))
|
||||
|
||||
(mf/defc page*
|
||||
{::mf/props :obj
|
||||
|
||||
@ -371,7 +371,7 @@
|
||||
:id "token-delete"
|
||||
:handler handle-open-confirm-modal}])]
|
||||
|
||||
[:div {:class (stl/css :item)}
|
||||
[:div {:class (stl/css :item) :data-id (str id)}
|
||||
[:> text* {:as "div"
|
||||
:typography t/body-medium
|
||||
:title name
|
||||
|
||||
@ -10,9 +10,7 @@
|
||||
["rxjs" :as rxjs]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.data.auth :refer [is-authenticated?]]
|
||||
[app.main.data.common :as dcm]
|
||||
[app.main.errors :as errors]
|
||||
@ -348,53 +346,6 @@
|
||||
[:> button* {:variant "primary" :on-click on-reload}
|
||||
(tr "labels.reload-page")]]]))
|
||||
|
||||
(defn- generate-report
|
||||
[data]
|
||||
(try
|
||||
(let [team-id (:current-team-id @st/state)
|
||||
profile-id (:profile-id @st/state)
|
||||
|
||||
trace (:app.main.errors/trace data)
|
||||
instance (:app.main.errors/instance data)]
|
||||
(with-out-str
|
||||
(println "Hint: " (or (:hint data) (ex-message instance) "--"))
|
||||
(println "Prof ID: " (str (or profile-id "--")))
|
||||
(println "Team ID: " (str (or team-id "--")))
|
||||
(println "URI: " cf/public-uri)
|
||||
|
||||
(when-let [file-id (:file-id data)]
|
||||
(println "File ID:" (str file-id)))
|
||||
|
||||
(println)
|
||||
|
||||
(println "Data:")
|
||||
(loop [data data]
|
||||
(-> (d/without-qualified data)
|
||||
(dissoc :explain)
|
||||
(d/update-when :data (constantly "(...)"))
|
||||
(pp/pprint {:level 8 :length 10}))
|
||||
|
||||
(println)
|
||||
|
||||
(when-let [explain (:explain data)]
|
||||
(print explain))
|
||||
|
||||
(when (and (= :server-error (:type data))
|
||||
(contains? data :data))
|
||||
(recur (:data data))))
|
||||
|
||||
(println "Trace:")
|
||||
(println trace)
|
||||
(println)
|
||||
|
||||
(println "Last events:")
|
||||
(pp/pprint @st/last-events {:length 200})
|
||||
|
||||
(println)))
|
||||
(catch :default cause
|
||||
(.error js/console "error on generating report.txt" cause)
|
||||
nil)))
|
||||
|
||||
(mf/defc internal-error*
|
||||
[{:keys [on-reset report] :as props}]
|
||||
(let [report-uri (mf/use-ref nil)
|
||||
|
||||
@ -98,7 +98,7 @@
|
||||
(if (= (:type manifest) "penpot/export-files")
|
||||
(let [manifest (decode-manifest manifest)]
|
||||
(assoc file :type :binfile-v3 :files (:files manifest)))
|
||||
(assoc file :type :legacy-zip :body body))))
|
||||
(assoc file :type :unknown))))
|
||||
(rx/finalize (partial uz/close zip-reader))))
|
||||
|
||||
(= "application/octet-stream" mtype)
|
||||
|
||||
@ -33,17 +33,20 @@
|
||||
(pprint (sm/humanize-explain data))))
|
||||
|
||||
(defn setup-store
|
||||
[file]
|
||||
(let [state (-> initial-state
|
||||
(assoc :current-file-id (:id file)
|
||||
:current-page-id (cthf/current-page-id file)
|
||||
:permissions {:can-edit true}
|
||||
:files {(:id file) file}))
|
||||
store (ptk/store {:state state :on-error on-error})]
|
||||
;; Unit tests skip team/workspace bootstrap; mirror team init so
|
||||
;; :features is populated the same way as features/initialize does in app.
|
||||
(ptk/emit! store (features/initialize #{}))
|
||||
store))
|
||||
([file] (setup-store file nil))
|
||||
([file {:keys [renderer] :as _opts}]
|
||||
(let [state (-> initial-state
|
||||
(assoc :current-file-id (:id file)
|
||||
:current-page-id (cthf/current-page-id file)
|
||||
:permissions {:can-edit true}
|
||||
:files {(:id file) file})
|
||||
(cond-> (some? renderer)
|
||||
(assoc-in [:profile :props :renderer] renderer)))
|
||||
store (ptk/store {:state state :on-error on-error})]
|
||||
;; Unit tests skip team/workspace bootstrap; mirror team init so
|
||||
;; :features is populated the same way as features/initialize does in app.
|
||||
(ptk/emit! store (features/initialize #{}))
|
||||
store)))
|
||||
|
||||
(defn run-store
|
||||
([store done events completed-cb]
|
||||
|
||||
@ -86,6 +86,20 @@
|
||||
(track! :set-shape-grow-type)
|
||||
nil)
|
||||
|
||||
(defn- mock-set-modifiers-start
|
||||
[]
|
||||
(track! :set-modifiers-start)
|
||||
nil)
|
||||
|
||||
(defn- mock-set-modifiers-end
|
||||
[]
|
||||
(track! :set-modifiers-end)
|
||||
nil)
|
||||
|
||||
(defn- mock-get-selection-rect
|
||||
([_ids] (track! :get-selection-rect) nil)
|
||||
([_ids _] (track! :get-selection-rect) nil))
|
||||
|
||||
(defn- mock-initialized?
|
||||
[]
|
||||
(track! :initialized?)
|
||||
@ -181,6 +195,9 @@
|
||||
:set-shape-text-content wasm.api/set-shape-text-content
|
||||
:set-shape-text-images wasm.api/set-shape-text-images
|
||||
:get-text-dimensions wasm.api/get-text-dimensions
|
||||
:set-modifiers-start wasm.api/set-modifiers-start
|
||||
:set-modifiers-end wasm.api/set-modifiers-end
|
||||
:get-selection-rect wasm.api/get-selection-rect
|
||||
:font-stored? wasm.fonts/font-stored?
|
||||
:make-font-data wasm.fonts/make-font-data
|
||||
:get-content-fonts wasm.fonts/get-content-fonts})
|
||||
@ -197,6 +214,9 @@
|
||||
(set! wasm.api/set-shape-text-content mock-set-shape-text-content)
|
||||
(set! wasm.api/set-shape-text-images mock-set-shape-text-images)
|
||||
(set! wasm.api/get-text-dimensions mock-get-text-dimensions)
|
||||
(set! wasm.api/set-modifiers-start mock-set-modifiers-start)
|
||||
(set! wasm.api/set-modifiers-end mock-set-modifiers-end)
|
||||
(set! wasm.api/get-selection-rect mock-get-selection-rect)
|
||||
(set! wasm.fonts/font-stored? mock-font-stored?)
|
||||
(set! wasm.fonts/make-font-data mock-make-font-data)
|
||||
(set! wasm.fonts/get-content-fonts mock-get-content-fonts))
|
||||
@ -221,6 +241,9 @@
|
||||
(set! wasm.api/set-shape-text-content (:set-shape-text-content orig))
|
||||
(set! wasm.api/set-shape-text-images (:set-shape-text-images orig))
|
||||
(set! wasm.api/get-text-dimensions (:get-text-dimensions orig))
|
||||
(set! wasm.api/set-modifiers-start (:set-modifiers-start orig))
|
||||
(set! wasm.api/set-modifiers-end (:set-modifiers-end orig))
|
||||
(set! wasm.api/get-selection-rect (:get-selection-rect orig))
|
||||
(set! wasm.fonts/font-stored? (:font-stored? orig))
|
||||
(set! wasm.fonts/make-font-data (:make-font-data orig))
|
||||
(set! wasm.fonts/get-content-fonts (:get-content-fonts orig)))
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
;; 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 frontend-tests.logic.nudge-selected-shapes-test
|
||||
"Regression tests for the keyboard-nudge transform stream.
|
||||
|
||||
Holding an arrow key on a selection drives `nudge-selected-shapes`
|
||||
through OS key-repeat. Before the throttle introduced for issue #10726,
|
||||
every key-repeat was mapped 1:1 into a `set-modifiers`/`set-wasm-modifiers`
|
||||
store write, which could starve the renderer and trip React error #185
|
||||
(Maximum update depth exceeded).
|
||||
|
||||
These tests do NOT reproduce the render storm (that requires real React
|
||||
renders and real OS key-repeat timing the unit harness cannot simulate).
|
||||
They guard the invariant the throttle must preserve: after a burst of
|
||||
`move-selected` events, the final committed shape position equals exactly
|
||||
`(event-count) * nudge-step` — i.e. the throttle drops no displacement.
|
||||
The legacy (non-WASM) branch case specifically guards the new un-sampled
|
||||
`rx/last` commit substream against `sample` dropping the tail value on
|
||||
completion."
|
||||
(:require
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.test-helpers.compositions :as ctho]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as cthi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[app.main.data.workspace.transforms :as dwt]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.state :as ths]
|
||||
[frontend-tests.helpers.wasm :as thw]))
|
||||
|
||||
(t/use-fixtures :each
|
||||
{:before (fn []
|
||||
(cthi/reset-idmap!)
|
||||
;; The Node test environment has no real WASM binary, so the
|
||||
;; WASM-branch cases below would throw inside `wasm.api/*`
|
||||
;; calls. Install lightweight mocks that let the CLJS-side
|
||||
;; logic (`apply-wasm-modifiers`'s `dwsh/update-shapes`)
|
||||
;; run normally while stubbing the WASM heap boundary.
|
||||
(thw/setup-wasm-mocks!))
|
||||
:after (fn []
|
||||
;; Teardown runs after the async test's `done` fires.
|
||||
(thw/teardown-wasm-mocks!))})
|
||||
|
||||
(def ^:private nudge-event-count
|
||||
"Number of `move-selected` events emitted in a burst. Chosen large enough
|
||||
that, with a 16 ms `sample` cadence and the unit harness's synchronous
|
||||
dispatch, the throttled live-preview substream would coalesce emissions
|
||||
were it not for the final `rx/last` commit honoring the exact cumulative
|
||||
position."
|
||||
20)
|
||||
|
||||
(def ^:private run-store-timeout-ms
|
||||
"Delay before `run-store` invokes its completion callback.
|
||||
|
||||
`nudge-selected-shapes` does not complete its transform streams on source
|
||||
completion alone; its internal stopper fires either after 1000 ms of idle
|
||||
time or 250 ms after a key-up event (see `transforms.cljs`). The unit
|
||||
harness emits no keyboard events, so only the 1000 ms idle timer can fire.
|
||||
We let the harness wait long enough for that timer to fire and run the
|
||||
final `apply-modifiers`/`finish-transform` before reading the store."
|
||||
1500)
|
||||
|
||||
(defn- run-nudge-store
|
||||
"Like `ths/run-store` but the outer subscription completes on a fixed
|
||||
timer instead of immediately on `:the/end`, giving the nudge's idle
|
||||
stopper time to fire and commit the cumulative displacement."
|
||||
[store done events completed-cb]
|
||||
(ths/run-store store done events completed-cb (fn [_stream] (rx/timer run-store-timeout-ms))))
|
||||
|
||||
(defn- shape-x
|
||||
"Read a shape's committed x position from its points-derived rect."
|
||||
[file label]
|
||||
(let [shape (cths/get-shape file label)
|
||||
rect (grc/points->rect (:points shape))]
|
||||
(:x rect)))
|
||||
|
||||
(defn- shape-y
|
||||
"Read a shape's committed y position from its points-derived rect."
|
||||
[file label]
|
||||
(let [shape (cths/get-shape file label)
|
||||
rect (grc/points->rect (:points shape))]
|
||||
(:y rect)))
|
||||
|
||||
(defn- make-file
|
||||
[]
|
||||
(-> (cthf/sample-file :file1)
|
||||
(ctho/add-rect :rect1 :x 100 :y 200 :width 50 :height 50)))
|
||||
|
||||
(defn- burst-move-events
|
||||
"Builds the event sequence: select rect, then `n` `move-selected` events
|
||||
in the given `direction` (with `shift?` controlling the nudge big/small
|
||||
step size — false = 1 px, true = 10 px)."
|
||||
[file direction shift? n]
|
||||
(let [rect (cths/get-shape file :rect1)]
|
||||
(into [(dws/select-shape (:id rect))]
|
||||
(repeat n (dwt/move-selected direction shift?)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Default (WASM) branch — `render-wasm/v1` active (helpers/state default).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(t/deftest nudge-burst-wasm-branches-commits-exact-final-position-right-small
|
||||
(t/async
|
||||
done
|
||||
(let [file (make-file)
|
||||
store (ths/setup-store file)
|
||||
events (burst-move-events file :right false nudge-event-count)]
|
||||
(run-nudge-store
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
final-x (shape-x file' :rect1)]
|
||||
;; nudge small = 1 px, direction :right => +x.
|
||||
(t/is (= (+ 100 nudge-event-count) final-x)
|
||||
"WASM branch: final x equals original + N * small-nudge after a throttled burst")))))))
|
||||
|
||||
(t/deftest nudge-burst-wasm-branches-commits-exact-final-position-up-big
|
||||
(t/async
|
||||
done
|
||||
(let [file (make-file)
|
||||
store (ths/setup-store file)
|
||||
events (burst-move-events file :up true nudge-event-count)]
|
||||
(run-nudge-store
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
final-y (shape-y file' :rect1)]
|
||||
;; nudge big = 10 px, direction :up => -y.
|
||||
(t/is (= (- 200 (* 10 nudge-event-count)) final-y)
|
||||
"WASM branch: final y equals original - N * big-nudge after a throttled burst")))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Legacy (non-WASM) branch — :renderer :svg disables render-wasm/v1.
|
||||
;; This case specifically guards the new `rx/last` commit substream added
|
||||
;; alongside the live-preview sample: if `sample` drops the tail value on
|
||||
;; completion, the un-sampled `rx/last` write still delivers the exact
|
||||
;; cumulative position to `apply-modifiers`.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(t/deftest nudge-burst-legacy-branch-commits-exact-final-position-right-small
|
||||
(t/async
|
||||
done
|
||||
(let [file (make-file)
|
||||
store (ths/setup-store file {:renderer :svg})
|
||||
events (burst-move-events file :right false nudge-event-count)]
|
||||
(run-nudge-store
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
final-x (shape-x file' :rect1)]
|
||||
(t/is (= (+ 100 nudge-event-count) final-x)
|
||||
"Legacy branch: final x equals original + N * small-nudge after a throttled burst")))))))
|
||||
|
||||
(t/deftest nudge-burst-legacy-branch-commits-exact-final-position-up-big
|
||||
(t/async
|
||||
done
|
||||
(let [file (make-file)
|
||||
store (ths/setup-store file {:renderer :svg})
|
||||
events (burst-move-events file :up true nudge-event-count)]
|
||||
(run-nudge-store
|
||||
store done events
|
||||
(fn [new-state]
|
||||
(let [file' (ths/get-file-from-state new-state)
|
||||
final-y (shape-y file' :rect1)]
|
||||
(t/is (= (- 200 (* 10 nudge-event-count)) final-y)
|
||||
"Legacy branch: final y equals original - N * big-nudge after a throttled burst")))))))
|
||||
@ -27,6 +27,7 @@
|
||||
[frontend-tests.logic.copying-and-duplicating-test]
|
||||
[frontend-tests.logic.frame-guides-test]
|
||||
[frontend-tests.logic.groups-test]
|
||||
[frontend-tests.logic.nudge-selected-shapes-test]
|
||||
[frontend-tests.logic.pasting-in-containers-test]
|
||||
[frontend-tests.main-errors-test]
|
||||
[frontend-tests.plugins.comments-test]
|
||||
@ -104,6 +105,7 @@
|
||||
'frontend-tests.logic.copying-and-duplicating-test
|
||||
'frontend-tests.logic.frame-guides-test
|
||||
'frontend-tests.logic.groups-test
|
||||
'frontend-tests.logic.nudge-selected-shapes-test
|
||||
'frontend-tests.logic.pasting-in-containers-test
|
||||
'frontend-tests.main-errors-test
|
||||
'frontend-tests.plugins.comments-test
|
||||
|
||||
@ -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
|
||||
|
||||
@ -712,16 +712,21 @@ impl Shape {
|
||||
}
|
||||
|
||||
pub fn set_path_segments(&mut self, segments: Vec<Segment>) {
|
||||
let path = Path::new(segments);
|
||||
match &mut self.shape_type {
|
||||
Type::Bool(Bool { bool_type, .. }) => {
|
||||
let path = match bool_type {
|
||||
// Exclusion booleans are computed with even-odd semantics but
|
||||
// PathData uploads do not carry the fill rule.
|
||||
BoolType::Exclusion => Path::new(segments).with_even_odd(true),
|
||||
_ => Path::new(segments),
|
||||
};
|
||||
self.shape_type = Type::Bool(Bool {
|
||||
bool_type: *bool_type,
|
||||
path,
|
||||
});
|
||||
}
|
||||
Type::Path(_) => {
|
||||
self.shape_type = Type::Path(path);
|
||||
self.shape_type = Type::Path(Path::new(segments));
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
@ -667,9 +667,12 @@ pub fn reflow_flex_layout(
|
||||
transform.post_concat(&Matrix::translate(delta_v));
|
||||
}
|
||||
|
||||
result.push_back(Modifier::transform_propagate(child.id, transform));
|
||||
if child.has_layout() {
|
||||
result.push_back(Modifier::reflow(child.id, force_reflow));
|
||||
// Skip identity: propagating it fans out through the whole subtree.
|
||||
if !math::identitish(&transform) {
|
||||
result.push_back(Modifier::transform_propagate(child.id, transform));
|
||||
if child.has_layout() {
|
||||
result.push_back(Modifier::reflow(child.id, force_reflow));
|
||||
}
|
||||
}
|
||||
|
||||
shape_anchor = next_anchor(
|
||||
|
||||
@ -884,9 +884,12 @@ pub fn reflow_grid_layout(
|
||||
transform.post_concat(&Matrix::translate(delta_v));
|
||||
}
|
||||
|
||||
result.push_back(Modifier::transform_propagate(child.id, transform));
|
||||
if child.has_layout() {
|
||||
result.push_back(Modifier::reflow(child.id, force_reflow));
|
||||
// Skip identity: propagating it fans out through the whole subtree.
|
||||
if !math::identitish(&transform) {
|
||||
result.push_back(Modifier::transform_propagate(child.id, transform));
|
||||
if child.has_layout() {
|
||||
result.push_back(Modifier::reflow(child.id, force_reflow));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -262,6 +262,9 @@ impl Path {
|
||||
|
||||
pub fn to_skia_path(&self, svg_attrs: Option<&SvgAttrs>) -> skia::Path {
|
||||
let mut path = self.skia_path.snapshot();
|
||||
if self.is_even_odd() {
|
||||
path.set_fill_type(skia::PathFillType::EvenOdd);
|
||||
}
|
||||
if let Some(attrs) = svg_attrs {
|
||||
if attrs.fill_rule == FillRule::Evenodd {
|
||||
path.set_fill_type(skia::PathFillType::EvenOdd);
|
||||
|
||||
615
scripts/error-reports.mjs
Executable file
615
scripts/error-reports.mjs
Executable file
@ -0,0 +1,615 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import dotenv from "dotenv";
|
||||
import { readFileSync, existsSync, createWriteStream } from "fs";
|
||||
import path from "path";
|
||||
import { createInterface } from "readline";
|
||||
|
||||
// ============================================================================
|
||||
// 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();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hint Normalization
|
||||
// ============================================================================
|
||||
|
||||
function normalizeHint(hint) {
|
||||
if (!hint) return hint;
|
||||
let h = hint;
|
||||
h = h.replace(/https?:\/\/"[^"]*"/g, '<uri>');
|
||||
h = h.replace(/https?:\/\/\S+/g, '<uri>');
|
||||
h = h.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<uuid>');
|
||||
h = h.replace(/[\d.]+[smh](?:[\d.]+[smh])?/g, '<elapsed>');
|
||||
h = h.replace(/\(\d+\)/g, '(<id>)');
|
||||
return h.trim();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Output Formatting
|
||||
// ============================================================================
|
||||
|
||||
function formatJson(data) {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function truncateHint(text, maxLength) {
|
||||
if (!text) return "-";
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
const TABLE_HEADERS = ["ID", "Created At", "Source", "Profile ID", "Kind", "Hint"];
|
||||
|
||||
function formatTableRow(item, normalizeHints) {
|
||||
const hint = normalizeHints ? normalizeHint(item.hint) : item.hint;
|
||||
return [
|
||||
item.id || "-",
|
||||
item.createdAt || "-",
|
||||
item.source || "-",
|
||||
item.profileId || "-",
|
||||
item.kind || "-",
|
||||
truncateHint(hint, 60)
|
||||
];
|
||||
}
|
||||
|
||||
function computeColWidths(headerLines, dataRows) {
|
||||
return headerLines.map((h, i) => {
|
||||
const maxData = Math.max(...dataRows.map(r => (r[i] || "").toString().length));
|
||||
return Math.max(h.length, maxData);
|
||||
});
|
||||
}
|
||||
|
||||
function padRow(row, colWidths) {
|
||||
return row.map((cell, i) => (cell || "-").toString().padEnd(colWidths[i])).join(" | ");
|
||||
}
|
||||
|
||||
function formatListTable(data, hasMore = false, normalizeHints = 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 rows = data.items.map(item => formatTableRow(item, normalizeHints));
|
||||
const colWidths = computeColWidths(TABLE_HEADERS, rows);
|
||||
|
||||
lines.push(padRow(TABLE_HEADERS, colWidths));
|
||||
lines.push(colWidths.map(w => "-".repeat(w)).join("-+-"));
|
||||
lines.push(...rows.map(row => padRow(row, colWidths)));
|
||||
|
||||
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 printTableHeaderStr(normalizeHints) {
|
||||
const colWidths = TABLE_HEADERS.map(h => Math.max(h.length, h === "ID" ? 36 : 10));
|
||||
return padRow(TABLE_HEADERS, colWidths) + "\n" + colWidths.map(w => "-".repeat(w)).join("-+-") + "\n";
|
||||
}
|
||||
|
||||
function printTableRowStr(item, normalizeHints) {
|
||||
const row = formatTableRow(item, normalizeHints);
|
||||
return row.map(cell => (cell || "-").toString()).join(" | ");
|
||||
}
|
||||
|
||||
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)"}`);
|
||||
|
||||
if (data.context) {
|
||||
lines.push(`--- Context ---`);
|
||||
const indented = data.context.split("\n").map(line => ` ${line}`).join("\n");
|
||||
lines.push(indented);
|
||||
}
|
||||
|
||||
if (data.params && data.params !== "{}") {
|
||||
lines.push(`--- Params ---`);
|
||||
const indented = data.params.split("\n").map(line => ` ${line}`).join("\n");
|
||||
lines.push(indented);
|
||||
}
|
||||
|
||||
if (data.props && data.props !== "{}") {
|
||||
lines.push(`--- Props ---`);
|
||||
const indented = data.props.split("\n").map(line => ` ${line}`).join("\n");
|
||||
lines.push(indented);
|
||||
}
|
||||
|
||||
const body = data.trace || data.report;
|
||||
lines.push(`--- Report ---`);
|
||||
if (body) {
|
||||
const indented = body.split("\n").map(line => ` ${line}`).join("\n");
|
||||
lines.push(indented);
|
||||
} 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.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;
|
||||
|
||||
// --from maps to server's --since (oldest boundary)
|
||||
// --since / --since-id are explicit cursor overrides
|
||||
if (args.since) {
|
||||
params.since = args.since;
|
||||
} else if (args.from) {
|
||||
params.since = args.from;
|
||||
}
|
||||
if (args.sinceId) params["since-id"] = args.sinceId;
|
||||
|
||||
// --to maps to server's --until (newest boundary)
|
||||
if (args.to) params.until = args.to;
|
||||
|
||||
// Output target
|
||||
const out = args.output
|
||||
? createWriteStream(args.output)
|
||||
: process.stdout;
|
||||
const write = (text) => new Promise((resolve) => out.write(text, resolve));
|
||||
|
||||
const streaming = args.all || args.format === "ndjson";
|
||||
let itemCount = 0;
|
||||
|
||||
if (streaming) {
|
||||
// Streaming mode: print items as they arrive
|
||||
if (args.format === "table") {
|
||||
await write(printTableHeaderStr(args.normalizeHints));
|
||||
}
|
||||
|
||||
let sinceParam = params.since;
|
||||
let sinceIdParam = params["since-id"];
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const currentParams = { ...params };
|
||||
if (sinceParam) currentParams.since = sinceParam;
|
||||
if (sinceIdParam) currentParams["since-id"] = sinceIdParam;
|
||||
|
||||
const result = await rpcCall(config, "get-error-reports", currentParams);
|
||||
|
||||
for (const item of result.items) {
|
||||
itemCount++;
|
||||
const outItem = args.normalizeHints ? { ...item, hint: normalizeHint(item.hint) } : item;
|
||||
if (args.format === "table") {
|
||||
await write(printTableRowStr(outItem, false) + "\n");
|
||||
} else {
|
||||
await write(JSON.stringify(outItem) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.nextSince && result.nextId) {
|
||||
sinceParam = result.nextSince;
|
||||
sinceIdParam = result.nextId;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single page: buffer and print
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (args.normalizeHints) {
|
||||
for (const item of allItems) {
|
||||
item.hint = normalizeHint(item.hint);
|
||||
}
|
||||
}
|
||||
|
||||
itemCount = allItems.length;
|
||||
const output = { items: allItems };
|
||||
|
||||
if (lastResult) {
|
||||
output.nextSince = lastResult.nextSince;
|
||||
output.nextId = lastResult.nextId;
|
||||
}
|
||||
|
||||
if (args.format === "table") {
|
||||
const hasMore = lastResult.nextSince && lastResult.nextId;
|
||||
await write(formatListTable(output, hasMore, false) + "\n");
|
||||
} else {
|
||||
await write(formatJson(output) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (args.output) {
|
||||
out.end();
|
||||
process.stderr.write(`Wrote ${itemCount} items to ${args.output}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
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(formatGetTable(result));
|
||||
} else {
|
||||
console.log(formatJson(result));
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdStats(config, args) {
|
||||
let items;
|
||||
|
||||
if (args.input) {
|
||||
// Read from file (supports JSON, JSON array, and NDJSON)
|
||||
const raw = readFileSync(args.input, "utf-8");
|
||||
items = parseItems(raw);
|
||||
} else if (!process.stdin.isTTY) {
|
||||
// Read from stdin (supports JSON, JSON array, and NDJSON)
|
||||
const raw = await readStdin();
|
||||
items = parseItems(raw);
|
||||
} else {
|
||||
// Fetch from API
|
||||
const params = {};
|
||||
if (args.limit !== undefined) params.limit = args.limit;
|
||||
if (args.from) params.since = args.from;
|
||||
if (args.to) params["until"] = args.to;
|
||||
|
||||
items = [];
|
||||
let sinceParam = params.since;
|
||||
let sinceIdParam = undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const currentParams = { ...params };
|
||||
if (sinceParam) currentParams.since = sinceParam;
|
||||
if (sinceIdParam) currentParams["since-id"] = sinceIdParam;
|
||||
|
||||
const result = await rpcCall(config, "get-error-reports", currentParams);
|
||||
items = [...items, ...result.items];
|
||||
|
||||
if (result.nextSince && result.nextId) {
|
||||
sinceParam = result.nextSince;
|
||||
sinceIdParam = result.nextId;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log("No error reports found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize all hints for grouping
|
||||
for (const item of items) {
|
||||
item._normHint = normalizeHint(item.hint) || "(empty)";
|
||||
}
|
||||
|
||||
// Aggregations
|
||||
const byHint = {};
|
||||
const byHost = {};
|
||||
const byTenant = {};
|
||||
const byVersion = {};
|
||||
const bySource = {};
|
||||
const byKind = {};
|
||||
const byHour = {};
|
||||
const profiles = new Set();
|
||||
|
||||
for (const item of items) {
|
||||
count(byHint, item._normHint);
|
||||
count(byHost, item.host || item.tenant || "(unknown)");
|
||||
count(byTenant, item.tenant || "(unknown)");
|
||||
count(byVersion, item.version || "(unknown)");
|
||||
count(bySource, item.source || "(unknown)");
|
||||
count(byKind, item.kind || "(none)");
|
||||
|
||||
const hour = item.createdAt ? item.createdAt.substring(11, 13) : "??";
|
||||
count(byHour, hour);
|
||||
|
||||
if (item.profileId) profiles.add(item.profileId);
|
||||
}
|
||||
|
||||
if (args.format === "json") {
|
||||
console.log(formatJson({
|
||||
total: items.length,
|
||||
uniqueProfiles: profiles.size,
|
||||
byHint: sortDesc(byHint),
|
||||
byHost: sortDesc(byHost),
|
||||
byTenant: sortDesc(byTenant),
|
||||
byVersion: sortDesc(byVersion),
|
||||
bySource: sortDesc(bySource),
|
||||
byKind: sortDesc(byKind),
|
||||
byHour: sortDesc(byHour)
|
||||
}));
|
||||
} else {
|
||||
console.log(`=== Error Stats ===`);
|
||||
console.log(`Total: ${items.length}`);
|
||||
console.log(`Unique profiles: ${profiles.size}`);
|
||||
console.log("");
|
||||
|
||||
printTable("By Signature (normalized hint)", byHint, items.length);
|
||||
printTable("By Source", bySource, items.length);
|
||||
printTable("By Kind", byKind, items.length);
|
||||
printTable("By Host", byHost, items.length);
|
||||
printTable("By Tenant", byTenant, items.length);
|
||||
printTable("By Version", byVersion, items.length);
|
||||
printTable("By Hour (UTC)", byHour, items.length);
|
||||
}
|
||||
}
|
||||
|
||||
function readStdin() {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => { data += line + "\n"; });
|
||||
rl.on("close", () => resolve(data));
|
||||
});
|
||||
}
|
||||
|
||||
function parseItems(raw) {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
return Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
// NDJSON: one JSON object per line
|
||||
return raw.split("\n")
|
||||
.filter(line => line.trim())
|
||||
.map(line => JSON.parse(line));
|
||||
}
|
||||
}
|
||||
|
||||
function count(map, key) {
|
||||
map[key] = (map[key] || 0) + 1;
|
||||
}
|
||||
|
||||
function sortDesc(map) {
|
||||
return Object.entries(map)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([key, count]) => ({ key, count }));
|
||||
}
|
||||
|
||||
function printTable(title, map, total) {
|
||||
const entries = Object.entries(map).sort((a, b) => b[1] - a[1]);
|
||||
const maxCount = entries[0]?.[1] || 1;
|
||||
const barWidth = 30;
|
||||
|
||||
console.log(`${title}:`);
|
||||
for (const [key, n] of entries) {
|
||||
const pct = ((100 * n) / total).toFixed(1);
|
||||
const barLen = Math.max(1, Math.round((n / maxCount) * barWidth));
|
||||
const bar = "█".repeat(barLen);
|
||||
const label = key.length > 60 ? key.substring(0, 57) + "..." : key;
|
||||
console.log(` ${String(n).padStart(5)} (${pct.padStart(5)}%) ${bar} ${label}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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("--from <date>", "ISO timestamp — oldest boundary (fetches items after this)")
|
||||
.option("--to <date>", "ISO timestamp — newest boundary (fetches items before this)")
|
||||
.option("--since <date>", "ISO timestamp — explicit cursor for manual pagination")
|
||||
.option("--since-id <uuid>", "Fetch errors after 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 (streams output)", false)
|
||||
.option("-f, --format <type>", "Output format (json|table|ndjson)", "table")
|
||||
.option("--normalize-hints", "Normalize hints by stripping dynamic values", false)
|
||||
.option("-o, --output <file>", "Write output to file instead of stdout")
|
||||
.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
|
||||
.command("stats")
|
||||
.description("Compute error report statistics")
|
||||
.option("--from <date>", "Start of interval (ISO timestamp)")
|
||||
.option("--to <date>", "End of interval (ISO timestamp)")
|
||||
.option("--limit <n>", "Items per page (default: 200)", (value) => parseInt(value, 10), 200)
|
||||
.option("--input <file>", "Read from local JSON/NDJSON file instead of API")
|
||||
.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 cmdStats(config, options);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
Loading…
x
Reference in New Issue
Block a user