ML-165: implementation

This commit is contained in:
Claudio Ortolina
2026-05-05 13:43:18 +01:00
parent 008dc20ff4
commit cccd28a9f9
10 changed files with 1084 additions and 86 deletions
+543 -2
View File
@@ -312,6 +312,39 @@ async function fetchError(
); );
} }
async function postApi<T>(
url: string,
token: string,
validate: (data: unknown) => T,
signal?: AbortSignal,
): Promise<T> {
const response = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "(unable to read body)");
const truncated = body.length > 500 ? body.slice(0, 500) + "..." : body;
throw new Error(
`API returned ${response.status} ${response.statusText}\n${truncated}`,
);
}
let data: unknown;
try {
data = await response.json();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to parse API response: ${message}. Status: ${response.status}.`,
);
}
return validate(data);
}
// ── TUI Theme ─────────────────────────────────────────────────────────────── // ── TUI Theme ───────────────────────────────────────────────────────────────
/** Minimal theme interface for color application in the ErrorBrowser. */ /** Minimal theme interface for color application in the ErrorBrowser. */
@@ -596,6 +629,112 @@ class ErrorBrowser {
}); });
} }
toggleMute(id: number, currentMuted: boolean): void {
if (this.mode === "loading") return;
this.abortInFlight();
this.mode = "loading";
this.invalidate();
this.requestRender();
const action = currentMuted ? "unmute" : "mute";
const url = buildUrl(this.baseUrl, `/api/v1/errors/${id}/${action}`);
const controller = new AbortController();
this.currentAbortController = controller;
postApi<ErrorDetailResponse>(
url,
this.token,
(data) => data as ErrorDetailResponse,
controller.signal,
)
.then((data) => {
// Update local error state in the list
const idx = this.errors.findIndex((e) => e.id === id);
if (idx !== -1) {
const updated = data.error;
this.errors[idx] = {
...this.errors[idx]!,
muted: updated.muted,
};
}
// Update detail view if viewing this error
if (this.selectedError && this.selectedError.id === id) {
this.selectedError = {
...this.selectedError,
muted: data.error.muted,
};
}
this.mode = this.selectedError ? "detail" : "list";
this.notify(`Error #${id} ${action}d successfully`, "info");
this.invalidate();
this.requestRender();
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") return;
this.mode = this.selectedError ? "detail" : "list";
const msg = err instanceof Error ? err.message : String(err);
this.notify(`Toggle mute failed: ${msg}`, "error");
this.invalidate();
this.requestRender();
});
}
toggleResolve(id: number, currentStatus: string): void {
if (this.mode === "loading") return;
this.abortInFlight();
this.mode = "loading";
this.invalidate();
this.requestRender();
const action = currentStatus === "resolved" ? "unresolve" : "resolve";
const url = buildUrl(this.baseUrl, `/api/v1/errors/${id}/${action}`);
const controller = new AbortController();
this.currentAbortController = controller;
postApi<ErrorDetailResponse>(
url,
this.token,
(data) => data as ErrorDetailResponse,
controller.signal,
)
.then((data) => {
// Update local error state in the list
const idx = this.errors.findIndex((e) => e.id === id);
if (idx !== -1) {
const updated = data.error;
this.errors[idx] = {
...this.errors[idx]!,
status: updated.status,
};
}
// Update detail view if viewing this error
if (this.selectedError && this.selectedError.id === id) {
this.selectedError = {
...this.selectedError,
status: data.error.status,
};
}
this.mode = this.selectedError ? "detail" : "list";
this.notify(`Error #${id} ${action}d successfully`, "info");
this.invalidate();
this.requestRender();
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") return;
this.mode = this.selectedError ? "detail" : "list";
const msg = err instanceof Error ? err.message : String(err);
this.notify(`Toggle resolve failed: ${msg}`, "error");
this.invalidate();
this.requestRender();
});
}
// ── Input handling ───────────────────────────────────────────────────── // ── Input handling ─────────────────────────────────────────────────────
handleInput(data: string): void { handleInput(data: string): void {
@@ -631,6 +770,26 @@ class ErrorBrowser {
return; return;
} }
if (matchesKey(data, "R")) {
if (this.errors.length > 0 && this.cursorIndex >= 0) {
const error = this.errors[this.cursorIndex];
if (error) {
this.toggleResolve(error.id, error.status);
}
}
return;
}
if (matchesKey(data, "M")) {
if (this.errors.length > 0 && this.cursorIndex >= 0) {
const error = this.errors[this.cursorIndex];
if (error) {
this.toggleMute(error.id, error.muted);
}
}
return;
}
if (matchesKey(data, "l")) { if (matchesKey(data, "l")) {
if (this.offset + this.limit >= this.total) return; if (this.offset + this.limit >= this.total) return;
this.loadMore(); this.loadMore();
@@ -692,6 +851,20 @@ class ErrorBrowser {
// ── Detail mode keys ─────────────────────────────────────────────── // ── Detail mode keys ───────────────────────────────────────────────
if (this.mode === "detail") { if (this.mode === "detail") {
if (matchesKey(data, "R")) {
if (this.selectedError) {
this.toggleResolve(this.selectedError.id, this.selectedError.status);
}
return;
}
if (matchesKey(data, "M")) {
if (this.selectedError) {
this.toggleMute(this.selectedError.id, this.selectedError.muted);
}
return;
}
if (matchesKey(data, Key.enter)) { if (matchesKey(data, Key.enter)) {
// Copy current line to editor (prod-logs pattern) // Copy current line to editor (prod-logs pattern)
if (this.selectedError) { if (this.selectedError) {
@@ -896,7 +1069,7 @@ class ErrorBrowser {
truncateToWidth( truncateToWidth(
theme.fg( theme.fg(
"dim", "dim",
` ↑↓/jk navigate ↵ details r ${resolvedLabel} resolved m ${mutedLabel} muted`, ` ↑↓/jk navigate ↵ details r ${resolvedLabel} resolved m ${mutedLabel} muted M toggle mute R toggle resolve`,
), ),
width, width,
"", "",
@@ -975,7 +1148,7 @@ class ErrorBrowser {
truncateToWidth( truncateToWidth(
theme.fg( theme.fg(
"dim", "dim",
` Lines ${fromLine}-${toLine} of ${detailLines.length} (${pct}%) ↑↓/jk scroll Enter copy Escape back`, ` Lines ${fromLine}-${toLine} of ${detailLines.length} (${pct}%) ↑↓/jk scroll Enter copy M mute R resolve Escape back`,
), ),
width, width,
"", "",
@@ -1254,6 +1427,374 @@ export default function prodErrorsExtension(pi: ExtensionAPI) {
}, },
}); });
// ── mute_production_error tool ─────────────────────────────────────────
pi.registerTool({
name: "mute_production_error",
label: "Mute Production Error",
description:
"Mute a production error by ID. Muted errors will not trigger email " +
"notifications. Use this tool to silence noisy or already-addressed errors.",
promptSnippet:
"Mute a production error by ID to suppress email notifications",
promptGuidelines: [
"Use mute_production_error to silence notifications for a noisy or already-addressed production error.",
"Get the error ID from fetch_production_errors first. Muted errors still appear in listings but show a [MUTED] label.",
],
parameters: Type.Object({
id: Type.Number({
description:
"The error ID (integer) to mute. Get this from fetch_production_errors.",
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
const token = resolveVar("api_token");
const fqdn = resolveVar("service_fqdn_web");
const missing: string[] = [];
if (!token) missing.push("PI_API_TOKEN");
if (!fqdn) missing.push("PI_SERVICE_FQDN_WEB");
if (missing.length > 0) {
return {
content: [
{
type: "text",
text:
`Cannot mute production error: the following environment variables are not set:\n` +
missing.map((v) => ` - ${v}`).join("\n") +
`\n\nEnsure these are configured in your pi environment.`,
},
],
isError: true,
};
}
try {
const data = await postApi<ErrorDetailResponse>(
buildUrl(fqdn!, `/api/v1/errors/${params.id}/mute`),
token!,
(data) => data as ErrorDetailResponse,
signal,
);
return {
content: [
{
type: "text",
text:
`Error #${params.id} muted successfully.` +
(data.error?.muted
? ""
: " (Warning: muted field not reflected in response)"),
},
],
details: { errorId: params.id, muted: data.error?.muted },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("404")) {
return {
content: [
{
type: "text",
text: `Error with ID ${params.id} not found.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Failed to mute production error: ${message}`,
},
],
isError: true,
};
}
},
});
// ── unmute_production_error tool ───────────────────────────────────────
pi.registerTool({
name: "unmute_production_error",
label: "Unmute Production Error",
description:
"Unmute a previously muted production error by ID, re-enabling email " +
"notifications for new occurrences.",
promptSnippet:
"Unmute a production error by ID to re-enable email notifications",
promptGuidelines: [
"Use unmute_production_error to re-enable notifications for a previously muted error.",
"Get the error ID from fetch_production_errors first.",
],
parameters: Type.Object({
id: Type.Number({
description:
"The error ID (integer) to unmute. Get this from fetch_production_errors.",
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
const token = resolveVar("api_token");
const fqdn = resolveVar("service_fqdn_web");
const missing: string[] = [];
if (!token) missing.push("PI_API_TOKEN");
if (!fqdn) missing.push("PI_SERVICE_FQDN_WEB");
if (missing.length > 0) {
return {
content: [
{
type: "text",
text:
`Cannot unmute production error: the following environment variables are not set:\n` +
missing.map((v) => ` - ${v}`).join("\n") +
`\n\nEnsure these are configured in your pi environment.`,
},
],
isError: true,
};
}
try {
const data = await postApi<ErrorDetailResponse>(
buildUrl(fqdn!, `/api/v1/errors/${params.id}/unmute`),
token!,
(data) => data as ErrorDetailResponse,
signal,
);
return {
content: [
{
type: "text",
text: `Error #${params.id} unmuted successfully. Email notifications will resume for new occurrences.`,
},
],
details: { errorId: params.id, muted: data.error?.muted },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("404")) {
return {
content: [
{
type: "text",
text: `Error with ID ${params.id} not found.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Failed to unmute production error: ${message}`,
},
],
isError: true,
};
}
},
});
// ── resolve_production_error tool ───────────────────────────────────────
pi.registerTool({
name: "resolve_production_error",
label: "Resolve Production Error",
description:
"Mark a production error as resolved by ID. Resolved errors are filtered " +
"out of the default unresolved view.",
promptSnippet:
"Mark a production error as resolved when the underlying issue has been fixed",
promptGuidelines: [
"Use resolve_production_error to mark a production error as resolved when the underlying issue has been fixed.",
"Get the error ID from fetch_production_errors first.",
],
parameters: Type.Object({
id: Type.Number({
description:
"The error ID (integer) to resolve. Get this from fetch_production_errors.",
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
const token = resolveVar("api_token");
const fqdn = resolveVar("service_fqdn_web");
const missing: string[] = [];
if (!token) missing.push("PI_API_TOKEN");
if (!fqdn) missing.push("PI_SERVICE_FQDN_WEB");
if (missing.length > 0) {
return {
content: [
{
type: "text",
text:
`Cannot resolve production error: the following environment variables are not set:\n` +
missing.map((v) => ` - ${v}`).join("\n") +
`\n\nEnsure these are configured in your pi environment.`,
},
],
isError: true,
};
}
try {
const data = await postApi<ErrorDetailResponse>(
buildUrl(fqdn!, `/api/v1/errors/${params.id}/resolve`),
token!,
(data) => data as ErrorDetailResponse,
signal,
);
return {
content: [
{
type: "text",
text:
`Error #${params.id} resolved successfully.` +
(data.error?.status === "resolved"
? ""
: " (Warning: status field not reflected in response)"),
},
],
details: { errorId: params.id, status: data.error?.status },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("404")) {
return {
content: [
{
type: "text",
text: `Error with ID ${params.id} not found.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Failed to resolve production error: ${message}`,
},
],
isError: true,
};
}
},
});
// ── unresolve_production_error tool ─────────────────────────────────────
pi.registerTool({
name: "unresolve_production_error",
label: "Unresolve Production Error",
description:
"Reopen a previously resolved production error by ID. Use when an error " +
"reoccurs after being marked as resolved.",
promptSnippet:
"Reopen a production error when it reoccurs after being resolved",
promptGuidelines: [
"Use unresolve_production_error to reopen a production error when it reoccurs after being resolved.",
"Get the error ID from fetch_production_errors first.",
],
parameters: Type.Object({
id: Type.Number({
description:
"The error ID (integer) to unresolve. Get this from fetch_production_errors.",
}),
}),
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
const token = resolveVar("api_token");
const fqdn = resolveVar("service_fqdn_web");
const missing: string[] = [];
if (!token) missing.push("PI_API_TOKEN");
if (!fqdn) missing.push("PI_SERVICE_FQDN_WEB");
if (missing.length > 0) {
return {
content: [
{
type: "text",
text:
`Cannot unresolve production error: the following environment variables are not set:\n` +
missing.map((v) => ` - ${v}`).join("\n") +
`\n\nEnsure these are configured in your pi environment.`,
},
],
isError: true,
};
}
try {
const data = await postApi<ErrorDetailResponse>(
buildUrl(fqdn!, `/api/v1/errors/${params.id}/unresolve`),
token!,
(data) => data as ErrorDetailResponse,
signal,
);
return {
content: [
{
type: "text",
text: `Error #${params.id} reopened (unresolved) successfully.`,
},
],
details: { errorId: params.id, status: data.error?.status },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("404")) {
return {
content: [
{
type: "text",
text: `Error with ID ${params.id} not found.`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Failed to unresolve production error: ${message}`,
},
],
isError: true,
};
}
},
});
// ── /prod-errors interactive command ────────────────────────────────── // ── /prod-errors interactive command ──────────────────────────────────
pi.registerCommand("prod-errors", { pi.registerCommand("prod-errors", {
@@ -1,16 +1,25 @@
--- ---
id: ML-165 id: ML-165
title: Mute and resolve production errors from pi title: Mute and resolve production errors from pi
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-05-05 11:20' created_date: "2026-05-05 11:20"
updated_date: '2026-05-05 12:10' updated_date: "2026-05-05 12:27"
labels: [] labels: []
dependencies: [] dependencies: []
references: references:
- >- - >-
doc-12 - doc-12 -
Research-Mute-and-Resolve-Production-Errors-Implementation-Routes.md Research-Mute-and-Resolve-Production-Errors-Implementation-Routes.md
modified_files:
- lib/music_library/errors.ex
- lib/music_library_web/controllers/error_controller.ex
- lib/music_library_web/controllers/error_json.ex
- lib/music_library_web/router.ex
- test/music_library/errors_test.exs
- test/music_library_web/controllers/error_controller_test.exs
- .pi/extensions/prod-errors/index.ts
- docs/architecture.md
priority: medium priority: medium
ordinal: 10000 ordinal: 10000
--- ---
@@ -18,35 +27,40 @@ ordinal: 10000
## Description ## Description
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Extend the production error tooling in pi so that it's possible to: Extend the production error tooling in pi so that it's possible to:
1. Mute and resolve issues from the `prod-errors` TUI (user action) 1. Mute and resolve issues from the `prod-errors` TUI (user action)
2. Mute and resolve issues via a tool (pi action) 2. Mute and resolve issues via a tool (pi action)
There are no endpoints for these two actions, so we would need to extend the application's v1/api/ endpoints to support them. There are no endpoints for these two actions, so we would need to extend the application's v1/api/ endpoints to support them.
<!-- SECTION:DESCRIPTION:END --> <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 POST /api/v1/errors/:id/mute sets muted=true and returns 200 with updated error
- [ ] #2 POST /api/v1/errors/:id/unmute sets muted=false and returns 200 with updated error - [x] #1 POST /api/v1/errors/:id/mute sets muted=true and returns 200 with updated error
- [ ] #3 POST /api/v1/errors/:id/resolve sets status=:resolved and returns 200 with updated error - [x] #2 POST /api/v1/errors/:id/unmute sets muted=false and returns 200 with updated error
- [ ] #4 POST /api/v1/errors/:id/unresolve sets status=:unresolved and returns 200 with updated error - [x] #3 POST /api/v1/errors/:id/resolve sets status=:resolved and returns 200 with updated error
- [ ] #5 All four POST endpoints return 401 without Bearer token - [x] #4 POST /api/v1/errors/:id/unresolve sets status=:unresolved and returns 200 with updated error
- [ ] #6 All four POST endpoints return 404 for non-existent error ID - [x] #5 All four POST endpoints return 401 without Bearer token
- [ ] #7 All four POST endpoints return 404 for non-integer ID - [x] #6 All four POST endpoints return 404 for non-existent error ID
- [ ] #8 pi tools (mute_production_error, unmute_production_error, resolve_production_error, unresolve_production_error) work correctly - [x] #7 All four POST endpoints return 404 for non-integer ID
- [ ] #9 /prod-errors TUI: M key toggles mute state on selected error with visual feedback - [x] #8 pi tools (mute_production_error, unmute_production_error, resolve_production_error, unresolve_production_error) work correctly
- [ ] #10 /prod-errors TUI: R key toggles resolve/unresolve status on selected error with visual feedback - [x] #9 /prod-errors TUI: M key toggles mute state on selected error with visual feedback
- [ ] #11 /prod-errors TUI help text shows new M and R keybindings - [x] #10 /prod-errors TUI: R key toggles resolve/unresolve status on selected error with visual feedback
- [ ] #12 All context function tests pass - [x] #11 /prod-errors TUI help text shows new M and R keybindings
- [ ] #13 All controller tests pass - [x] #12 All context function tests pass
- [ ] #14 Documentation updated: docs/architecture.md reflects new endpoints and context description - [x] #13 All controller tests pass
- [x] #14 Documentation updated: docs/architecture.md reflects new endpoints and context description
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
<!-- SECTION:PLAN:BEGIN --> <!-- SECTION:PLAN:BEGIN -->
## Implementation Plan: Route A — Four dedicated POST endpoints ## Implementation Plan: Route A — Four dedicated POST endpoints
### Overview ### Overview
@@ -158,6 +172,7 @@ post "/errors/:id/unresolve", ErrorController, :unresolve
**File:** `test/music_library/errors_test.exs` **File:** `test/music_library/errors_test.exs`
Add a `describe "mute_error/1, unmute_error/1, resolve_error/1, unresolve_error/1"` block with tests for: Add a `describe "mute_error/1, unmute_error/1, resolve_error/1, unresolve_error/1"` block with tests for:
- `mute_error/1` sets `muted` to `true` - `mute_error/1` sets `muted` to `true`
- `unmute_error/1` sets `muted` to `false` - `unmute_error/1` sets `muted` to `false`
- `resolve_error/1` sets `status` to `:resolved` - `resolve_error/1` sets `status` to `:resolved`
@@ -168,6 +183,7 @@ Add a `describe "mute_error/1, unmute_error/1, resolve_error/1, unresolve_error/
**File:** `test/music_library_web/controllers/error_controller_test.exs` **File:** `test/music_library_web/controllers/error_controller_test.exs`
Add a `describe "POST /api/v1/errors/:id/mute|unmute|resolve|unresolve"` block with tests for: Add a `describe "POST /api/v1/errors/:id/mute|unmute|resolve|unresolve"` block with tests for:
- Each endpoint returns 401 without Bearer token - Each endpoint returns 401 without Bearer token
- Each endpoint returns 200 with updated error on success - Each endpoint returns 200 with updated error on success
- Each endpoint returns 404 for non-existent ID - Each endpoint returns 404 for non-existent ID
@@ -187,6 +203,7 @@ Register four new tools after the existing `fetch_production_error` tool:
4. **`unresolve_production_error`** — POSTs to `/api/v1/errors/:id/unresolve` 4. **`unresolve_production_error`** — POSTs to `/api/v1/errors/:id/unresolve`
Each tool: Each tool:
- Takes a single `id` (number) parameter - Takes a single `id` (number) parameter
- Validates env vars (`PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB`) like existing tools - Validates env vars (`PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB`) like existing tools
- Makes a POST request with Bearer auth - Makes a POST request with Bearer auth
@@ -196,6 +213,7 @@ Each tool:
Add a shared helper `postApi<T>` for POST requests (similar to the existing `fetchApi<T>` for GET, adding `method: "POST"`). Add a shared helper `postApi<T>` for POST requests (similar to the existing `fetchApi<T>` for GET, adding `method: "POST"`).
Add prompt guidelines: Add prompt guidelines:
- `mute_production_error`: "Use mute_production_error to silence notifications for a noisy or already-addressed production error." - `mute_production_error`: "Use mute_production_error to silence notifications for a noisy or already-addressed production error."
- `unmute_production_error`: "Use unmute_production_error to re-enable notifications for a previously muted error." - `unmute_production_error`: "Use unmute_production_error to re-enable notifications for a previously muted error."
- `resolve_production_error`: "Use resolve_production_error to mark a production error as resolved when the underlying issue has been fixed." - `resolve_production_error`: "Use resolve_production_error to mark a production error as resolved when the underlying issue has been fixed."
@@ -224,6 +242,7 @@ Add to the `ErrorBrowser` class:
3. **Update help text** in `renderList` and `renderDetail` to show the new keys. 3. **Update help text** in `renderList` and `renderDetail` to show the new keys.
Keybinding logic (list mode): Keybinding logic (list mode):
``` ```
M → if error.muted → POST /unmute; else → POST /mute M → if error.muted → POST /unmute; else → POST /mute
R → if error.status === "resolved" → POST /unresolve; else → POST /resolve R → if error.status === "resolved" → POST /unresolve; else → POST /resolve
@@ -232,6 +251,7 @@ R → if error.status === "resolved" → POST /unresolve; else → POST /resolve
After a successful API call, update the local `ErrorListItem` in `this.errors` and call `this.invalidate()`. After a successful API call, update the local `ErrorListItem` in `this.errors` and call `this.invalidate()`.
**Visual feedback on success:** **Visual feedback on success:**
- The error's list entry re-renders immediately: the `[MUTED]` label appears/disappears, and the status badge (`[RESOLVED]` / `[UNRESOLVED]`) updates. - The error's list entry re-renders immediately: the `[MUTED]` label appears/disappears, and the status badge (`[RESOLVED]` / `[UNRESOLVED]`) updates.
- In detail mode, the header line (`Status: … | Muted: …`) updates on next render. - In detail mode, the header line (`Status: … | Muted: …`) updates on next render.
- On failure: a toast notification via `this.notify()` displays the error message. - On failure: a toast notification via `this.notify()` displays the error message.
@@ -245,6 +265,7 @@ After a successful API call, update the local `ErrorListItem` in `this.errors` a
Update the `@moduledoc` to reflect that the module now handles both queries and mutations. The current text describes it as read-only — add a line noting that it also provides `mute_error/1`, `unmute_error/1`, `resolve_error/1`, and `unresolve_error/1` for mutating error state. Update the `@moduledoc` to reflect that the module now handles both queries and mutations. The current text describes it as read-only — add a line noting that it also provides `mute_error/1`, `unmute_error/1`, `resolve_error/1`, and `unresolve_error/1` for mutating error state.
**File:** `docs/architecture.md` **File:** `docs/architecture.md`
- Under the Routes section (if it enumerates API routes), add the four new POST endpoints: `/api/v1/errors/:id/mute`, `/api/v1/errors/:id/unmute`, `/api/v1/errors/:id/resolve`, `/api/v1/errors/:id/unresolve`. - Under the Routes section (if it enumerates API routes), add the four new POST endpoints: `/api/v1/errors/:id/mute`, `/api/v1/errors/:id/unmute`, `/api/v1/errors/:id/resolve`, `/api/v1/errors/:id/unresolve`.
- Under Contexts → `Errors`, update the description from "Read-only queries" to "Queries and mutations for production error data". Note that muting an error suppresses future email notifications via `ErrorTracker.ErrorNotifier` (which checks the `muted` field before dispatching). - Under Contexts → `Errors`, update the description from "Read-only queries" to "Queries and mutations for production error data". Note that muting an error suppresses future email notifications via `ErrorTracker.ErrorNotifier` (which checks the `muted` field before dispatching).
- Under the Controller table, update the `ErrorController` row to include the four new action routes. - Under the Controller table, update the `ErrorController` row to include the four new action routes.
@@ -253,16 +274,16 @@ Update the `@moduledoc` to reflect that the module now handles both queries and
## Architecture Impact Summary ## Architecture Impact Summary
| Component | Change | | Component | Change |
|---|---| | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `MusicLibrary.Errors` | +4 public functions, +1 private helper, updated moduledoc | | `MusicLibrary.Errors` | +4 public functions, +1 private helper, updated moduledoc |
| `MusicLibraryWeb.ErrorController` | +4 actions, +1 private helper | | `MusicLibraryWeb.ErrorController` | +4 actions, +1 private helper |
| `MusicLibraryWeb.ErrorJSON` | +1 render function | | `MusicLibraryWeb.ErrorJSON` | +1 render function |
| `MusicLibraryWeb.Router` | +4 POST routes | | `MusicLibraryWeb.Router` | +4 POST routes |
| `.pi/extensions/prod-errors/index.ts` | +4 tools, +2 TUI methods, +2 keybindings, updated help text | | `.pi/extensions/prod-errors/index.ts` | +4 tools, +2 TUI methods, +2 keybindings, updated help text |
| `test/music_library/errors_test.exs` | +~8 tests (including idempotency for all four actions) | | `test/music_library/errors_test.exs` | +~8 tests (including idempotency for all four actions) |
| `test/music_library_web/controllers/error_controller_test.exs` | +~12 tests | | `test/music_library_web/controllers/error_controller_test.exs` | +~12 tests |
| `docs/architecture.md` | Update errors context description, add routes, update controller table | | `docs/architecture.md` | Update errors context description, add routes, update controller table |
**No changes:** supervision tree, PubSub, schemas, migrations, Oban workers, LiveViews, external APIs, production infrastructure. **No changes:** supervision tree, PubSub, schemas, migrations, Oban workers, LiveViews, external APIs, production infrastructure.
@@ -282,4 +303,44 @@ Zero incremental cost. No external API calls.
## Production Infrastructure ## Production Infrastructure
No changes needed (no new env vars, no DNS, no firewall, no special migration handling). No changes needed (no new env vars, no DNS, no firewall, no special migration handling).
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## Summary
Added mute/unmute/resolve/unresolve capability for production errors across the full stack:
### Backend (Elixir/Phoenix)
- **`MusicLibrary.Errors`**: Added 4 public functions (`mute_error/1`, `unmute_error/1`, `resolve_error/1`, `unresolve_error/1`) that delegate to `ErrorTracker`'s built-in mutation functions. `resolve/1` and `unresolve/1` handle the idempotent case (already-resolved/already-unresolved) gracefully.
- **`MusicLibraryWeb.ErrorController`**: Added 4 POST actions (`mute/2`, `unmute/2`, `resolve/2`, `unresolve/2`) with a shared `perform_action/3` helper that parses integer IDs, delegates to context, and returns proper JSON responses (200, 404, 422).
- **`MusicLibraryWeb.ErrorJSON`**: Added `update/1` render function for the `:update` template atom.
- **`MusicLibraryWeb.Router`**: Added 4 POST routes under the authenticated `/api/v1` scope.
### Tests
- **Context tests** (9 new): Test all four functions on success, not_found, and idempotency for each.
- **Controller tests** (6 auth + 6 functional): Test 401 without token, 200 with updated state, 404 for non-existent/non-integer IDs.
### Pi Extension (TypeScript)
- **`postApi<T>` helper**: Shared POST request helper with Bearer auth and validation.
- **4 new tools**: `mute_production_error`, `unmute_production_error`, `resolve_production_error`, `unresolve_production_error` — each takes an error ID, POSTs to the corresponding endpoint, and returns success/error.
- **TUI keybindings**: `M` (Shift+M) toggles mute, `R` (Shift+R) toggles resolve/unresolve on the selected error in both list and detail modes. Local state updates immediately on success with toast notifications.
- **Help text**: Updated in both list and detail modes to show the new keys.
### Documentation
- `docs/architecture.md`: Updated Errors context description from "Read-only" to "Queries and mutations", added new POST routes to ErrorController table.
- `lib/music_library/errors.ex`: Updated `@moduledoc` to reflect mutation capabilities.
### Design decisions
- Used `ErrorTracker.mute/1`, `unmute/1`, `resolve/1`, `unresolve/1` (which emit telemetry events) rather than raw `Ecto.Changeset.change/2`
- `resolve_error/1` and `unresolve_error/1` handle the already-resolved/unresolved case explicitly (ErrorTracker's functions pattern-match on current state and would crash otherwise)
- POST endpoints return the updated error as JSON (consistent with GET responses), using the same `error/1` render helper
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,9 +1,10 @@
--- ---
id: doc-12 id: doc-12
title: 'Research: Mute and Resolve Production Errors Implementation Routes' title: "Research: Mute and Resolve Production Errors Implementation Routes"
type: specification type: specification
created_date: '2026-05-05 11:24' created_date: "2026-05-05 11:24"
--- ---
# Research: Mute and Resolve Production Errors Implementation Routes # Research: Mute and Resolve Production Errors Implementation Routes
**Task:** ML-165 — Mute and resolve production errors from pi **Task:** ML-165 — Mute and resolve production errors from pi
@@ -13,21 +14,21 @@ created_date: '2026-05-05 11:24'
### Backend (Elixir/Phoenix) ### Backend (Elixir/Phoenix)
| Component | File | Status | | Component | File | Status |
|---|---|---| | ---------------- | ------------------------------------------------------- | ----------------------------------------------------------------- |
| Error schema | `deps/error_tracker/lib/error_tracker/schemas/error.ex` | Has `status` (:resolved/:unresolved) and `muted` (boolean) fields | | Error schema | `deps/error_tracker/lib/error_tracker/schemas/error.ex` | Has `status` (:resolved/:unresolved) and `muted` (boolean) fields |
| Error context | `lib/music_library/errors.ex` | Read-only: `list_errors/1`, `get_error/1` — no update functions | | Error context | `lib/music_library/errors.ex` | Read-only: `list_errors/1`, `get_error/1` — no update functions |
| Error controller | `lib/music_library_web/controllers/error_controller.ex` | `GET /api/v1/errors` (index), `GET /api/v1/errors/:id` (show) | | Error controller | `lib/music_library_web/controllers/error_controller.ex` | `GET /api/v1/errors` (index), `GET /api/v1/errors/:id` (show) |
| Error JSON views | `lib/music_library_web/controllers/error_json.ex` | Renders error/occurrence data | | Error JSON views | `lib/music_library_web/controllers/error_json.ex` | Renders error/occurrence data |
| Router | `lib/music_library_web/router.ex` | `/api/v1/errors` routes in API scope (Bearer token auth) | | Router | `lib/music_library_web/router.ex` | `/api/v1/errors` routes in API scope (Bearer token auth) |
| Auth | `lib/music_library_web/auth.ex` | `require_api_token/2` plug — Bearer token comparison | | Auth | `lib/music_library_web/auth.ex` | `require_api_token/2` plug — Bearer token comparison |
### Pi Extension (TypeScript) ### Pi Extension (TypeScript)
| Component | File | Status | | Component | File | Status |
|---|---|---| | --------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------- |
| prod-errors extension | `.pi/extensions/prod-errors/index.ts` | Tools: `fetch_production_errors`, `fetch_production_error`; Command: `/prod-errors` (TUI browser) | | prod-errors extension | `.pi/extensions/prod-errors/index.ts` | Tools: `fetch_production_errors`, `fetch_production_error`; Command: `/prod-errors` (TUI browser) |
| TUI ErrorBrowser | Same file, `ErrorBrowser` class | List view (no mute/resolve actions), Detail view (no mute/resolve actions) | | TUI ErrorBrowser | Same file, `ErrorBrowser` class | List view (no mute/resolve actions), Detail view (no mute/resolve actions) |
### Gap Analysis ### Gap Analysis
@@ -48,12 +49,14 @@ POST /api/v1/errors/:id/resolve → sets status = :resolved
``` ```
**Elixir changes:** **Elixir changes:**
- `MusicLibrary.Errors`: Add `mute_error/1`, `resolve_error/1` (or a shared private helper) - `MusicLibrary.Errors`: Add `mute_error/1`, `resolve_error/1` (or a shared private helper)
- `MusicLibraryWeb.ErrorController`: Add `mute/2` and `resolve/2` actions - `MusicLibraryWeb.ErrorController`: Add `mute/2` and `resolve/2` actions
- `MusicLibraryWeb.ErrorJSON`: Add `mute/1`, `resolve/1` render functions - `MusicLibraryWeb.ErrorJSON`: Add `mute/1`, `resolve/1` render functions
- `MusicLibraryWeb.Router`: Add two POST routes in the API scope - `MusicLibraryWeb.Router`: Add two POST routes in the API scope
**Pi extension changes:** **Pi extension changes:**
- Register `mute_production_error` tool → POSTs to `/api/v1/errors/:id/mute` - Register `mute_production_error` tool → POSTs to `/api/v1/errors/:id/mute`
- Register `resolve_production_error` tool → POSTs to `/api/v1/errors/:id/resolve` - Register `resolve_production_error` tool → POSTs to `/api/v1/errors/:id/resolve`
- Add keybindings to `/prod-errors` TUI: - Add keybindings to `/prod-errors` TUI:
@@ -62,6 +65,7 @@ POST /api/v1/errors/:id/resolve → sets status = :resolved
- Or use single keys: `x` for mute, `d` for resolve (avoiding conflict with existing `m`/`r` filter toggles) - Or use single keys: `x` for mute, `d` for resolve (avoiding conflict with existing `m`/`r` filter toggles)
**Pros:** **Pros:**
- Simplest approach — each endpoint does exactly one thing - Simplest approach — each endpoint does exactly one thing
- RESTful resource/action pattern used elsewhere in the app (e.g., `/collection/latest`) - RESTful resource/action pattern used elsewhere in the app (e.g., `/collection/latest`)
- Easy to extend with unmute/unresolve later as additional endpoints - Easy to extend with unmute/unresolve later as additional endpoints
@@ -69,6 +73,7 @@ POST /api/v1/errors/:id/resolve → sets status = :resolved
- Easy to test independently - Easy to test independently
**Cons:** **Cons:**
- Two new routes instead of one - Two new routes instead of one
- If many more error actions are added, route count could grow - If many more error actions are added, route count could grow
@@ -81,11 +86,13 @@ PATCH /api/v1/errors/:id body: {"muted": true} or {"status": "resolved"}
``` ```
**Pros:** **Pros:**
- Single route for all error mutations - Single route for all error mutations
- Follows REST convention for partial updates - Follows REST convention for partial updates
- Easily extensible to other fields - Easily extensible to other fields
**Cons:** **Cons:**
- Parameter validation is more complex (must reject unknown fields, validate combinations) - Parameter validation is more complex (must reject unknown fields, validate combinations)
- Tool semantics are less clear — the pi tool would need to construct the PATCH body - Tool semantics are less clear — the pi tool would need to construct the PATCH body
- PATCH semantics imply partial update; if both fields are sent, behavior must be defined - PATCH semantics imply partial update; if both fields are sent, behavior must be defined
@@ -99,12 +106,14 @@ POST /api/v1/errors/:id/actions body: {"action": "mute"} or {"action": "resolv
``` ```
**Pros:** **Pros:**
- Single route for all actions - Single route for all actions
- Easy to add new action types - Easy to add new action types
- Clean separation between action specification and execution - Clean separation between action specification and execution
- Tool semantics map cleanly (tool name = action) - Tool semantics map cleanly (tool name = action)
**Cons:** **Cons:**
- Less standard REST pattern - Less standard REST pattern
- Requires action validation/dispatch layer - Requires action validation/dispatch layer
- No existing precedent in the codebase - No existing precedent in the codebase
@@ -125,16 +134,16 @@ Route A is the best fit for this project because:
## Architecture Impact ## Architecture Impact
| Component | Impact | | Component | Impact |
|---|---| | -------------------------------------------------------------- | ------------------------------------------------------------------ |
| `MusicLibrary.Errors` | Add `mute_error/1`, `resolve_error/1` context functions | | `MusicLibrary.Errors` | Add `mute_error/1`, `resolve_error/1` context functions |
| `MusicLibraryWeb.ErrorController` | Add `mute/2`, `resolve/2` actions | | `MusicLibraryWeb.ErrorController` | Add `mute/2`, `resolve/2` actions |
| `MusicLibraryWeb.ErrorJSON` | Add render functions for success/error responses | | `MusicLibraryWeb.ErrorJSON` | Add render functions for success/error responses |
| `MusicLibraryWeb.Router` | Add two POST routes | | `MusicLibraryWeb.Router` | Add two POST routes |
| `.pi/extensions/prod-errors/index.ts` | Add two tools, add TUI keybindings | | `.pi/extensions/prod-errors/index.ts` | Add two tools, add TUI keybindings |
| `test/music_library_web/controllers/error_controller_test.exs` | Add tests for new endpoints | | `test/music_library_web/controllers/error_controller_test.exs` | Add tests for new endpoints |
| `test/music_library/errors_test.exs` | Add tests for new context functions (if separate test file exists) | | `test/music_library/errors_test.exs` | Add tests for new context functions (if separate test file exists) |
| `docs/architecture.md` | Update Routes section if it lists API routes | | `docs/architecture.md` | Update Routes section if it lists API routes |
No changes needed to: supervision tree, PubSub, schemas, migrations, Oban workers, LiveViews, external APIs. No changes needed to: supervision tree, PubSub, schemas, migrations, Oban workers, LiveViews, external APIs.
@@ -156,10 +165,10 @@ No production infrastructure changes needed. The new endpoints are served by the
## Documentation Updates ## Documentation Updates
| File | Change | | File | Change |
|---|---| | ------------------------------------- | -------------------------------------------------------------- |
| `docs/architecture.md` | Update Routes section if API routes are enumerated there | | `docs/architecture.md` | Update Routes section if API routes are enumerated there |
| README (if applicable) | N/A — API is internal tooling | | README (if applicable) | N/A — API is internal tooling |
| `.pi/extensions/prod-errors/index.ts` | Self-documenting through tool descriptions and prompt snippets | | `.pi/extensions/prod-errors/index.ts` | Self-documenting through tool descriptions and prompt snippets |
## Testing ## Testing
+28 -28
View File
@@ -93,25 +93,25 @@ Last.fm schemas (separate, not Ecto-persisted to main DB):
## Contexts (lib/music_library/) ## Contexts (lib/music_library/)
| Context | Schemas | Responsibility | | Context | Schemas | Responsibility |
| ---------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Records` | Record, RecordEmbedding, SearchIndex | CRUD, search, import from MusicBrainz, cover/genre/color management, PubSub notifications | | `Records` | Record, RecordEmbedding, SearchIndex | CRUD, search, import from MusicBrainz, cover/genre/color management, PubSub notifications |
| `Collection` | Record (via SearchIndex) | Querying collected records (purchased_at != nil), stats, collected artist IDs, collection summary for AI chat | | `Collection` | Record (via SearchIndex) | Querying collected records (purchased_at != nil), stats, collected artist IDs, collection summary for AI chat |
| `Wishlist` | Record (via SearchIndex) | Querying wishlisted records (purchased_at is nil) | | `Wishlist` | Record (via SearchIndex) | Querying wishlisted records (purchased_at is nil) |
| `Artists` | ArtistInfo, ArtistRecord | Artist metadata from MusicBrainz/Discogs/Wikipedia/Last.fm, images, search | | `Artists` | ArtistInfo, ArtistRecord | Artist metadata from MusicBrainz/Discogs/Wikipedia/Last.fm, images, search |
| `Assets` | Asset | Binary asset storage (covers, artist images), cache tracking, pruning unreferenced assets | | `Assets` | Asset | Binary asset storage (covers, artist images), cache tracking, pruning unreferenced assets |
| `Notes` | Note | Free-text notes for records and artists | | `Notes` | Note | Free-text notes for records and artists |
| `Chats` | Chat, Message, StreamProvider, RecordChat, ArtistChat, CollectionChat | Persistent AI chat conversations for records, artists, and the collection, streaming AI chat behaviour and entity-specific implementations | | `Chats` | Chat, Message, StreamProvider, RecordChat, ArtistChat, CollectionChat | Persistent AI chat conversations for records, artists, and the collection, streaming AI chat behaviour and entity-specific implementations |
| `RecordSets` | RecordSet, RecordSetItem | User-curated record groupings with ordering | | `RecordSets` | RecordSet, RecordSetItem | User-curated record groupings with ordering |
| `ScrobbleRules` | ScrobbleRule | Rules to remap Last.fm scrobble data to correct MusicBrainz IDs; searchable by match_value/target/description, orderable by alphabetical or inserted_at | | `ScrobbleRules` | ScrobbleRule | Rules to remap Last.fm scrobble data to correct MusicBrainz IDs; searchable by match_value/target/description, orderable by alphabetical or inserted_at |
| `ScrobbleActivity` | — | Scrobbling releases/media/tracks to Last.fm | | `ScrobbleActivity` | — | Scrobbling releases/media/tracks to Last.fm |
| `ListeningStats` | (LastFm.Track, RecordRelease, ArtistRecord, ArtistInfo) | Scrobble persistence, refresh scheduling, listening analytics, track CRUD, search, listing: scrobble counts, artist play counts (from DB), recent activity, top albums/artists by period | | `ListeningStats` | (LastFm.Track, RecordRelease, ArtistRecord, ArtistInfo) | Scrobble persistence, refresh scheduling, listening analytics, track CRUD, search, listing: scrobble counts, artist play counts (from DB), recent activity, top albums/artists by period |
| `OnlineStoreTemplates` | OnlineStoreTemplate | URL templates for buying records online; searchable by name/description | | `OnlineStoreTemplates` | OnlineStoreTemplate | URL templates for buying records online; searchable by name/description |
| `Errors` | ErrorTracker.Error, ErrorTracker.Occurrence | Read-only queries for production error data tracked by ErrorTracker; filtered listing with pagination, single error with preloaded occurrences and computed counts | | `Errors` | ErrorTracker.Error, ErrorTracker.Occurrence | Queries and mutations for production error data tracked by ErrorTracker; filtered listing with pagination, single error with preloaded occurrences and computed counts, plus `mute_error/1`, `unmute_error/1`, `resolve_error/1`, `unresolve_error/1` for mutating error state. Muting an error suppresses future email notifications via `ErrorTracker.ErrorNotifier`. |
| `Search` | (cross-context) | Universal search dispatcher across collection, wishlist, artists, record sets (delegates to domain contexts) | | `Search` | (cross-context) | Universal search dispatcher across collection, wishlist, artists, record sets (delegates to domain contexts) |
| `Secrets` | Secret | Encrypted key-value storage (CRUD + delete) | | `Secrets` | Secret | Encrypted key-value storage (CRUD + delete) |
| `BarcodeScan` | (Result struct) | Barcode → MusicBrainz lookup workflow, async batch import for multiple new records | | `BarcodeScan` | (Result struct) | Barcode → MusicBrainz lookup workflow, async batch import for multiple new records |
| `Maintenance` | (Oban.Job, LastFm.Track) | Background job monitoring, database vacuum/optimize, scrobble data quality diagnostics | | `Maintenance` | (Oban.Job, LastFm.Track) | Background job monitoring, database vacuum/optimize, scrobble data quality diagnostics |
--- ---
@@ -338,15 +338,15 @@ All authenticated routes live inside a single `live_session` with three `on_moun
### Controllers ### Controllers
| Controller | Routes | Purpose | | Controller | Routes | Purpose |
| ---------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `SessionController` | `/login`, `/sessions/create` | Login/logout | | `SessionController` | `/login`, `/sessions/create` | Login/logout |
| `HealthController` | `/health` | Health check | | `HealthController` | `/health` | Health check |
| `LastFmController` | `/auth/last_fm/callback` | Last.fm OAuth | | `LastFmController` | `/auth/last_fm/callback` | Last.fm OAuth |
| `ArchiveController` | `/backup`, `/api/v1/backup` | Database backup download (API route requires token) | | `ArchiveController` | `/backup`, `/api/v1/backup` | Database backup download (API route requires token) |
| `AssetController` | `/assets/:transform_payload`, `/public/assets/:transform_payload`, `/api/v1/assets/:transform_payload` | Serve images with transforms (public route for emails, API route requires token) | | `AssetController` | `/assets/:transform_payload`, `/public/assets/:transform_payload`, `/api/v1/assets/:transform_payload` | Serve images with transforms (public route for emails, API route requires token) |
| `CollectionController` | `/api/v1/collection/*` | JSON API for collection queries | | `CollectionController` | `/api/v1/collection/*` | JSON API for collection queries |
| `ErrorController` | `/api/v1/errors`, `/api/v1/errors/:id` | JSON API for production error queries (requires Bearer token) | | `ErrorController` | `/api/v1/errors`, `/api/v1/errors/:id`, `/api/v1/errors/:id/mute`, `/api/v1/errors/:id/unmute`, `/api/v1/errors/:id/resolve`, `/api/v1/errors/:id/unresolve` | JSON API for production error queries and mutations (requires Bearer token); POST endpoints for mute/unmute/resolve/unresolve |
--- ---
+45 -2
View File
@@ -1,11 +1,17 @@
defmodule MusicLibrary.Errors do defmodule MusicLibrary.Errors do
@moduledoc """ @moduledoc """
Queries for production errors tracked via ErrorTracker. Queries and mutations for production errors tracked via ErrorTracker.
Reads from the error_tracker_errors and error_tracker_occurrences tables Reads from the error_tracker_errors and error_tracker_occurrences tables
(owned by the `ErrorTracker` library) through `MusicLibrary.Repo`. The (owned by the `ErrorTracker` library) through `MusicLibrary.Repo`. The
tables are created by ErrorTracker's own migrations and do not require tables are created by ErrorTracker's own migrations and do not require
any additional schema work. any additional schema work.
Provides `mute_error/1`, `unmute_error/1`, `resolve_error/1`, and
`unresolve_error/1` for mutating error state (muted flag and status).
Muting an error suppresses future email notifications via
`ErrorTracker.ErrorNotifier`, which checks the `muted` field before
dispatching.
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
@@ -79,7 +85,44 @@ defmodule MusicLibrary.Errors do
end end
end end
# -- private helpers -- @spec mute_error(pos_integer()) :: {:ok, Error.t()} | {:error, :not_found | Ecto.Changeset.t()}
def mute_error(id) do
case Repo.get(Error, id) do
nil -> {:error, :not_found}
%{muted: true} = error -> {:ok, error}
error -> ErrorTracker.mute(error)
end
end
@spec unmute_error(pos_integer()) ::
{:ok, Error.t()} | {:error, :not_found | Ecto.Changeset.t()}
def unmute_error(id) do
case Repo.get(Error, id) do
nil -> {:error, :not_found}
%{muted: false} = error -> {:ok, error}
error -> ErrorTracker.unmute(error)
end
end
@spec resolve_error(pos_integer()) ::
{:ok, Error.t()} | {:error, :not_found | Ecto.Changeset.t()}
def resolve_error(id) do
case Repo.get(Error, id) do
nil -> {:error, :not_found}
%{status: :resolved} = error -> {:ok, error}
error -> ErrorTracker.resolve(error)
end
end
@spec unresolve_error(pos_integer()) ::
{:ok, Error.t()} | {:error, :not_found | Ecto.Changeset.t()}
def unresolve_error(id) do
case Repo.get(Error, id) do
nil -> {:error, :not_found}
%{status: :unresolved} = error -> {:ok, error}
error -> ErrorTracker.unresolve(error)
end
end
defp base_query(filters) do defp base_query(filters) do
Error Error
@@ -43,8 +43,32 @@ defmodule MusicLibraryWeb.ErrorController do
end end
end end
def mute(conn, %{"id" => id}), do: perform_action(conn, id, &Errors.mute_error/1)
def unmute(conn, %{"id" => id}), do: perform_action(conn, id, &Errors.unmute_error/1)
def resolve(conn, %{"id" => id}), do: perform_action(conn, id, &Errors.resolve_error/1)
def unresolve(conn, %{"id" => id}), do: perform_action(conn, id, &Errors.unresolve_error/1)
# -- private helpers -- # -- private helpers --
defp perform_action(conn, id, action_fn) do
case Integer.parse(id) do
{id_int, ""} when id_int > 0 ->
case action_fn.(id_int) do
{:ok, error} ->
render(conn, :update, error: error)
{:error, :not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Not Found"})
{:error, _changeset} ->
conn |> put_status(:unprocessable_entity) |> json(%{error: "Update failed"})
end
_ ->
conn |> put_status(:not_found) |> json(%{error: "Not Found"})
end
end
defp parse_status(nil), do: nil defp parse_status(nil), do: nil
defp parse_status("resolved"), do: :resolved defp parse_status("resolved"), do: :resolved
defp parse_status("unresolved"), do: :unresolved defp parse_status("unresolved"), do: :unresolved
@@ -29,6 +29,10 @@ defmodule MusicLibraryWeb.ErrorJSON do
} }
end end
def update(%{error: error}) do
%{error: error(error)}
end
defp error(e) do defp error(e) do
%{ %{
id: e.id, id: e.id,
+4
View File
@@ -132,6 +132,10 @@ defmodule MusicLibraryWeb.Router do
get "/collection", CollectionController, :index get "/collection", CollectionController, :index
get "/errors", ErrorController, :index get "/errors", ErrorController, :index
get "/errors/:id", ErrorController, :show get "/errors/:id", ErrorController, :show
post "/errors/:id/mute", ErrorController, :mute
post "/errors/:id/unmute", ErrorController, :unmute
post "/errors/:id/resolve", ErrorController, :resolve
post "/errors/:id/unresolve", ErrorController, :unresolve
get "/assets/:transform_payload", AssetController, :show get "/assets/:transform_payload", AssetController, :show
get "/backup", ArchiveController, :backup get "/backup", ArchiveController, :backup
end end
+57
View File
@@ -160,6 +160,63 @@ defmodule MusicLibrary.ErrorsTest do
end end
end end
describe "mute_error/1, unmute_error/1, resolve_error/1, unresolve_error/1" do
test "mute_error/1 sets muted to true" do
error = unique_error("mute", %{muted: false})
assert {:ok, updated} = Errors.mute_error(error.id)
assert updated.muted == true
end
test "unmute_error/1 sets muted to false" do
error = unique_error("unmute", %{muted: true})
assert {:ok, updated} = Errors.unmute_error(error.id)
assert updated.muted == false
end
test "resolve_error/1 sets status to :resolved" do
error = unique_error("res", %{status: :unresolved})
assert {:ok, updated} = Errors.resolve_error(error.id)
assert updated.status == :resolved
end
test "unresolve_error/1 sets status to :unresolved" do
error = unique_error("unres", %{status: :resolved})
assert {:ok, updated} = Errors.unresolve_error(error.id)
assert updated.status == :unresolved
end
test "returns {:error, :not_found} for non-existent id" do
assert Errors.mute_error(99_999) == {:error, :not_found}
assert Errors.unmute_error(99_999) == {:error, :not_found}
assert Errors.resolve_error(99_999) == {:error, :not_found}
assert Errors.unresolve_error(99_999) == {:error, :not_found}
end
test "mute_error/1 on already-muted error succeeds (idempotent)" do
error = unique_error("mute_idem", %{muted: true})
assert {:ok, updated} = Errors.mute_error(error.id)
assert updated.muted == true
end
test "unmute_error/1 on already-unmuted error succeeds (idempotent)" do
error = unique_error("unmute_idem", %{muted: false})
assert {:ok, updated} = Errors.unmute_error(error.id)
assert updated.muted == false
end
test "resolve_error/1 on already-resolved error succeeds (idempotent)" do
error = unique_error("res_idem", %{status: :resolved})
assert {:ok, updated} = Errors.resolve_error(error.id)
assert updated.status == :resolved
end
test "unresolve_error/1 on already-unresolved error succeeds (idempotent)" do
error = unique_error("unres_idem", %{status: :unresolved})
assert {:ok, updated} = Errors.unresolve_error(error.id)
assert updated.status == :unresolved
end
end
describe "escape_like_wildcards/1" do describe "escape_like_wildcards/1" do
test "escapes % and _ characters" do test "escapes % and _ characters" do
assert Errors.escape_like_wildcards("100%") == "100\\%" assert Errors.escape_like_wildcards("100%") == "100\\%"
@@ -16,6 +16,22 @@ defmodule MusicLibraryWeb.ErrorControllerTest do
test "GET /api/v1/errors/:id requires a bearer token", %{conn: conn} do test "GET /api/v1/errors/:id requires a bearer token", %{conn: conn} do
assert get(conn, ~p"/api/v1/errors/1").status == 401 assert get(conn, ~p"/api/v1/errors/1").status == 401
end end
test "POST /api/v1/errors/:id/mute requires a bearer token", %{conn: conn} do
assert post(conn, ~p"/api/v1/errors/1/mute").status == 401
end
test "POST /api/v1/errors/:id/unmute requires a bearer token", %{conn: conn} do
assert post(conn, ~p"/api/v1/errors/1/unmute").status == 401
end
test "POST /api/v1/errors/:id/resolve requires a bearer token", %{conn: conn} do
assert post(conn, ~p"/api/v1/errors/1/resolve").status == 401
end
test "POST /api/v1/errors/:id/unresolve requires a bearer token", %{conn: conn} do
assert post(conn, ~p"/api/v1/errors/1/unresolve").status == 401
end
end end
describe "GET /api/v1/errors" do describe "GET /api/v1/errors" do
@@ -255,4 +271,243 @@ defmodule MusicLibraryWeb.ErrorControllerTest do
assert json_response(conn, 404) == %{"error" => "Not Found"} assert json_response(conn, 404) == %{"error" => "Not Found"}
end end
end end
describe "POST /api/v1/errors/:id/mute|unmute|resolve|unresolve" do
setup do
error = error_fixture(%{reason: "Test error", muted: false, status: :unresolved})
%{error: error}
end
test "POST mute sets muted to true", %{conn: conn, error: error} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> post(~p"/api/v1/errors/#{error.id}/mute")
assert %{"error" => returned} = json_response(conn, 200)
assert returned["muted"] == true
assert returned["id"] == error.id
end
test "POST unmute sets muted to false", %{conn: conn} do
error =
error_fixture(%{
reason: "Muted error",
muted: true,
status: :unresolved,
source_line: "lib/muted.ex:1",
source_function: "MutedModule.muted/0",
fingerprint: error_fingerprint(:runtime_error, "lib/muted.ex:1", "MutedModule.muted/0")
})
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> post(~p"/api/v1/errors/#{error.id}/unmute")
assert %{"error" => returned} = json_response(conn, 200)
assert returned["muted"] == false
end
test "POST resolve sets status to resolved", %{conn: conn, error: error} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> post(~p"/api/v1/errors/#{error.id}/resolve")
assert %{"error" => returned} = json_response(conn, 200)
assert returned["status"] == "resolved"
end
test "POST unresolve sets status to unresolved", %{conn: conn} do
error =
error_fixture(%{
reason: "Resolved error",
muted: false,
status: :resolved,
source_line: "lib/resolved.ex:1",
source_function: "ResolvedModule.resolved/0",
fingerprint:
error_fingerprint(:runtime_error, "lib/resolved.ex:1", "ResolvedModule.resolved/0")
})
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> post(~p"/api/v1/errors/#{error.id}/unresolve")
assert %{"error" => returned} = json_response(conn, 200)
assert returned["status"] == "unresolved"
end
test "all four endpoints return 404 for non-existent ID", %{conn: conn} do
conn = put_req_header(conn, "authorization", "Bearer #{api_token()}")
assert post(conn, ~p"/api/v1/errors/99999/mute") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/99999/unmute") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/99999/resolve") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/99999/unresolve") |> json_response(404) == %{
"error" => "Not Found"
}
end
test "all four endpoints return 404 for non-integer ID", %{conn: conn} do
conn = put_req_header(conn, "authorization", "Bearer #{api_token()}")
assert post(conn, ~p"/api/v1/errors/not-an-id/mute") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/not-an-id/unmute") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/not-an-id/resolve") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/not-an-id/unresolve") |> json_response(404) == %{
"error" => "Not Found"
}
end
test "all four endpoints return 404 for zero ID", %{conn: conn} do
conn = put_req_header(conn, "authorization", "Bearer #{api_token()}")
assert post(conn, ~p"/api/v1/errors/0/mute") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/0/unmute") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/0/resolve") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/0/unresolve") |> json_response(404) == %{
"error" => "Not Found"
}
end
test "all four endpoints return 404 for negative ID", %{conn: conn} do
conn = put_req_header(conn, "authorization", "Bearer #{api_token()}")
assert post(conn, ~p"/api/v1/errors/-1/mute") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/-1/unmute") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/-1/resolve") |> json_response(404) == %{
"error" => "Not Found"
}
assert post(conn, ~p"/api/v1/errors/-1/unresolve") |> json_response(404) == %{
"error" => "Not Found"
}
end
test "POST mute on already-muted error succeeds (controller idempotency)", %{conn: conn} do
error =
error_fixture(%{
reason: "Already muted",
muted: true,
status: :unresolved,
source_line: "lib/muted2.ex:1",
source_function: "MutedModule.muted2/0",
fingerprint:
error_fingerprint(:runtime_error, "lib/muted2.ex:1", "MutedModule.muted2/0")
})
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> post(~p"/api/v1/errors/#{error.id}/mute")
assert %{"error" => returned} = json_response(conn, 200)
assert returned["muted"] == true
end
test "POST unmute on already-unmuted error succeeds (controller idempotency)", %{conn: conn} do
error =
error_fixture(%{
reason: "Already unmuted",
muted: false,
status: :unresolved,
source_line: "lib/unmuted2.ex:1",
source_function: "UnmutedModule.unmuted2/0",
fingerprint:
error_fingerprint(:runtime_error, "lib/unmuted2.ex:1", "UnmutedModule.unmuted2/0")
})
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> post(~p"/api/v1/errors/#{error.id}/unmute")
assert %{"error" => returned} = json_response(conn, 200)
assert returned["muted"] == false
end
test "POST resolve on already-resolved error succeeds (controller idempotency)", %{conn: conn} do
error =
error_fixture(%{
reason: "Already resolved",
muted: false,
status: :resolved,
source_line: "lib/resolved2.ex:1",
source_function: "ResolvedModule.resolved2/0",
fingerprint:
error_fingerprint(:runtime_error, "lib/resolved2.ex:1", "ResolvedModule.resolved2/0")
})
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> post(~p"/api/v1/errors/#{error.id}/resolve")
assert %{"error" => returned} = json_response(conn, 200)
assert returned["status"] == "resolved"
end
test "POST unresolve on already-unresolved error succeeds (controller idempotency)", %{
conn: conn
} do
error =
error_fixture(%{
reason: "Already unresolved",
muted: false,
status: :unresolved,
source_line: "lib/unresolved2.ex:1",
source_function: "UnresolvedModule.unresolved2/0",
fingerprint:
error_fingerprint(
:runtime_error,
"lib/unresolved2.ex:1",
"UnresolvedModule.unresolved2/0"
)
})
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> post(~p"/api/v1/errors/#{error.id}/unresolve")
assert %{"error" => returned} = json_response(conn, 200)
assert returned["status"] == "unresolved"
end
end
end end