Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ src/
├── context.ts # CLI context (process, env)
├── commands/ # CLI commands organized by resource
│ ├── alert/ # Alert commands (list, view)
│ ├── monitor/ # Monitor commands
│ │ └── mute/ # Monitor mute commands (list, view, create, update, delete)
│ ├── auth/ # Auth commands (configure, login, logout, status)
│ ├── cli/ # CLI management (install, uninstall, upgrade)
│ ├── content/ # Content pack management (experimental: gated + hidden)
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ Command line interface for [Observe Inc](https://www.observeinc.com).
- **OPAL Query Execution** - Run OPAL queries directly from your terminal with schema-aware table output.
- **AI Agent Skills** - List and view reusable AI-agent instruction documents stored in Observe.
- **Alert Monitoring** - List and view alerts with severity filtering and active-only views.
- **Monitor Mutes** - Full CRUD for monitor mute rules (snoozes), targeting all monitors or a specific set.
- **Content Packs** - Install and view Host Explorer, Kubernetes Explorer, and Trace Explorer content.
- **Ingest Token Management** - Full CRUD for ingest tokens with datastream association.
- **Data Integrations** - Create data connections and datasources (AWS, Kubernetes, host) and generate CloudFormation quick-create URLs for AWS filedrop deployments.
- **Datastream Management** - Create, list, view, and update datastreams.
- **Multiple Output Formats** - All commands support `--format json` and `--format csv` for scripting and pipelines.
- **Responsive Tables** - Terminal-aware column widths with automatic text wrapping.
Expand Down Expand Up @@ -50,6 +54,27 @@ To update installed skills after edits in this repo, run `npx skills update`.
| `observe skill view` | View skill details and content |
| `observe alert list` | List alerts with severity and status filtering |
| `observe alert view` | View full alert details |
| `observe monitor mute list` | List and search monitor mute rules |
| `observe monitor mute view` | View full monitor mute rule details |
| `observe monitor mute create` | Create a monitor mute rule |
| `observe monitor mute update` | Update a monitor mute rule |
| `observe monitor mute delete` | Delete a monitor mute rule |
| `observe content host install` | Install Host Explorer content |
| `observe content host view` | View Host Explorer content |
| `observe content kubernetes install` | Install Kubernetes Explorer content |
| `observe content kubernetes view` | View Kubernetes Explorer content |
| `observe content tracing install` | Install Trace Explorer content |
| `observe content tracing view` | View Trace Explorer content |
| `observe ingest-token create` | Create a new ingest token |
| `observe ingest-token list` | List and search ingest tokens |
| `observe ingest-token view` | View an ingest token by ID |
| `observe ingest-token update` | Update an ingest token |
| `observe datasource create` | Create a datasource attached to a data connection |
| `observe datasource update` | Update an existing datasource's config |
| `observe datasource generate-stack-url` | Build a CloudFormation quick-create URL for a filedrop |
| `observe data-connection create` | Create a data connection (AWS, kubernetes, host, etc.) |
| `observe data-connection list` | List data connections |
| `observe data-connection view` | View a data connection by ID (with its datasources) |
| `observe datastream create` | Create a new datastream |
| `observe datastream list` | List datastreams |
| `observe datastream view` | View a datastream by ID |
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { buildApplication } from "@stricli/core";
import { name } from "../package.json";
import { alertRoutes } from "./commands/alert/index.js";
import { monitorRoutes } from "./commands/monitor/index.js";
import { dataConnectionRoutes } from "./commands/data-connection/index.js";
import { datasourceRoutes } from "./commands/datasource/index.js";
import { datastreamTokenRoutes } from "./commands/datastream-token/index.js";
Expand Down Expand Up @@ -31,6 +32,7 @@ export const routes = defineRoutes({
query: queryCommand,
skill: skillRoutes,
alert: alertRoutes,
monitor: monitorRoutes,
content: contentRoutes,
"ingest-token": ingestTokenRoutes,
datasource: datasourceRoutes,
Expand Down
17 changes: 17 additions & 0 deletions src/commands/monitor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { buildRouteMap } from "@stricli/core";
import { monitorMuteRoutes } from "./mute/index";

export const monitorRoutes = buildRouteMap({
routes: {
mute: monitorMuteRoutes,
},
docs: {
brief: "Manage monitors",
fullDescription: [
"Manage monitors in Observe.",
"",
"Commands:",
" mute Manage monitor mute rules (snoozes)",
].join("\n"),
},
});
234 changes: 234 additions & 0 deletions src/commands/monitor/mute/create.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";
import { resolve } from "node:path";
import type { LocalContext } from "../../../context";
import { createWriter } from "../../../lib/writer";

const repoRoot = resolve(import.meta.dir, "../../../..");
const restModulePath = resolve(
repoRoot,
"src/rest/monitor-mute/create-monitor-mute.ts",
);

const loadConfigFn = mock(() => ({
customerId: "test-customer",
token: "test-token",
domain: "observeinc.com",
}));

const createMonitorMuteFn = mock((_args: unknown) =>
Promise.resolve({ id: "mute-1", label: "Snooze checkout" }),
);

let create: (typeof import("./create"))["create"];

let previousNoColor: string | undefined;
let previousForceColor: string | undefined;

const deps = {
loadConfig: loadConfigFn,
} as Parameters<(typeof import("./create"))["create"]>[1];

beforeAll(async () => {
previousNoColor = process.env.NO_COLOR;
previousForceColor = process.env.FORCE_COLOR;
process.env.NO_COLOR = "1";
process.env.FORCE_COLOR = "0";

void mock.module(restModulePath, () => ({
createMonitorMute: createMonitorMuteFn,
}));

const mod = await import("./create.ts");
create = mod.create;
});

afterAll(() => {
mock.restore();
if (previousNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = previousNoColor;
}
if (previousForceColor === undefined) {
delete process.env.FORCE_COLOR;
} else {
process.env.FORCE_COLOR = previousForceColor;
}
});

function createMockContext() {
const stdout: string[] = [];
const stderr: string[] = [];
let exitCode: number | undefined;

const processMock = {
stdout: {
write: (msg: string) => {
stdout.push(msg);
return true;
},
},
stderr: {
write: (msg: string) => {
stderr.push(msg);
return true;
},
},
exit: (code?: number) => {
exitCode = code ?? 0;
throw new Error("process.exit");
},
};

const context = {
process: processMock,
writer: createWriter({ process: processMock }),
} as unknown as LocalContext;

return { context, stdout, stderr, getExitCode: () => exitCode };
}

describe("monitor mute create", () => {
beforeEach(() => {
loadConfigFn.mockClear();
createMonitorMuteFn.mockClear();
});

test("builds the request body from flags and forwards it", async () => {
const { context, stdout } = createMockContext();
await create.call(
context,
{
label: "Snooze checkout",
monitors: ["42", "43"],
start: "2026-06-23T18:00:00Z",
end: "2026-06-23T20:00:00Z",
json: true,
},
deps,
);

expect(createMonitorMuteFn).toHaveBeenCalledTimes(1);
const [args] = createMonitorMuteFn.mock.calls[0]!;
expect((args as { body: unknown }).body).toMatchObject({
label: "Snooze checkout",
target: { kind: "Monitors", monitors: [{ id: "42" }, { id: "43" }] },
schedule: {
kind: "OneTime",
oneTime: {
startTime: "2026-06-23T18:00:00Z",
endTime: "2026-06-23T20:00:00Z",
},
},
});
const output = JSON.parse(stdout.join(""));
expect(output.id).toBe("mute-1");
});

test("builds a recurring + global body (with filter)", async () => {
const { context } = createMockContext();
await create.call(
context,
{
label: "Weekday business hours",
global: true,
filter: 'level == "Critical"',
cron: "0 9 * * 1-5",
timezone: "America/Los_Angeles",
duration: 3600,
json: true,
},
deps,
);

const [args] = createMonitorMuteFn.mock.calls[0]!;
expect((args as { body: unknown }).body).toMatchObject({
target: { kind: "Global" },
filter: 'level == "Critical"',
schedule: {
kind: "Recurring",
recurring: {
cronSchedule: {
rawCron: "0 9 * * 1-5",
timezone: "America/Los_Angeles",
},
durationSeconds: 3600,
},
},
});
});

test("exits 1 when no target is given", async () => {
const { context, getExitCode, stderr } = createMockContext();
try {
await create.call(
context,
{ label: "x", start: "2026-06-23T18:00:00Z", json: true },
deps,
);
throw new Error("expected process.exit");
} catch (error) {
expect((error as Error).message).toBe("process.exit");
}
expect(getExitCode()).toBe(1);
expect(stderr.join("")).toContain("target");
expect(createMonitorMuteFn).not.toHaveBeenCalled();
});

test("exits 1 when no schedule is given", async () => {
const { context, getExitCode, stderr } = createMockContext();
try {
await create.call(context, { label: "x", monitors: ["42"] }, deps);
throw new Error("expected process.exit");
} catch (error) {
expect((error as Error).message).toBe("process.exit");
}
expect(getExitCode()).toBe(1);
expect(stderr.join("")).toContain("schedule");
expect(createMonitorMuteFn).not.toHaveBeenCalled();
});

test("exits 1 when --global is used without --filter", async () => {
const { context, getExitCode, stderr } = createMockContext();
try {
await create.call(
context,
{ label: "x", global: true, start: "2026-06-23T18:00:00Z" },
deps,
);
throw new Error("expected process.exit");
} catch (error) {
expect((error as Error).message).toBe("process.exit");
}
expect(getExitCode()).toBe(1);
expect(stderr.join("")).toContain("--filter");
expect(createMonitorMuteFn).not.toHaveBeenCalled();
});

test("exits 1 on API error", async () => {
createMonitorMuteFn.mockImplementationOnce(() => {
throw new Error("bad target");
});
const { context, stderr, getExitCode } = createMockContext();
try {
await create.call(
context,
{ label: "x", monitors: ["42"], start: "2026-06-23T18:00:00Z" },
deps,
);
throw new Error("expected process.exit");
} catch (error) {
expect((error as Error).message).toBe("process.exit");
}
expect(getExitCode()).toBe(1);
expect(stderr.join("")).toContain("Error");
});
});
Loading