Skip to content
Merged
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
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@iconify-json/solar": "^1.2.5",
"@pinia/testing": "^0.1.6",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
Expand Down
45 changes: 45 additions & 0 deletions scripts/build-solar-icons.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Regenerates src/lib/solar-icons.ts from @iconify-json/solar.
//
// The published set is 7,000 icons and about 6MB of JSON in one object, so importing it puts
// all of it in the bundle whatever is registered afterwards. This writes out only the icons
// the app renders. Add a name below, run `node scripts/build-solar-icons.mjs`, and commit the
// result.
import { writeFileSync } from "node:fs";
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
const { icons, width, height } = require("@iconify-json/solar/icons.json");

const used = [
"check-circle-bold",
"danger-triangle-bold",
"clock-circle-bold",
"stop-circle-bold",
"alt-arrow-right-linear",
"cpu-bold-duotone",
"ssd-square-bold-duotone",
"global-bold-duotone",
"danger-circle-bold-duotone",
"stopwatch-bold-duotone",
"chart-2-bold-duotone",
];

const missing = used.filter((name) => !icons[name]);
if (missing.length) {
console.error(`These are not in the Solar set: ${missing.join(", ")}`);
process.exit(1);
}

const subset = { prefix: "solar", width, height, icons: Object.fromEntries(used.map((n) => [n, icons[n]])) };

writeFileSync(
new URL("../src/lib/solar-icons.ts", import.meta.url),
`// Generated by scripts/build-solar-icons.mjs. Do not edit by hand.
//
// Only the icons this app renders are here: importing the published set pulls its whole 6MB
// of JSON into the bundle, and no amount of filtering afterwards gets it back out.
export const solarSubset = ${JSON.stringify(subset, null, 2)} as const;
`,
);

console.log(`Wrote ${used.length} Solar icons.`);
115 changes: 115 additions & 0 deletions src/components/AlertRulesPanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { createTestingPinia } from "@pinia/testing";
import AlertRulesPanel from "./AlertRulesPanel.vue";
import { observabilityApi } from "@/services/observability";

vi.mock("@/services/observability", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/services/observability")>();
return {
...actual,
observabilityApi: { alertRules: vi.fn(), saveAlertRules: vi.fn(), firingAlerts: vi.fn() },
};
});

const rule = {
id: "r1",
name: "CPU high",
deployment: "shop",
metric: "container.cpu.usage",
comparison: "above" as const,
threshold: 80,
for_seconds: 60,
enabled: true,
};

const mountPanel = () =>
mount(AlertRulesPanel, {
props: { deployments: ["shop", "blog"] },
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
stubs: {
BaseModal: { template: "<div v-if='visible'><slot /><slot name='footer' /></div>", props: ["visible"] },
},
},
});

describe("AlertRulesPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(observabilityApi.alertRules).mockResolvedValue({ data: [rule] } as any);
vi.mocked(observabilityApi.firingAlerts).mockResolvedValue({ data: [] } as any);
vi.mocked(observabilityApi.saveAlertRules).mockResolvedValue({ data: [rule] } as any);
});

it("says what each rule watches in words", async () => {
const wrapper = mountPanel();
await flushPromises();

expect(wrapper.text()).toContain("CPU high");
expect(wrapper.text()).toContain("CPU usage above 80% for 60s, on shop");
});

it("marks a rule that is currently firing", async () => {
vi.mocked(observabilityApi.firingAlerts).mockResolvedValue({
data: [{ rule_id: "r1", rule_name: "CPU high", state: "firing" }],
} as any);

const wrapper = mountPanel();
await flushPromises();

expect(wrapper.text()).toContain("Firing");
expect(wrapper.find(".arp-state--firing").exists()).toBe(true);
});

it("sends the threshold as a number, not the string the input holds", async () => {
const wrapper = mountPanel();
await flushPromises();

(wrapper.vm as any).openNew();
// An input hands back a string whatever its type is.
(wrapper.vm as any).draft.name = "Memory";
(wrapper.vm as any).draft.threshold = "90";
(wrapper.vm as any).draft.for_seconds = "30";
await (wrapper.vm as any).save();
await flushPromises();

const sent = vi.mocked(observabilityApi.saveAlertRules).mock.calls[0][0];
const added = sent[sent.length - 1];
expect(added.threshold).toBe(90);
expect(added.for_seconds).toBe(30);
});

it("shows why the agent refused a rule", async () => {
vi.mocked(observabilityApi.saveAlertRules).mockRejectedValue({
response: { data: { error: 'unknown metric "container.disk.usage"' } },
});

const wrapper = mountPanel();
await flushPromises();
(wrapper.vm as any).openNew();
await (wrapper.vm as any).save();
await flushPromises();

expect(wrapper.text()).toContain("unknown metric");
});

it("deletes a rule by saving the set without it", async () => {
vi.mocked(observabilityApi.saveAlertRules).mockResolvedValue({ data: [] } as any);

const wrapper = mountPanel();
await flushPromises();
await (wrapper.vm as any).remove(rule);

expect(observabilityApi.saveAlertRules).toHaveBeenCalledWith([]);
});

it("invites the first rule when there are none", async () => {
vi.mocked(observabilityApi.alertRules).mockResolvedValue({ data: [] } as any);

const wrapper = mountPanel();
await flushPromises();

expect(wrapper.text()).toContain("Nothing is watched yet");
});
});
Loading
Loading