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
6 changes: 3 additions & 3 deletions docs/configuration.it.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,9 +538,9 @@ riuscito, si riprova domani", registrato a livello debug sul server e per il
resto silenzioso.

Le scritture hanno effetto al **ciclo di poll successivo**, senza restart, perché lo
scheduler rilegge la configurazione a ogni passaggio. Un database nuovo viene
inizializzato con GitHub, Cloudflare e Anthropic così la dashboard è subito utile; la
tua lista non viene più sovrascritta in seguito.
scheduler rilegge la configurazione a ogni passaggio. Un database nuovo parte
senza provider: il wizard di primo avvio chiede quali seguire e manda una notifica di
prova reale (roadmap 4.5).

### 3.3 Variabili d'ambiente

Expand Down
6 changes: 3 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,9 +520,9 @@ just falls back to "last check didn't go through, retrying tomorrow", logged
at debug level on the server and otherwise silent.

Writes take effect on the **next poll cycle**, with no restart, because the
scheduler re-reads its configuration every pass. A fresh database is seeded with
GitHub, Cloudflare and Anthropic so the dashboard is useful immediately; your own
list is never overwritten afterwards.
scheduler re-reads its configuration every pass. A fresh database starts with
no providers: the first-run wizard asks which ones to watch and sends a real test
notification (roadmap 4.5).

### 3.3 Environment variables

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "isitdown",
"version": "2.26.0",
"version": "2.27.0",
"private": true,
"description": "Polls the public status pages of third-party providers and notifies on state transitions",
"license": "MIT",
Expand Down
12 changes: 7 additions & 5 deletions src/ui/db/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import type { DatabaseSync } from "node:sqlite";
/**
* First-boot defaults, so a fresh container shows a working dashboard instead of
* an empty grid. Providers are seeded only when the table is empty: an operator's
* own list is never overwritten.
* own list is never overwritten. The server turns that off (`providers: false`):
* a real installation starts empty and the first-run wizard (roadmap 4.5) asks
* which providers to watch.
*
* Channels are seeded disabled and carry the *name* of the environment variable
* holding each credential — never a value. That is the whole secret model of the
Expand Down Expand Up @@ -239,21 +241,21 @@ const DEFAULT_CHANNELS = [
{ id: "webpush", config: {} },
];

export function seedDefaults(db: DatabaseSync): void {
export function seedDefaults(db: DatabaseSync, { providers = true }: { providers?: boolean } = {}): void {
// One commit for every default row, not one fsync per insert.
db.exec("BEGIN");
try {
insertDefaults(db);
insertDefaults(db, providers);
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
}

function insertDefaults(db: DatabaseSync): void {
function insertDefaults(db: DatabaseSync, providers: boolean): void {
const [existing] = db.prepare("SELECT COUNT(*) AS n FROM services").all() as { n: number }[];
if ((existing?.n ?? 0) === 0) {
if (providers && (existing?.n ?? 0) === 0) {
const insert = db.prepare(
"INSERT INTO services (id, name, adapter, base_url, options, enabled, created_at) VALUES (?, ?, ?, ?, NULL, 1, ?)",
);
Expand Down
7 changes: 7 additions & 0 deletions src/ui/dbConfigSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ const settingsSchema = z.object({
* hundred of them wants compact at any width.
*/
density: z.enum(["comfortable", "compact"]).catch("comfortable"),
/**
* First-run wizard and guided tour — roadmap 4.5 and 4.6. Instance
* preferences, not browser ones: an installation is set up once, and a
* second browser must not walk the operator through it again.
*/
wizardDone: booleanSetting.catch(false),
tourSeen: booleanSetting.catch(false),
/**
* When the operator last said "I have seen it" on the Overview's "while you
* were away" summary — roadmap 4.4. An instance preference rather than a
Expand Down
4 changes: 4 additions & 0 deletions src/ui/routes/preferences.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export function preferencesRoutes(runtime: UiRuntimeCore): Router {
.optional(),
mapView: z.enum(["off", "map", "globe"]).optional(),
density: z.enum(["comfortable", "compact"]).optional(),
wizardDone: z.boolean().optional(),
tourSeen: z.boolean().optional(),
// Validated against the runtime's own zone table rather than a hand-kept
// list: "is this a zone?" is a question Intl can answer exactly, and a
// preference that stores a name nothing can format is a dashboard of
Expand All @@ -72,6 +74,8 @@ export function preferencesRoutes(runtime: UiRuntimeCore): Router {
notificationLocale: settings.notificationLocale,
mapView: settings.mapView,
density: settings.density,
wizardDone: settings.wizardDone,
tourSeen: settings.tourSeen,
timeZone: settings.timeZone,
lastSeenAt: settings.lastSeenAt === "" ? null : settings.lastSeenAt,
};
Expand Down
4 changes: 3 additions & 1 deletion src/ui/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface UiRuntimeOptions {
dbPath: string;
env: NodeJS.ProcessEnv;
logger?: Logger | undefined;
/** Seed the three starter providers into an empty database. Defaults on; the server turns it off. */
seedProviders?: boolean | undefined;
}

/**
Expand Down Expand Up @@ -188,7 +190,7 @@ export async function buildUiRuntime(options: UiRuntimeOptions): Promise<UiRunti

const db = openDatabase(options.dbPath);
migrate(db);
seedDefaults(db);
seedDefaults(db, { providers: options.seedProviders ?? true });
// Before the first cycle, like the plugins: a heartbeat provider row has to
// resolve to its adapter on the first read (roadmap 2.5).
const heartbeats = useHeartbeatLedger(db);
Expand Down
2 changes: 1 addition & 1 deletion src/ui/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const logger = createLogger(

let runtime: Awaited<ReturnType<typeof buildUiRuntime>> | undefined;
try {
runtime = await buildUiRuntime({ dbPath: DB_PATH, env: process.env, logger });
runtime = await buildUiRuntime({ dbPath: DB_PATH, env: process.env, logger, seedProviders: false });
} catch (error) {
logger.error("isitdown ui failed to start", {
error: error instanceof Error ? error.message : String(error),
Expand Down
5 changes: 5 additions & 0 deletions src/ui/web/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Outlet, useLocation, useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { CommandPalette } from "@/components/CommandPalette.tsx";
import { GuidedTour } from "@/components/GuidedTour.tsx";
import { SetupWizard } from "@/components/SetupWizard.tsx";
import { DashboardActions } from "@/components/dashboards/DashboardActions.tsx";
import { Rail } from "@/components/Rail.tsx";
import { Header } from "@/components/Header.tsx";
Expand Down Expand Up @@ -81,6 +83,9 @@ export function App() {
shortcut has to answer on every screen, and a palette mounted inside
a view would take its own key listener away with it on navigation. */}
<CommandPalette />
{/* Roadmaps 4.5 and 4.6: first-run chrome, in the shell for the palette's reason. */}
<SetupWizard />
<GuidedTour />
{/* Roadmap 4.2. The named dashboards' dialogs and Undo notice, in the
shell for the palette's reason: rail, header and ⌘K all ask for them. */}
<DashboardActions />
Expand Down
88 changes: 88 additions & 0 deletions src/ui/web/components/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,92 @@ describe("CommandPalette", () => {
expect(await screen.findByRole("option", { name: i18n.t("action.poll-now") })).toBeInTheDocument();
expect(await screen.findByRole("option", { name: i18n.t("nav.overview") })).toBeInTheDocument();
});

describe("actions — roadmap 4.7", () => {
const config = {
services: [{ id: "github", name: "GitHub", adapter: "statuspage", baseUrl: "https://example.test", enabled: true }],
channels: [
{ id: "telegram", enabled: true, fields: [], locale: "", template: "" },
{ id: "slack", enabled: false, fields: [], locale: "", template: "" },
],
};
const calls = (): unknown[][] => (globalThis.fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls;

it("mutes a provider: action, provider, duration", async () => {
const user = userEvent.setup();
renderWithProviders(<CommandPalette />, { status, config });
await open(user);

await user.click(await screen.findByRole("option", { name: i18n.t("palette.mute") }));
await user.click(await screen.findByRole("option", { name: "GitHub" }));
await user.click(await screen.findByRole("option", { name: i18n.t("mute.for-120") }));

await waitFor(() => {
const patch = calls().find((call) => String(call[0]) === "/config/services/github");
expect(patch).toBeDefined();
expect(JSON.parse(String((patch?.[1] as RequestInit).body)).mutedUntil).toEqual(expect.any(String));
});
expect(await screen.findByRole("status")).toHaveTextContent(i18n.t("toast.service.muted", { name: "GitHub" }));
});

it("steps back from a second page on Backspace in an empty input", async () => {
const user = userEvent.setup();
renderWithProviders(<CommandPalette />, { status, config });
await open(user);

await user.click(await screen.findByRole("option", { name: i18n.t("palette.mute") }));
// A click on a row takes focus off the input in jsdom; the keyboard path
// (Enter on the row) leaves it there, which is the case this is about.
await user.click(screen.getByPlaceholderText(i18n.t("palette.pick-provider")));
await user.keyboard("{Backspace}");

expect(await screen.findByRole("option", { name: i18n.t("action.poll-now") })).toBeInTheDocument();
});

it("jumps to the latest incident", async () => {
const incidents = {
active: [],
counts: {},
page: {
page: 1,
pageSize: 1,
total: 1,
items: [{ providerId: "github", incidentId: "i1", name: "Down", impact: "major", status: "investigating", startedAt: "2026-10-20T10:00:00Z", updatedAt: "2026-10-20T10:00:00Z", resolvedAt: null }],
},
};
const user = userEvent.setup();
renderWithProviders(<CommandPalette />, { status, config, incidents });
await open(user);

await user.click(await screen.findByRole("option", { name: i18n.t("palette.last-incident") }));

await waitFor(() => expect(window.location.hash).toBe("#/incidents/github/i1"));
});

it("writes the typed text as a fleet-wide marker", async () => {
const user = userEvent.setup();
renderWithProviders(<CommandPalette />, { status, config });
await open(user);

await user.click(await screen.findByRole("option", { name: i18n.t("palette.annotate") }));
await user.type(screen.getByPlaceholderText(i18n.t("palette.annotate-placeholder")), "deploy 4.2");
await user.keyboard("{Enter}");

await waitFor(() => {
const post = calls().find((call) => String(call[0]) === "/annotations" && (call[1] as RequestInit).method === "POST");
expect(JSON.parse(String((post?.[1] as RequestInit).body))).toMatchObject({ label: "deploy 4.2" });
});
});

it("offers only enabled channels for a test notification", async () => {
const user = userEvent.setup();
renderWithProviders(<CommandPalette />, { status, config });
await open(user);

await user.click(await screen.findByRole("option", { name: i18n.t("palette.send-test") }));

expect(await screen.findByRole("option", { name: i18n.t("channel.name.telegram") })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: i18n.t("channel.name.slack") })).not.toBeInTheDocument();
});
});
});
Loading
Loading