diff --git a/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts index fbdf228c3..0abbf38d8 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts @@ -3654,8 +3654,6 @@ describe("StatusPageService.UpdateStatusPage — new fields", () => { }); describe("StatusPageService — new fields limit enforcement (workspace 2 / free plan)", () => { - let ws2PageId: number; - test("returns 403 when creating with custom_domain on free plan", async () => { const res = await connectRequest( "CreateStatusPage", @@ -3700,37 +3698,55 @@ describe("StatusPageService — new fields limit enforcement (workspace 2 / free expect(res.status).toBe(403); }); + // Each update test creates and deletes its OWN page rather than sharing a + // `ws2PageId` across the block: the services suites clear workspace-2 pages + // (`cleanQuotaGatedTables(SEEDED_WORKSPACE_FREE_ID)`) on committed rows in + // parallel, so a page persisted across several tests can vanish mid-block + // and turn the expected 403 into a 404. Mirrors the IP-restriction block. test("returns 403 when updating with custom_domain on free plan", async () => { const ws2Page = await db .insert(page) .values({ workspaceId: 2, - title: `${TEST_PREFIX}-limit-update-ws2`, - slug: `${TEST_PREFIX}-limit-update-ws2-slug`, + title: `${TEST_PREFIX}-limit-update-ws2-cd`, + slug: `${TEST_PREFIX}-limit-update-ws2-cd-slug`, description: "Free plan page", customDomain: "", }) .returning() .get(); - ws2PageId = ws2Page.id; const res = await connectRequest( "UpdateStatusPage", { - id: String(ws2PageId), + id: String(ws2Page.id), customDomain: "status.freeplan.com", }, { "x-openstatus-key": "2" }, ); expect(res.status).toBe(403); + + await db.delete(page).where(eq(page.id, ws2Page.id)); }); test("returns 403 when updating with PASSWORD_PROTECTED on free plan", async () => { + const ws2Page = await db + .insert(page) + .values({ + workspaceId: 2, + title: `${TEST_PREFIX}-limit-update-ws2-pw`, + slug: `${TEST_PREFIX}-limit-update-ws2-pw-slug`, + description: "Free plan page", + customDomain: "", + }) + .returning() + .get(); + const res = await connectRequest( "UpdateStatusPage", { - id: String(ws2PageId), + id: String(ws2Page.id), accessType: "PAGE_ACCESS_TYPE_PASSWORD_PROTECTED", password: "secret", }, @@ -3738,13 +3754,27 @@ describe("StatusPageService — new fields limit enforcement (workspace 2 / free ); expect(res.status).toBe(403); + + await db.delete(page).where(eq(page.id, ws2Page.id)); }); test("returns 403 when updating with AUTHENTICATED on free plan", async () => { + const ws2Page = await db + .insert(page) + .values({ + workspaceId: 2, + title: `${TEST_PREFIX}-limit-update-ws2-auth`, + slug: `${TEST_PREFIX}-limit-update-ws2-auth-slug`, + description: "Free plan page", + customDomain: "", + }) + .returning() + .get(); + const res = await connectRequest( "UpdateStatusPage", { - id: String(ws2PageId), + id: String(ws2Page.id), accessType: "PAGE_ACCESS_TYPE_AUTHENTICATED", authEmailDomains: ["example.com"], }, @@ -3753,7 +3783,7 @@ describe("StatusPageService — new fields limit enforcement (workspace 2 / free expect(res.status).toBe(403); - await db.delete(page).where(eq(page.id, ws2PageId)); + await db.delete(page).where(eq(page.id, ws2Page.id)); }); }); diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 8eac89de6..c41d93b87 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -1,16 +1,12 @@ import { Events, setupAnalytics } from "@openstatus/analytics"; -import { and, asc, eq, isNull, ne } from "@openstatus/db"; +import { eq } from "@openstatus/db"; +import { user } from "@openstatus/db/src/schema"; +import type { ServiceContext } from "@openstatus/services"; import { - invitation, - monitor, - notification, - page, - selectWorkspaceSchema, - user, - usersToWorkspaces, - workspace, -} from "@openstatus/db/src/schema"; -import { getLimits } from "@openstatus/db/src/schema/plan/utils"; + downgradeWorkspaceToFree, + getWorkspaceByStripeId, + updateWorkspacePlan, +} from "@openstatus/services/workspace"; import { TRPCError } from "@trpc/server"; import type Stripe from "stripe"; import { z } from "zod"; @@ -62,19 +58,17 @@ export const webhookRouter = createTRPCRouter({ ? subscription.customer : subscription.customer.id; - const result = await opts.ctx.db - .select() - .from(workspace) - .where(eq(workspace.stripeId, customerId)) - .get(); - if (!result) { + const ws = await getWorkspaceByStripeId({ + input: { stripeId: customerId }, + db: opts.ctx.db, + }); + if (!ws) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace not found", }); } - const ws = selectWorkspaceSchema.parse(result); const oldPlan = ws.plan; const built = buildFromSubscriptionOrThrow(subscription); @@ -85,17 +79,24 @@ export const webhookRouter = createTRPCRouter({ return; } - await opts.ctx.db - .update(workspace) - .set({ + // No `reason` metadata: `customer.subscription.updated` fires on trivial + // changes too, so let the audit no-op-skip drop rows where nothing + // tracked changed. The `stripe-subscription-updated` actor id still + // identifies the source on the rows that do land. + await updateWorkspacePlan({ + ctx: { + workspace: ws, + actor: { type: "system", job: "stripe-subscription-updated" }, + db: opts.ctx.db, + }, + input: { plan: built.plan, subscriptionId: subscription.id, endsAt: new Date(subscription.current_period_end * 1000), paidUntil: new Date(subscription.current_period_end * 1000), - limits: JSON.stringify(built.limits), - }) - .where(eq(workspace.id, result.id)) - .run(); + limits: built.limits, + }, + }); const allActive = await stripe.subscriptions.list({ customer: customerId, @@ -134,7 +135,7 @@ export const webhookRouter = createTRPCRouter({ const analytics = await setupAnalytics({ userId: `usr_${userResult.id}`, email: userResult.email || undefined, - workspaceId: String(result.id), + workspaceId: String(ws.id), plan: newPlan, }); await analytics.track(event); @@ -157,12 +158,11 @@ export const webhookRouter = createTRPCRouter({ ? subscription.customer : subscription.customer.id; - const result = await opts.ctx.db - .select() - .from(workspace) - .where(eq(workspace.stripeId, customerId)) - .get(); - if (!result) { + const ws = await getWorkspaceByStripeId({ + input: { stripeId: customerId }, + db: opts.ctx.db, + }); + if (!ws) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace not found", @@ -178,17 +178,21 @@ export const webhookRouter = createTRPCRouter({ }); } - await opts.ctx.db - .update(workspace) - .set({ + await updateWorkspacePlan({ + ctx: { + workspace: ws, + actor: { type: "system", job: "stripe-session-completed" }, + db: opts.ctx.db, + }, + input: { plan: built.plan, subscriptionId: subscription.id, endsAt: new Date(subscription.current_period_end * 1000), paidUntil: new Date(subscription.current_period_end * 1000), - limits: JSON.stringify(built.limits), - }) - .where(eq(workspace.id, result.id)) - .run(); + limits: built.limits, + reason: "checkout_session_completed", + }, + }); const customer = await stripe.customers.retrieve(customerId); if (!customer.deleted && customer.email) { @@ -202,7 +206,7 @@ export const webhookRouter = createTRPCRouter({ const analytics = await setupAnalytics({ userId: `usr_${userResult.id}`, email: userResult.email || undefined, - workspaceId: String(result.id), + workspaceId: String(ws.id), plan: built.plan, }); await analytics.track(Events.UpgradeWorkspace); @@ -224,124 +228,30 @@ export const webhookRouter = createTRPCRouter({ return; } - const { workspaces, customDomains } = await opts.ctx.db.transaction( - async (tx) => { - const _workspace = await tx - .update(workspace) - .set({ - subscriptionId: null, - plan: "free", - paidUntil: null, - endsAt: null, - limits: JSON.stringify(getLimits("free")), - }) - .where(eq(workspace.stripeId, customerId)) - .returning(); - - if (!_workspace.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Workspace not found", - }); - } - - const workspaceId = _workspace[0].id; - - const activeMonitors = await tx - .select({ id: monitor.id }) - .from(monitor) - .where( - and( - eq(monitor.workspaceId, workspaceId), - eq(monitor.active, true), - isNull(monitor.deletedAt), - ), - ) - .orderBy(asc(monitor.createdAt)); - - for (const m of activeMonitors.slice(1)) { - await tx - .update(monitor) - .set({ active: false, updatedAt: new Date() }) - .where(eq(monitor.id, m.id)) - .run(); - } - - const statusPages = await tx - .select({ id: page.id, customDomain: page.customDomain }) - .from(page) - .where(eq(page.workspaceId, workspaceId)) - .orderBy(asc(page.createdAt)); - - const customDomains = [ - ...new Set( - statusPages - .map((p) => p.customDomain) - .filter((domain) => domain !== ""), - ), - ]; - - for (const p of statusPages.slice(1)) { - await tx.delete(page).where(eq(page.id, p.id)).run(); - } - - if (statusPages.length > 0) { - await tx - .update(page) - .set({ - customDomain: "", - password: null, - accessType: "public", - authEmailDomains: null, - updatedAt: new Date(), - }) - .where(eq(page.id, statusPages[0].id)) - .run(); - } - - const notifications = await tx - .select({ id: notification.id, provider: notification.provider }) - .from(notification) - .where(eq(notification.workspaceId, workspaceId)) - .orderBy(asc(notification.createdAt)); - - const keepNotification = - notifications.find((n) => n.provider === "email") ?? notifications[0]; - - for (const n of notifications.filter( - (n) => n.id !== keepNotification?.id, - )) { - await tx.delete(notification).where(eq(notification.id, n.id)).run(); - } - - // Remove all non-owner members from the workspace - await tx - .delete(usersToWorkspaces) - .where( - and( - eq(usersToWorkspaces.workspaceId, workspaceId), - ne(usersToWorkspaces.role, "owner"), - ), - ) - .run(); - - // Remove all pending invitations for the workspace - await tx - .delete(invitation) - .where(eq(invitation.workspaceId, workspaceId)) - .run(); - - return { workspaces: _workspace, customDomains }; - }, - ); + const ws = await getWorkspaceByStripeId({ + input: { stripeId: customerId }, + db: opts.ctx.db, + }); - if (!workspaces[0]) { + if (!ws) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace not found", }); } + // System actor — no user is attributable to an involuntary Stripe + // cancellation. The service verb runs the whole trim in one audited + // transaction; a failed audit insert rolls the downgrade back and the + // webhook returns non-2xx so Stripe retries. + const ctx: ServiceContext = { + workspace: ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: opts.ctx.db, + }; + + const { customDomains } = await downgradeWorkspaceToFree({ ctx }); + // Free plan has no custom-domain feature — release each domain on Vercel // unless another workspace's page still holds it. Best-effort after // commit: a Vercel error must not fail the webhook into Stripe retries. @@ -356,7 +266,6 @@ export const webhookRouter = createTRPCRouter({ } } - const workspaceId = workspaces[0].id; const customer = await stripe.customers.retrieve(customerId); if (!customer.deleted && customer.email) { @@ -370,7 +279,7 @@ export const webhookRouter = createTRPCRouter({ const analytics = await setupAnalytics({ userId: `usr_${userResult.id}`, email: customer.email || undefined, - workspaceId: String(workspaceId), + workspaceId: String(ws.id), plan: "free", }); await analytics.track(Events.DowngradeWorkspace); diff --git a/packages/db/src/schema/audit_logs/validation.ts b/packages/db/src/schema/audit_logs/validation.ts index 8220a2f00..ad2076dff 100644 --- a/packages/db/src/schema/audit_logs/validation.ts +++ b/packages/db/src/schema/audit_logs/validation.ts @@ -99,7 +99,7 @@ const notificationActions = [ const userActions = [action("user.delete", "user", intId)] as const; const workspaceActions = [ - action("workspace.update", "workspace", intId), + action("workspace.update", "workspace", intId, { optionalMetadata: true }), ] as const; const maintenanceActions = [ diff --git a/packages/services/src/member/delete.ts b/packages/services/src/member/delete.ts index 7329c325c..5e467c65d 100644 --- a/packages/services/src/member/delete.ts +++ b/packages/services/src/member/delete.ts @@ -1,24 +1,12 @@ import { and, eq } from "@openstatus/db"; import { usersToWorkspaces } from "@openstatus/db/src/schema"; -import { z } from "zod"; -import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; import { NotFoundError, PreconditionFailedError } from "../errors"; +import { removeMemberInWorkspace } from "./internal"; import { DeleteMemberInput } from "./schemas"; -// Composite-PK rows: drizzle's createSelectSchema would flatten the join, -// but the membership row has no auto-generated columns we'd want to drop -// from a snapshot. Inline parser keeps the audit `before` shape stable -// even if a column is added to `users_to_workspaces` later. -const memberRowSnapshot = z.object({ - userId: z.number(), - workspaceId: z.number(), - role: z.string(), - createdAt: z.coerce.date().nullable(), -}); - /** * Delete a member's workspace association. Idempotent on the target row — * if the target user has no membership in the caller's workspace (wrong id, @@ -77,23 +65,6 @@ export async function deleteMember(args: { ); } - const [removed] = await tx - .delete(usersToWorkspaces) - .where( - and( - eq(usersToWorkspaces.workspaceId, ctx.workspace.id), - eq(usersToWorkspaces.userId, input.userId), - ), - ) - .returning(); - - if (!removed) return; - - await emitAudit(tx, ctx, { - action: "member.delete", - entityType: "member", - entityId: input.userId, - before: memberRowSnapshot.parse(removed), - }); + await removeMemberInWorkspace({ tx, ctx, userId: input.userId }); }); } diff --git a/packages/services/src/member/internal.ts b/packages/services/src/member/internal.ts new file mode 100644 index 000000000..1d50f2d64 --- /dev/null +++ b/packages/services/src/member/internal.ts @@ -0,0 +1,55 @@ +import { and, eq } from "@openstatus/db"; +import { usersToWorkspaces } from "@openstatus/db/src/schema"; +import { z } from "zod"; + +import { emitAudit } from "../audit"; +import { type DB, type ServiceContext } from "../context"; + +// Composite-PK rows: drizzle's createSelectSchema would flatten the join, +// but the membership row has no auto-generated columns we'd want to drop +// from a snapshot. Inline parser keeps the audit `before` shape stable +// even if a column is added to `users_to_workspaces` later. +export const memberRowSnapshot = z.object({ + userId: z.number(), + workspaceId: z.number(), + role: z.string(), + createdAt: z.coerce.date().nullable(), +}); + +/** + * Shared delete+audit body for a single membership row — the sole place + * the `member.delete` write lives. `deleteMember` wraps this with its + * owner-actor / self-removal guards; the Stripe downgrade cascade calls it + * directly as a `system` actor. This is an extraction, not a duplicate: + * the guard-bearing entry point (`deleteMember`) delegates here so the two + * callers can't drift on the workspace-scoping or the audit snapshot. + * + * No authorization guard of its own — callers own that. Idempotent on the + * target row: a missing membership deletes nothing and emits no audit row. + */ +export async function removeMemberInWorkspace(args: { + tx: DB; + ctx: ServiceContext; + userId: number; +}): Promise { + const { tx, ctx, userId } = args; + + const [removed] = await tx + .delete(usersToWorkspaces) + .where( + and( + eq(usersToWorkspaces.workspaceId, ctx.workspace.id), + eq(usersToWorkspaces.userId, userId), + ), + ) + .returning(); + + if (!removed) return; + + await emitAudit(tx, ctx, { + action: "member.delete", + entityType: "member", + entityId: userId, + before: memberRowSnapshot.parse(removed), + }); +} diff --git a/packages/services/src/notification/__tests__/notification.test.ts b/packages/services/src/notification/__tests__/notification.test.ts index 5082f9718..e0d1fea72 100644 --- a/packages/services/src/notification/__tests__/notification.test.ts +++ b/packages/services/src/notification/__tests__/notification.test.ts @@ -3,16 +3,15 @@ import { monitor, notification, notificationsToMonitors, + selectWorkspaceSchema, + workspace, } from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; import { expect } from "@std/expect"; import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; +import { SEEDED_WORKSPACE_TEAM_ID } from "../../../test/fixtures"; import { - SEEDED_WORKSPACE_FREE_ID, - SEEDED_WORKSPACE_TEAM_ID, -} from "../../../test/fixtures"; -import { - cleanQuotaGatedTables, expectAuditRow, loadSeededWorkspace, makeApiKeyCtx, @@ -37,17 +36,34 @@ let teamCtx: ServiceContext; let freeCtx: ServiceContext; let teamMonitorId: number; +// Dedicated, freshly-inserted free-plan workspace for the quota-sensitive +// negative-path tests (`notification-channels: 1`). The shared seeded free +// workspace (#2) is written concurrently by the apps/server RPC suites under +// parallel test execution, which intermittently exhausts the quota and fails +// the wrong assertion. An isolated workspace removes that cross-suite race; +// every test writes inside a rolled-back transaction, so its committed +// notification count stays at zero for the whole suite. +const FREE_WS_SLUG = `${TEST_PREFIX}-free-ws`; + beforeAll(async () => { const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); - const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); teamCtx = makeUserCtx(team, { userId: 1 }); - freeCtx = makeUserCtx(free, { userId: 2 }); - // Clear quota-gated rows on the free workspace so - // `notification-channels: 1` (free plan) can actually be exercised - // by negative-path tests — any leftover row from prior runs tripped - // `LimitExceededError` before the intended assertion fired. - await cleanQuotaGatedTables(SEEDED_WORKSPACE_FREE_ID); + await db + .delete(workspace) + .where(eq(workspace.slug, FREE_WS_SLUG)) + .catch(() => undefined); + const freeRow = await db + .insert(workspace) + .values({ + slug: FREE_WS_SLUG, + name: `${TEST_PREFIX}-free`, + plan: "free", + limits: JSON.stringify(getLimits("free")), + }) + .returning() + .get(); + freeCtx = makeUserCtx(selectWorkspaceSchema.parse(freeRow), { userId: 2 }); const monitorRow = await db .insert(monitor) @@ -70,6 +86,10 @@ afterAll(async () => { .delete(monitor) .where(eq(monitor.id, teamMonitorId)) .catch(() => undefined); + await db + .delete(workspace) + .where(eq(workspace.slug, FREE_WS_SLUG)) + .catch(() => undefined); }); describe("createNotification", () => { @@ -326,7 +346,7 @@ describe("updateNotification", () => { const [inserted] = await tx .insert(notification) .values({ - workspaceId: SEEDED_WORKSPACE_FREE_ID, + workspaceId: freeCtx.workspace.id, name: `${TEST_PREFIX}-downgrade-gate`, provider: "pagerduty", data: JSON.stringify({ diff --git a/packages/services/src/page/__tests__/page.test.ts b/packages/services/src/page/__tests__/page.test.ts index 4cfcb98fe..fa7af6cad 100644 --- a/packages/services/src/page/__tests__/page.test.ts +++ b/packages/services/src/page/__tests__/page.test.ts @@ -3,16 +3,15 @@ import { monitor, page as pageTable, pageComponent, + selectWorkspaceSchema, + workspace, } from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; import { expect } from "@std/expect"; import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; +import { SEEDED_WORKSPACE_TEAM_ID } from "../../../test/fixtures"; import { - SEEDED_WORKSPACE_FREE_ID, - SEEDED_WORKSPACE_TEAM_ID, -} from "../../../test/fixtures"; -import { - cleanQuotaGatedTables, expectAuditRow, loadSeededWorkspace, makeApiKeyCtx, @@ -41,17 +40,35 @@ let teamCtx: ServiceContext; let freeCtx: ServiceContext; let teamMonitorId: number; +// Dedicated, freshly-inserted free-plan workspace for the quota-sensitive +// negative-path tests (status-pages limit = 1). They assume exclusive control +// of the workspace's page count, but the shared seeded free workspace (#2) is +// written concurrently by the apps/server RPC suites under parallel test +// execution — which intermittently exhausts the quota and fails the wrong +// assertion. An isolated workspace removes that cross-suite race; because every +// test writes inside a rolled-back transaction, its committed page count stays +// at zero for the whole suite. +const FREE_WS_SLUG = `${TEST_PREFIX}-free-ws`; + beforeAll(async () => { const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); - const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); teamCtx = makeUserCtx(team, { userId: 1 }); - freeCtx = makeUserCtx(free, { userId: 2 }); - // Clear leftover quota-gated rows on the free workspace so - // negative-path tests hit their intended assertion (e.g. - // `assertStatusPageQuota` on free = 1 page) regardless of what - // prior runs left behind. - await cleanQuotaGatedTables(SEEDED_WORKSPACE_FREE_ID); + await db + .delete(workspace) + .where(eq(workspace.slug, FREE_WS_SLUG)) + .catch(() => undefined); + const freeRow = await db + .insert(workspace) + .values({ + slug: FREE_WS_SLUG, + name: `${TEST_PREFIX}-free`, + plan: "free", + limits: JSON.stringify(getLimits("free")), + }) + .returning() + .get(); + freeCtx = makeUserCtx(selectWorkspaceSchema.parse(freeRow), { userId: 2 }); const teamMonitor = await db .insert(monitor) @@ -74,6 +91,10 @@ afterAll(async () => { .delete(monitor) .where(eq(monitor.id, teamMonitorId)) .catch(() => undefined); + await db + .delete(workspace) + .where(eq(workspace.slug, FREE_WS_SLUG)) + .catch(() => undefined); }); let slugCounter = 0; @@ -160,7 +181,7 @@ describe("createPage (full form)", () => { slug, description: "", customDomain: "", - workspaceId: SEEDED_WORKSPACE_FREE_ID, + workspaceId: freeCtx.workspace.id, monitors: [{ monitorId: teamMonitorId }], }, }), @@ -352,14 +373,15 @@ describe("updatePageCustomTheme", () => { test("rejects when plan lacks custom-theme", async () => { await withTestTransaction(async (tx) => { const ctx = { ...freeCtx, db: tx }; - const p = await newPage({ - ctx, - input: { title: "Free Theme", slug: uniqueSlug("free-theme") }, - }); + // No page needed: the `custom-theme` limit check fires before the + // page lookup (mirrors the read-only-actor case below). Creating a + // page here would depend on the shared free workspace's status-pages + // quota, which a parallel suite can exhaust → flaky LimitExceededError + // on the wrong assertion. await expect( updatePageCustomTheme({ ctx, - input: { id: p.id, customTheme: { light: { "--primary": "red" } } }, + input: { id: 1, customTheme: { light: { "--primary": "red" } } }, }), ).rejects.toBeInstanceOf(LimitExceededError); }); diff --git a/packages/services/src/workspace/__tests__/downgrade.test.ts b/packages/services/src/workspace/__tests__/downgrade.test.ts new file mode 100644 index 000000000..ebc361d2c --- /dev/null +++ b/packages/services/src/workspace/__tests__/downgrade.test.ts @@ -0,0 +1,430 @@ +import { eq } from "@openstatus/db"; +import { + invitation, + monitor, + notification, + page, + selectWorkspaceSchema, + user, + usersToWorkspaces, + workspace, +} from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + expectAuditRow, + makeApiKeyCtx, + readAuditLog, + withTestTransaction, +} from "../../../test/helpers"; +import type { DrizzleTx, ServiceContext } from "../../context"; +import { ForbiddenError } from "../../errors"; +import { downgradeWorkspaceToFree } from "../index.ts"; + +const OLDEST = new Date("2020-01-01T00:00:00Z"); +const NEWER = new Date("2021-01-01T00:00:00Z"); + +/** + * Insert a self-contained `team` workspace with everything the free tier + * can't hold: two active monitors, two pages (the survivor carrying paid + * access features), two notifications, an owner + two members, and a + * pending + accepted invitation. Everything is scoped to the returned + * workspace so assertions don't collide with seed data. Rolled back by the + * enclosing `withTestTransaction`. + */ +async function seedTeamWorkspace(tx: DrizzleTx) { + const wsRow = await tx + .insert(workspace) + .values({ + slug: "svc-downgrade-test", + name: "Downgrade Test", + plan: "team", + stripeId: "cus_svc_downgrade_test", + subscriptionId: "sub_svc_downgrade_test", + limits: JSON.stringify(getLimits("team")), + }) + .returning() + .get(); + const ws = selectWorkspaceSchema.parse(wsRow); + + const ownerUserId = 900_001; + const memberAId = 900_002; + const memberBId = 900_003; + await tx.insert(user).values([ + { id: ownerUserId, tenantId: "svc-downgrade-owner" }, + { id: memberAId, tenantId: "svc-downgrade-member-a" }, + { id: memberBId, tenantId: "svc-downgrade-member-b" }, + ]); + await tx.insert(usersToWorkspaces).values([ + { workspaceId: ws.id, userId: ownerUserId, role: "owner" }, + { workspaceId: ws.id, userId: memberAId, role: "member" }, + { workspaceId: ws.id, userId: memberBId, role: "admin" }, + ]); + + const oldestMonitor = await tx + .insert(monitor) + .values({ + workspaceId: ws.id, + url: "https://oldest.example.com", + active: true, + createdAt: OLDEST, + }) + .returning() + .get(); + const newerMonitor = await tx + .insert(monitor) + .values({ + workspaceId: ws.id, + url: "https://newer.example.com", + active: true, + createdAt: NEWER, + }) + .returning() + .get(); + + const keptPage = await tx + .insert(page) + .values({ + workspaceId: ws.id, + title: "Kept Page", + description: "", + slug: "svc-downgrade-kept", + customDomain: "status.acme.test", + password: "hunter2", + accessType: "password", + allowIndex: false, + createdAt: OLDEST, + }) + .returning() + .get(); + const deletedPage = await tx + .insert(page) + .values({ + workspaceId: ws.id, + title: "Deleted Page", + description: "", + slug: "svc-downgrade-deleted", + customDomain: "", + createdAt: NEWER, + }) + .returning() + .get(); + + const emailNotification = await tx + .insert(notification) + .values({ + workspaceId: ws.id, + name: "keep-email", + provider: "email", + data: "{}", + createdAt: NEWER, + }) + .returning() + .get(); + const discordNotification = await tx + .insert(notification) + .values({ + workspaceId: ws.id, + name: "drop-discord", + provider: "discord", + data: "{}", + createdAt: OLDEST, + }) + .returning() + .get(); + + const pendingInvitation = await tx + .insert(invitation) + .values({ + workspaceId: ws.id, + email: "pending@example.test", + token: "svc-downgrade-pending", + expiresAt: NEWER, + }) + .returning() + .get(); + const acceptedInvitation = await tx + .insert(invitation) + .values({ + workspaceId: ws.id, + email: "accepted@example.test", + token: "svc-downgrade-accepted", + expiresAt: NEWER, + acceptedAt: OLDEST, + }) + .returning() + .get(); + + return { + ws, + ownerUserId, + memberAId, + memberBId, + oldestMonitor, + newerMonitor, + keptPage, + deletedPage, + emailNotification, + discordNotification, + pendingInvitation, + acceptedInvitation, + }; +} + +describe("downgradeWorkspaceToFree", () => { + test("flips the plan to free and resets billing columns", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx }); + + const after = await tx + .select() + .from(workspace) + .where(eq(workspace.id, s.ws.id)) + .get(); + expect(after?.plan).toBe("free"); + expect(after?.subscriptionId).toBeNull(); + expect(after?.paidUntil).toBeNull(); + expect(after?.endsAt).toBeNull(); + // Compare parsed content, not the raw string — the verb persists + // `limitsSchema`-canonicalised JSON (key order differs from the + // config object returned by `getLimits`). + expect(JSON.parse(after?.limits ?? "{}")).toEqual(getLimits("free")); + + await expectAuditRow({ + workspaceId: s.ws.id, + action: "workspace.update", + entityType: "workspace", + entityId: s.ws.id, + actorType: "system", + db: tx, + }); + + const [wsAudit] = await readAuditLog({ + workspaceId: s.ws.id, + entityType: "workspace", + entityId: s.ws.id, + db: tx, + }); + expect(wsAudit?.metadata).toMatchObject({ + reason: "subscription_deleted", + from: "team", + to: "free", + }); + }); + }); + + test("deactivates all but the oldest active monitor", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx }); + + const oldest = await tx + .select() + .from(monitor) + .where(eq(monitor.id, s.oldestMonitor.id)) + .get(); + const newer = await tx + .select() + .from(monitor) + .where(eq(monitor.id, s.newerMonitor.id)) + .get(); + expect(oldest?.active).toBe(true); + expect(newer?.active).toBe(false); + + await expectAuditRow({ + workspaceId: s.ws.id, + action: "monitor.update", + entityType: "monitor", + entityId: s.newerMonitor.id, + db: tx, + }); + }); + }); + + test("deletes all but the oldest page and strips the survivor's paid features", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + const { customDomains } = await downgradeWorkspaceToFree({ ctx }); + expect(customDomains).toContain("status.acme.test"); + + const deleted = await tx + .select() + .from(page) + .where(eq(page.id, s.deletedPage.id)) + .get(); + expect(deleted).toBeUndefined(); + + const kept = await tx + .select() + .from(page) + .where(eq(page.id, s.keptPage.id)) + .get(); + expect(kept?.customDomain).toBe(""); + expect(kept?.password).toBeNull(); + expect(kept?.accessType).toBe("public"); + // no-index is paid-only — the survivor must become indexable again. + expect(kept?.allowIndex).toBe(true); + + await expectAuditRow({ + workspaceId: s.ws.id, + action: "page.delete", + entityType: "page", + entityId: s.deletedPage.id, + db: tx, + }); + await expectAuditRow({ + workspaceId: s.ws.id, + action: "page.update", + entityType: "page", + entityId: s.keptPage.id, + db: tx, + }); + }); + }); + + test("keeps the email notification, deletes the rest", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx }); + + const kept = await tx + .select() + .from(notification) + .where(eq(notification.id, s.emailNotification.id)) + .get(); + const dropped = await tx + .select() + .from(notification) + .where(eq(notification.id, s.discordNotification.id)) + .get(); + expect(kept).toBeDefined(); + expect(dropped).toBeUndefined(); + + await expectAuditRow({ + workspaceId: s.ws.id, + action: "notification.delete", + entityType: "notification", + entityId: s.discordNotification.id, + db: tx, + }); + }); + }); + + test("removes every non-owner member, one audit row each", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx }); + + const remaining = await tx + .select() + .from(usersToWorkspaces) + .where(eq(usersToWorkspaces.workspaceId, s.ws.id)) + .all(); + expect(remaining.map((r) => r.userId).sort()).toEqual([s.ownerUserId]); + + for (const userId of [s.memberAId, s.memberBId]) { + await expectAuditRow({ + workspaceId: s.ws.id, + action: "member.delete", + entityType: "member", + entityId: userId, + actorType: "system", + db: tx, + }); + } + }); + }); + + test("deletes pending invitations but keeps accepted ones", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx }); + + const pending = await tx + .select() + .from(invitation) + .where(eq(invitation.id, s.pendingInvitation.id)) + .get(); + const accepted = await tx + .select() + .from(invitation) + .where(eq(invitation.id, s.acceptedInvitation.id)) + .get(); + expect(pending).toBeUndefined(); + expect(accepted).toBeDefined(); + + await expectAuditRow({ + workspaceId: s.ws.id, + action: "invitation.delete", + entityType: "invitation", + entityId: s.pendingInvitation.id, + db: tx, + }); + + const acceptedAudit = await readAuditLog({ + workspaceId: s.ws.id, + entityType: "invitation", + entityId: s.acceptedInvitation.id, + db: tx, + }); + expect(acceptedAudit).toHaveLength(0); + }); + }); + + test("rejects a read-only api key actor", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + ...makeApiKeyCtx(s.ws, { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + + await expect(downgradeWorkspaceToFree({ ctx })).rejects.toBeInstanceOf( + ForbiddenError, + ); + }); + }); +}); diff --git a/packages/services/src/workspace/__tests__/workspace.test.ts b/packages/services/src/workspace/__tests__/workspace.test.ts index 576efa1b7..667735208 100644 --- a/packages/services/src/workspace/__tests__/workspace.test.ts +++ b/packages/services/src/workspace/__tests__/workspace.test.ts @@ -1,5 +1,10 @@ import { eq } from "@openstatus/db"; -import { statusReport, workspace } from "@openstatus/db/src/schema"; +import { + selectWorkspaceSchema, + statusReport, + workspace, +} from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; import { expect } from "@std/expect"; import { beforeAll, describe, test } from "@std/testing/bdd"; @@ -8,16 +13,20 @@ import { expectAuditRow, loadSeededWorkspace, makeApiKeyCtx, + makeSystemCtx, makeUserCtx, + readAuditLog, withTestTransaction, } from "../../../test/helpers"; -import type { ServiceContext } from "../../context"; +import type { DrizzleTx, ServiceContext } from "../../context"; import { ForbiddenError } from "../../errors"; import { getWorkspace, + getWorkspaceByStripeId, getWorkspaceWithUsage, listWorkspaces, updateWorkspaceName, + updateWorkspacePlan, } from "../index.ts"; let teamCtx: ServiceContext; @@ -155,3 +164,177 @@ describe("updateWorkspaceName", () => { }); }); }); + +describe("getWorkspaceByStripeId", () => { + test("resolves the workspace for a known stripe customer id", async () => { + await withTestTransaction(async (tx) => { + const inserted = await tx + .insert(workspace) + .values({ + slug: "svc-ws-by-stripe", + name: "Stripe Lookup", + plan: "team", + stripeId: "cus_svc_ws_by_stripe", + limits: JSON.stringify(getLimits("team")), + }) + .returning() + .get(); + + const result = await getWorkspaceByStripeId({ + input: { stripeId: "cus_svc_ws_by_stripe" }, + db: tx, + }); + expect(result?.id).toBe(inserted.id); + expect(typeof result?.limits).toBe("object"); + }); + }); + + test("returns null when no workspace maps to the customer", async () => { + await withTestTransaction(async (tx) => { + const result = await getWorkspaceByStripeId({ + input: { stripeId: "cus_does_not_exist" }, + db: tx, + }); + expect(result).toBeNull(); + }); + }); +}); + +async function insertPlanWorkspace( + tx: DrizzleTx, + opts: { plan: "free" | "starter" | "team" | "scale"; slug: string }, +) { + const row = await tx + .insert(workspace) + .values({ + slug: opts.slug, + name: opts.slug, + plan: opts.plan, + stripeId: `cus_${opts.slug}`, + subscriptionId: `sub_${opts.slug}`, + limits: JSON.stringify(getLimits(opts.plan)), + }) + .returning() + .get(); + return selectWorkspaceSchema.parse(row); +} + +describe("updateWorkspacePlan", () => { + test("writes the new plan + limits and audits the change", async () => { + await withTestTransaction(async (tx) => { + const ws = await insertPlanWorkspace(tx, { + plan: "team", + slug: "svc-plan-downgrade", + }); + const ctx = { + ...makeSystemCtx(ws, { job: "stripe-subscription-updated" }), + db: tx, + }; + + await updateWorkspacePlan({ + ctx, + input: { + plan: "starter", + subscriptionId: "sub_new", + paidUntil: new Date("2027-01-01T00:00:00Z"), + endsAt: new Date("2027-01-01T00:00:00Z"), + limits: getLimits("starter"), + }, + }); + + const after = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ws.id)) + .get(); + expect(after?.plan).toBe("starter"); + expect(after?.subscriptionId).toBe("sub_new"); + // Compare parsed content, not the raw string — the verb persists + // `limitsSchema`-canonicalised JSON (key order differs from the + // config object returned by `getLimits`). + expect(JSON.parse(after?.limits ?? "{}")).toEqual(getLimits("starter")); + + await expectAuditRow({ + workspaceId: ws.id, + action: "workspace.update", + entityType: "workspace", + entityId: ws.id, + actorType: "system", + db: tx, + }); + + const [audit] = await readAuditLog({ + workspaceId: ws.id, + entityType: "workspace", + entityId: ws.id, + db: tx, + }); + expect(audit?.changedFields).toContain("plan"); + // No `reason` passed → no metadata stamped. + expect(audit?.metadata).toBeNull(); + }); + }); + + test("stamps reason / from / to metadata when a reason is given", async () => { + await withTestTransaction(async (tx) => { + const ws = await insertPlanWorkspace(tx, { + plan: "free", + slug: "svc-plan-checkout", + }); + const ctx = { + ...makeSystemCtx(ws, { job: "stripe-session-completed" }), + db: tx, + }; + + await updateWorkspacePlan({ + ctx, + input: { + plan: "team", + subscriptionId: "sub_checkout", + paidUntil: new Date("2027-01-01T00:00:00Z"), + endsAt: new Date("2027-01-01T00:00:00Z"), + limits: getLimits("team"), + reason: "checkout_session_completed", + }, + }); + + const [audit] = await readAuditLog({ + workspaceId: ws.id, + entityType: "workspace", + entityId: ws.id, + db: tx, + }); + expect(audit?.metadata).toMatchObject({ + reason: "checkout_session_completed", + from: "free", + to: "team", + }); + }); + }); + + test("rejects a read-only api key actor", async () => { + await withTestTransaction(async (tx) => { + const ws = await insertPlanWorkspace(tx, { + plan: "team", + slug: "svc-plan-readonly", + }); + const ctx = { + ...makeApiKeyCtx(ws, { keyId: "k-read", userId: 1, scopes: ["read"] }), + db: tx, + }; + + await expect( + updateWorkspacePlan({ + ctx, + input: { + plan: "starter", + subscriptionId: null, + paidUntil: null, + endsAt: null, + limits: getLimits("starter"), + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); diff --git a/packages/services/src/workspace/downgrade.ts b/packages/services/src/workspace/downgrade.ts new file mode 100644 index 000000000..3c724f058 --- /dev/null +++ b/packages/services/src/workspace/downgrade.ts @@ -0,0 +1,172 @@ +import { and, asc, eq, isNull, ne } from "@openstatus/db"; +import { + invitation, + monitor, + notification, + page, + usersToWorkspaces, +} from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; + +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { deleteInvitation } from "../invitation"; +import { removeMemberInWorkspace } from "../member/internal"; +import { bulkUpdateMonitors } from "../monitor"; +import { deleteNotification } from "../notification"; +import { + deletePage, + updatePageCustomDomain, + updatePagePasswordProtection, +} from "../page"; +import { updateWorkspacePlan } from "./update"; + +/** + * Drop a workspace to the `free` plan and trim everything the free tier + * can't hold — the cascade triggered by a Stripe subscription deletion. + * Every step routes through an existing entity verb so the trim is fully + * audited (`monitor.update`, `page.delete`, `notification.delete`, + * `member.delete`, `invitation.delete`) alongside the `workspace.update` + * for the plan flip itself. One transaction: a failed audit insert rolls + * back the whole downgrade. + * + * Trim rules (unchanged from the pre-service webhook): + * - keep the oldest active monitor, deactivate the rest; + * - keep the oldest page, hard-delete the rest, and strip the survivor's + * custom domain / password / access restrictions (free has none); + * - keep one notification (email channel preferred), delete the rest; + * - remove every non-owner member; + * - delete every pending invitation. + * + * Returns the set of custom domains that were attached to any page, so the + * caller can release them on Vercel *after* the transaction commits — + * that cleanup is best-effort and must not roll the downgrade back. + */ +export async function downgradeWorkspaceToFree(args: { + ctx: ServiceContext; +}): Promise<{ customDomains: string[] }> { + const { ctx } = args; + requireScope(ctx, "write"); + const workspaceId = ctx.workspace.id; + + return withTransaction(ctx, async (tx) => { + const txCtx: ServiceContext = { ...ctx, db: tx }; + + await updateWorkspacePlan({ + ctx: txCtx, + input: { + plan: "free", + subscriptionId: null, + paidUntil: null, + endsAt: null, + limits: getLimits("free"), + reason: "subscription_deleted", + }, + }); + + const activeMonitors = await tx + .select({ id: monitor.id }) + .from(monitor) + .where( + and( + eq(monitor.workspaceId, workspaceId), + eq(monitor.active, true), + isNull(monitor.deletedAt), + ), + ) + .orderBy(asc(monitor.createdAt)); + + const monitorIdsToDeactivate = activeMonitors.slice(1).map((m) => m.id); + if (monitorIdsToDeactivate.length > 0) { + await bulkUpdateMonitors({ + ctx: txCtx, + input: { ids: monitorIdsToDeactivate, active: false }, + }); + } + + const statusPages = await tx + .select({ id: page.id, customDomain: page.customDomain }) + .from(page) + .where(eq(page.workspaceId, workspaceId)) + .orderBy(asc(page.createdAt)); + + const customDomains = [ + ...new Set( + statusPages + .map((p) => p.customDomain) + .filter((domain): domain is string => !!domain && domain !== ""), + ), + ]; + + for (const p of statusPages.slice(1)) { + await deletePage({ ctx: txCtx, input: { id: p.id } }); + } + + // Strip the surviving page's paid-only access features. Both verbs + // no-op (no audit row) when the field is already at its free value. + // `allowIndex: true` restores the free default — the `no-index` + // feature (hiding a page from search engines) is paid-only, so a + // survivor that had `allowIndex=false` must become indexable again. + const keptPage = statusPages[0]; + if (keptPage) { + await updatePageCustomDomain({ + ctx: txCtx, + input: { id: keptPage.id, customDomain: "" }, + }); + await updatePagePasswordProtection({ + ctx: txCtx, + input: { + id: keptPage.id, + accessType: "public", + password: null, + authEmailDomains: null, + allowIndex: true, + }, + }); + } + + const notifications = await tx + .select({ id: notification.id, provider: notification.provider }) + .from(notification) + .where(eq(notification.workspaceId, workspaceId)) + .orderBy(asc(notification.createdAt)); + + const keepNotification = + notifications.find((n) => n.provider === "email") ?? notifications[0]; + + for (const n of notifications) { + if (n.id === keepNotification?.id) continue; + await deleteNotification({ ctx: txCtx, input: { id: n.id } }); + } + + const nonOwnerMembers = await tx + .select({ userId: usersToWorkspaces.userId }) + .from(usersToWorkspaces) + .where( + and( + eq(usersToWorkspaces.workspaceId, workspaceId), + ne(usersToWorkspaces.role, "owner"), + ), + ); + + for (const m of nonOwnerMembers) { + await removeMemberInWorkspace({ tx, ctx: txCtx, userId: m.userId }); + } + + const pendingInvitations = await tx + .select({ id: invitation.id }) + .from(invitation) + .where( + and( + eq(invitation.workspaceId, workspaceId), + isNull(invitation.acceptedAt), + ), + ); + + for (const inv of pendingInvitations) { + await deleteInvitation({ ctx: txCtx, input: { id: inv.id } }); + } + + return { customDomains }; + }); +} diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts index ea6109cff..4e8e953ff 100644 --- a/packages/services/src/workspace/index.ts +++ b/packages/services/src/workspace/index.ts @@ -1,14 +1,18 @@ export { getWorkspace, + getWorkspaceByStripeId, getWorkspaceWithUsage, listWorkspaces, type WorkspaceUsage, type WorkspaceWithUsage, } from "./list"; -export { updateWorkspaceName } from "./update"; +export { downgradeWorkspaceToFree } from "./downgrade"; +export { updateWorkspaceName, updateWorkspacePlan } from "./update"; export { + GetWorkspaceByStripeIdInput, GetWorkspaceInput, GetWorkspaceWithUsageInput, ListWorkspacesInput, UpdateWorkspaceNameInput, + UpdateWorkspacePlanInput, } from "./schemas"; diff --git a/packages/services/src/workspace/list.ts b/packages/services/src/workspace/list.ts index ba9f45214..6606a7208 100644 --- a/packages/services/src/workspace/list.ts +++ b/packages/services/src/workspace/list.ts @@ -6,10 +6,11 @@ import { workspace, } from "@openstatus/db/src/schema"; -import type { ServiceContext } from "../context"; +import type { DB, ServiceContext } from "../context"; import { NotFoundError } from "../errors"; import type { Workspace } from "../types"; import { + GetWorkspaceByStripeIdInput, type GetWorkspaceWithUsageInput, ListWorkspacesInput, } from "./schemas"; @@ -97,6 +98,28 @@ export async function getWorkspaceWithUsage(args: { return { ...selectWorkspaceSchema.parse(result), usage }; } +/** + * Resolve a workspace by its Stripe customer id. Runs before a + * `ctx.workspace` exists (the Stripe webhook only holds the customer id), + * so it takes an optional `db`/tx rather than a `ServiceContext`. Returns + * `null` when no workspace maps to the customer — an expected case (an + * event for a customer we don't own), which the caller maps to its own + * error rather than a thrown `NotFoundError`. + */ +export async function getWorkspaceByStripeId(args: { + input: GetWorkspaceByStripeIdInput; + db?: DB; +}): Promise { + const { stripeId } = GetWorkspaceByStripeIdInput.parse(args.input); + const db = args.db ?? defaultDb; + + const row = await db.query.workspace.findFirst({ + where: eq(workspace.stripeId, stripeId), + }); + + return row ? selectWorkspaceSchema.parse(row) : null; +} + /** * Workspaces the given user belongs to. Called before `ctx.workspace` is * meaningful (list runs across every workspace the user has access to), so diff --git a/packages/services/src/workspace/schemas.ts b/packages/services/src/workspace/schemas.ts index 300c48107..33917227a 100644 --- a/packages/services/src/workspace/schemas.ts +++ b/packages/services/src/workspace/schemas.ts @@ -1,3 +1,5 @@ +import { workspacePlanSchema } from "@openstatus/db/src/schema"; +import { limitsSchema } from "@openstatus/db/src/schema/plan/schema"; import { z } from "zod"; export const GetWorkspaceInput = z.object({}).strict(); @@ -11,7 +13,32 @@ export type GetWorkspaceWithUsageInput = z.infer< export const ListWorkspacesInput = z.object({ userId: z.number().int() }); export type ListWorkspacesInput = z.infer; +export const GetWorkspaceByStripeIdInput = z.object({ + stripeId: z.string().min(1), +}); +export type GetWorkspaceByStripeIdInput = z.infer< + typeof GetWorkspaceByStripeIdInput +>; + export const UpdateWorkspaceNameInput = z.object({ name: z.string().min(1), }); export type UpdateWorkspaceNameInput = z.infer; + +/** + * Set a workspace's billing plan and the columns that move with it + * (subscription id, paid-until / ends-at dates, feature limits). Driven + * by the Stripe webhook — `limits` is the structured object; the verb + * serialises it to the `text` column. `reason` is stamped into the audit + * row's `metadata` so a plan change from an involuntary cancellation is + * distinguishable from a checkout upgrade. + */ +export const UpdateWorkspacePlanInput = z.object({ + plan: workspacePlanSchema, + subscriptionId: z.string().nullable(), + paidUntil: z.date().nullable(), + endsAt: z.date().nullable(), + limits: limitsSchema, + reason: z.string().optional(), +}); +export type UpdateWorkspacePlanInput = z.infer; diff --git a/packages/services/src/workspace/update.ts b/packages/services/src/workspace/update.ts index 6c670fd71..1d1be03e5 100644 --- a/packages/services/src/workspace/update.ts +++ b/packages/services/src/workspace/update.ts @@ -5,7 +5,7 @@ import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; import { NotFoundError } from "../errors"; -import { UpdateWorkspaceNameInput } from "./schemas"; +import { UpdateWorkspaceNameInput, UpdateWorkspacePlanInput } from "./schemas"; /** * Rename the caller's workspace. No conflict check — workspace names are @@ -46,3 +46,59 @@ export async function updateWorkspaceName(args: { }); }); } + +/** + * Set the workspace's plan and the billing columns that move with it. + * The single audit `workspace.update` row carries the plan flip in + * `changed_fields`; a `reason` (e.g. `"subscription_deleted"`) is stamped + * into `metadata` so an involuntary downgrade reads differently from a + * checkout upgrade. + */ +export async function updateWorkspacePlan(args: { + ctx: ServiceContext; + input: UpdateWorkspacePlanInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = UpdateWorkspacePlanInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ctx.workspace.id)) + .get(); + if (!existing) throw new NotFoundError("workspace", ctx.workspace.id); + + const updated = await tx + .update(workspace) + .set({ + plan: input.plan, + subscriptionId: input.subscriptionId, + paidUntil: input.paidUntil, + endsAt: input.endsAt, + limits: JSON.stringify(input.limits), + updatedAt: new Date(), + }) + .where(eq(workspace.id, ctx.workspace.id)) + .returning() + .get(); + + const metadata = input.reason + ? { + reason: input.reason, + from: existing.plan ?? "free", + to: input.plan, + } + : undefined; + + await emitAudit(tx, ctx, { + action: "workspace.update", + entityType: "workspace", + entityId: ctx.workspace.id, + before: existing, + after: updated, + metadata, + }); + }); +} -- 2.51.2 From e458dbeea375a0b0d3ae2ca20ed7124947a7d03e Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Tue, 21 Jul 2026 17:12:31 +0000 Subject: [PATCH 002/109] private location: typo and bug refresh (#2407) * private location: typo and bug refresh * fix pr --- apps/checker/pkg/scheduler/scheduler.go | 334 ++++++++++-------- apps/checker/pkg/scheduler/scheduler_test.go | 80 +++++ .../pages/docs/concept/private-locations.mdx | 2 +- .../docs/concept/probes-and-locations.mdx | 2 +- .../guides/how-to-create-private-location.mdx | 8 +- 5 files changed, 271 insertions(+), 155 deletions(-) diff --git a/apps/checker/pkg/scheduler/scheduler.go b/apps/checker/pkg/scheduler/scheduler.go index 7b5e3860b..c9c52a956 100644 --- a/apps/checker/pkg/scheduler/scheduler.go +++ b/apps/checker/pkg/scheduler/scheduler.go @@ -1,6 +1,7 @@ package scheduler import ( + "bytes" "context" "log" "sync" @@ -10,6 +11,7 @@ import ( "github.com/madflojo/tasks" "github.com/openstatushq/openstatus/apps/checker/pkg/job" v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" + "google.golang.org/protobuf/proto" ) const ( @@ -27,6 +29,40 @@ type MonitorManager struct { JobRunner job.JobRunner Scheduler *tasks.Scheduler mu sync.Mutex + configs map[string][]byte +} + +// shouldSchedule reports whether a task has to be created for the monitor, and +// drops the running one first when the config changed: a task captures its +// monitor when it is created, so an edited monitor would otherwise keep +// checking with the config it had on the probe's first fetch. +func (mm *MonitorManager) shouldSchedule(id string, monitor proto.Message) bool { + mm.mu.Lock() + defer mm.mu.Unlock() + + if mm.configs == nil { + mm.configs = make(map[string][]byte) + } + + config, err := proto.MarshalOptions{Deterministic: true}.Marshal(monitor) + if err != nil { + log.Printf("Failed to encode config for monitor %s: %v", id, err) + config = nil + } + + if _, lookupErr := mm.Scheduler.Lookup(id); lookupErr != nil { + mm.configs[id] = config + return true + } + + if bytes.Equal(mm.configs[id], config) { + return false + } + + log.Printf("Config changed for monitor %s, rescheduling", id) + mm.Scheduler.Del(id) + mm.configs[id] = config + return true } // UpdateMonitors fetches the latest monitors and starts/stops jobs as needed @@ -42,176 +78,176 @@ func (mm *MonitorManager) UpdateMonitors(ctx context.Context) { // HTTP monitors: start jobs for new monitors for _, m := range res.Msg.HttpMonitors { currentIDs[m.Id] = struct{}{} - _, err := mm.Scheduler.Lookup(m.Id) - if err != nil { - interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second - task := tasks.Task{ - Interval: interval, - RunOnce: false, - RunSingleInstance: true, - // StartAfter: time.Duration(1) * time.Second, - ErrFunc: func(e error) { - log.Printf("An error occurred when executing task %s", e) - }, - FuncWithTaskContext: func(ctx tasks.TaskContext) error { - monitor := m - c := context.Background() - log.Printf("Starting job for monitor %s (%s)", monitor.Id, monitor.Url) - data, err := mm.JobRunner.HTTPJob(c, monitor, res.Msg.Region) - - if err != nil { - log.Printf("Monitor check failed for %s (%s): %v", monitor.Id, monitor.Url, err) - return err - } - resp, ingestErr := mm.Client.IngestHTTP(c, &connect.Request[v1.IngestHTTPRequest]{ - Msg: &v1.IngestHTTPRequest{ - MonitorId: monitor.Id, - Id: data.ID, - Url: monitor.Url, - Message: data.Message, - Latency: data.Latency, - Timing: data.Timing, - Headers: data.Headers, - Body: data.Body, - RequestStatus: data.RequestStatus, - StatusCode: int64(data.StatusCode), - Error: int64(data.Error), - CronTimestamp: data.CronTimestamp, - Timestamp: data.Timestamp, - }, - }) - if ingestErr != nil { - log.Printf("Failed to ingest HTTP result for %s (%s): %v", monitor.Id, monitor.Url, ingestErr) - return ingestErr - } - log.Printf("Monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Url, resp) - return nil - }, - } - - err := mm.Scheduler.AddWithID(m.Id, &task) - - if err != nil { - log.Printf("Failed to add HTTP monitor job for %s (%s): %v", m.Id, m.Url, err) - continue - } - log.Printf("Started monitoring job for %s (%s)", m.Id, m.Url) + if !mm.shouldSchedule(m.Id, m) { continue } + interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second + task := tasks.Task{ + Interval: interval, + RunOnce: false, + RunSingleInstance: true, + // StartAfter: time.Duration(1) * time.Second, + ErrFunc: func(e error) { + log.Printf("An error occurred when executing task %s", e) + }, + FuncWithTaskContext: func(ctx tasks.TaskContext) error { + monitor := m + c := context.Background() + log.Printf("Starting job for monitor %s (%s)", monitor.Id, monitor.Url) + data, err := mm.JobRunner.HTTPJob(c, monitor, res.Msg.Region) + + if err != nil { + log.Printf("Monitor check failed for %s (%s): %v", monitor.Id, monitor.Url, err) + return err + } + resp, ingestErr := mm.Client.IngestHTTP(c, &connect.Request[v1.IngestHTTPRequest]{ + Msg: &v1.IngestHTTPRequest{ + MonitorId: monitor.Id, + Id: data.ID, + Url: monitor.Url, + Message: data.Message, + Latency: data.Latency, + Timing: data.Timing, + Headers: data.Headers, + Body: data.Body, + RequestStatus: data.RequestStatus, + StatusCode: int64(data.StatusCode), + Error: int64(data.Error), + CronTimestamp: data.CronTimestamp, + Timestamp: data.Timestamp, + }, + }) + if ingestErr != nil { + log.Printf("Failed to ingest HTTP result for %s (%s): %v", monitor.Id, monitor.Url, ingestErr) + return ingestErr + } + log.Printf("Monitor check for %s (%s) ingested with status %q (code %d), ingest response: %v", monitor.Id, monitor.Url, data.RequestStatus, data.StatusCode, resp) + return nil + }, + } + + if err := mm.Scheduler.AddWithID(m.Id, &task); err != nil { + log.Printf("Failed to add HTTP monitor job for %s (%s): %v", m.Id, m.Url, err) + continue + } + log.Printf("Started monitoring job for %s (%s)", m.Id, m.Url) } // TCP monitors: start jobs for new monitors for _, m := range res.Msg.TcpMonitors { currentIDs[m.Id] = struct{}{} - _, err := mm.Scheduler.Lookup(m.Id) - if err != nil { - - interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second - task := tasks.Task{ - Interval: interval, - RunOnce: false, - // StartAfter: time.Now().Add(5 * time.Millisecond), - RunSingleInstance: true, - FuncWithTaskContext: func(ctx tasks.TaskContext) error { - - monitor := m - c := context.Background() - log.Printf("Starting TCP job for monitor %s (%s)", monitor.Id, monitor.Uri) - data, err := mm.JobRunner.TCPJob(c, monitor, res.Msg.Region) - if err != nil { - log.Printf("TCP monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) - } - resp, ingestErr := mm.Client.IngestTCP(c, &connect.Request[v1.IngestTCPRequest]{ - Msg: &v1.IngestTCPRequest{ - MonitorId: monitor.Id, - Id: data.ID, - Uri: monitor.Uri, - Message: data.Message, - Latency: data.Latency, - RequestStatus: data.RequestStatus, - Error: int64(data.Error), - CronTimestamp: data.CronTimestamp, - Timestamp: data.Timestamp, - }, - }) - if ingestErr != nil { - log.Printf("Failed to ingest TCP result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) - return ingestErr - } - log.Printf("TCP monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Uri, resp) - - return nil - }, - } - err := mm.Scheduler.AddWithID(m.Id, &task) - - if err != nil { - log.Printf("Failed to add TCP monitor job for %s (%s): %v", m.Id, m.Uri, err) - continue - } - log.Printf("Started TCP monitoring job for %s (%s)", m.Id, m.Uri) + if !mm.shouldSchedule(m.Id, m) { + continue + } + + interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second + task := tasks.Task{ + Interval: interval, + RunOnce: false, + // StartAfter: time.Now().Add(5 * time.Millisecond), + RunSingleInstance: true, + FuncWithTaskContext: func(ctx tasks.TaskContext) error { + + monitor := m + c := context.Background() + log.Printf("Starting TCP job for monitor %s (%s)", monitor.Id, monitor.Uri) + data, err := mm.JobRunner.TCPJob(c, monitor, res.Msg.Region) + if err != nil { + log.Printf("TCP monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) + return err + } + resp, ingestErr := mm.Client.IngestTCP(c, &connect.Request[v1.IngestTCPRequest]{ + Msg: &v1.IngestTCPRequest{ + MonitorId: monitor.Id, + Id: data.ID, + Uri: monitor.Uri, + Message: data.Message, + Latency: data.Latency, + RequestStatus: data.RequestStatus, + Error: int64(data.Error), + CronTimestamp: data.CronTimestamp, + Timestamp: data.Timestamp, + }, + }) + if ingestErr != nil { + log.Printf("Failed to ingest TCP result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) + return ingestErr + } + log.Printf("TCP monitor check for %s (%s) ingested with status %q, ingest response: %v", monitor.Id, monitor.Uri, data.RequestStatus, resp) + + return nil + }, } + + if err := mm.Scheduler.AddWithID(m.Id, &task); err != nil { + log.Printf("Failed to add TCP monitor job for %s (%s): %v", m.Id, m.Uri, err) + continue + } + log.Printf("Started TCP monitoring job for %s (%s)", m.Id, m.Uri) } for _, m := range res.Msg.DnsMonitors { currentIDs[m.Id] = struct{}{} - _, err := mm.Scheduler.Lookup(m.Id) - if err != nil { - - interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second - task := tasks.Task{ - Interval: interval, - RunOnce: false, - // StartAfter: time.Now().Add(5 * time.Millisecond), - RunSingleInstance: true, - FuncWithTaskContext: func(ctx tasks.TaskContext) error { - - monitor := m - c := context.Background() - log.Printf("Starting TCP job for monitor %s (%s)", monitor.Id, monitor.Uri) - _, err := mm.JobRunner.DNSJob(c, monitor) - if err != nil { - log.Printf("TCP monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) - } - resp, ingestErr := mm.Client.IngestDNS(c, &connect.Request[v1.IngestDNSRequest]{ - Msg: &v1.IngestDNSRequest{ - MonitorId: monitor.Id, - - // Id: data.ID, - // Uri: monitor.Uri, - // Message: data.Message, - // Latency: data.Latency, - // RequestStatus: data.RequestStatus, - // Error: int64(data.Error), - // CronTimestamp: data.CronTimestamp, - // Timestamp: data.Timestamp, - }, - }) - if ingestErr != nil { - log.Printf("Failed to ingest TCP result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) - return ingestErr - } - log.Printf("TCP monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Uri, resp) - - return nil - }, - } - err := mm.Scheduler.AddWithID(m.Id, &task) - - if err != nil { - log.Printf("Failed to add TCP monitor job for %s (%s): %v", m.Id, m.Uri, err) - continue - } - log.Printf("Started TCP monitoring job for %s (%s)", m.Id, m.Uri) + if !mm.shouldSchedule(m.Id, m) { + continue + } + + interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second + task := tasks.Task{ + Interval: interval, + RunOnce: false, + // StartAfter: time.Now().Add(5 * time.Millisecond), + RunSingleInstance: true, + FuncWithTaskContext: func(ctx tasks.TaskContext) error { + + monitor := m + c := context.Background() + log.Printf("Starting DNS job for monitor %s (%s)", monitor.Id, monitor.Uri) + _, err := mm.JobRunner.DNSJob(c, monitor) + if err != nil { + log.Printf("DNS monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) + return err + } + resp, ingestErr := mm.Client.IngestDNS(c, &connect.Request[v1.IngestDNSRequest]{ + Msg: &v1.IngestDNSRequest{ + MonitorId: monitor.Id, + + // Id: data.ID, + // Uri: monitor.Uri, + // Message: data.Message, + // Latency: data.Latency, + // RequestStatus: data.RequestStatus, + // Error: int64(data.Error), + // CronTimestamp: data.CronTimestamp, + // Timestamp: data.Timestamp, + }, + }) + if ingestErr != nil { + log.Printf("Failed to ingest DNS result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) + return ingestErr + } + log.Printf("DNS monitor check for %s (%s) ingested, ingest response: %v", monitor.Id, monitor.Uri, resp) + + return nil + }, + } + + if err := mm.Scheduler.AddWithID(m.Id, &task); err != nil { + log.Printf("Failed to add DNS monitor job for %s (%s): %v", m.Id, m.Uri, err) + continue } + log.Printf("Started DNS monitoring job for %s (%s)", m.Id, m.Uri) } + mm.mu.Lock() for id := range mm.Scheduler.Tasks() { if _, stillExists := currentIDs[id]; !stillExists { mm.Scheduler.Del(id) + delete(mm.configs, id) } } + mm.mu.Unlock() } diff --git a/apps/checker/pkg/scheduler/scheduler_test.go b/apps/checker/pkg/scheduler/scheduler_test.go index e70aeed22..0d86d3150 100644 --- a/apps/checker/pkg/scheduler/scheduler_test.go +++ b/apps/checker/pkg/scheduler/scheduler_test.go @@ -23,15 +23,23 @@ type mockJobRunner struct { mu sync.Mutex httpRegion string tcpRegion string + httpMonitor *v1.HTTPMonitor } func (m *mockJobRunner) HTTPJob(ctx context.Context, monitor *v1.HTTPMonitor, region string) (*job.HttpPrivateRegionData, error) { m.HTTPJobCalled.Store(true) m.mu.Lock() m.httpRegion = region + m.httpMonitor = monitor m.mu.Unlock() return &job.HttpPrivateRegionData{}, nil } + +func (m *mockJobRunner) HTTPMonitor() *v1.HTTPMonitor { + m.mu.Lock() + defer m.mu.Unlock() + return m.httpMonitor +} func (m *mockJobRunner) TCPJob(ctx context.Context, monitor *v1.TCPMonitor, region string) (*job.TCPPrivateRegionData, error) { m.TCPJobCalled.Store(true) @@ -158,3 +166,75 @@ func TestMonitorManager_StartAndStopJobs_WithJobRunner(t *testing.T) { } } + +// runScheduledTask executes an already scheduled task synchronously, so a test +// can observe the monitor config its closure captured without waiting for the +// interval to elapse. +func runScheduledTask(t *testing.T, s *tasks.Scheduler, id string) { + t.Helper() + + task, err := s.Lookup(id) + if err != nil { + t.Fatalf("expected a task scheduled for %s: %v", id, err) + } + if err := task.FuncWithTaskContext(tasks.TaskContext{}); err != nil { + t.Fatalf("task %s returned an error: %v", id, err) + } +} + +func TestMonitorManager_ReschedulesOnConfigChange(t *testing.T) { + ctx := t.Context() + + // Long periodicity: the task only runs when the test invokes it. + withoutHeader := &v1.HTTPMonitor{Id: "http1", Url: "https://openstat.us", Periodicity: "1h"} + unchanged := &v1.HTTPMonitor{Id: "http1", Url: "https://openstat.us", Periodicity: "1h"} + withHeader := &v1.HTTPMonitor{ + Id: "http1", Url: "https://openstat.us", Periodicity: "1h", + Headers: []*v1.Headers{{Key: "X-Auth-Token", Value: "secret"}}, + } + + current := withoutHeader + client := &mockClient{ + MonitorsFunc: func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { + return connect.NewResponse(&v1.MonitorsResponse{ + HttpMonitors: []*v1.HTTPMonitor{current}, + Region: "frankfurt-dc1", + }), nil + }, + IngestHTTPFunc: func(ctx context.Context, req *connect.Request[v1.IngestHTTPRequest]) (*connect.Response[v1.IngestHTTPResponse], error) { + return connect.NewResponse(&v1.IngestHTTPResponse{}), nil + }, + } + jobRunner := &mockJobRunner{} + + s := tasks.New() + defer s.Stop() + + mm := &scheduler.MonitorManager{Client: client, JobRunner: jobRunner, Scheduler: s} + + mm.UpdateMonitors(ctx) + runScheduledTask(t, mm.Scheduler, "http1") + if got := jobRunner.HTTPMonitor(); got != withoutHeader { + t.Fatalf("expected the job to run with the fetched monitor, got %v", got) + } + + // Same config, fresh pointer: rescheduling here would reset the interval timer. + current = unchanged + mm.UpdateMonitors(ctx) + runScheduledTask(t, mm.Scheduler, "http1") + if got := jobRunner.HTTPMonitor(); got != withoutHeader { + t.Errorf("expected an unchanged monitor to keep its task, got a rescheduled one") + } + + current = withHeader + mm.UpdateMonitors(ctx) + runScheduledTask(t, mm.Scheduler, "http1") + + got := jobRunner.HTTPMonitor() + if got != withHeader { + t.Fatalf("expected the job to run with the updated monitor, got %v", got) + } + if len(got.Headers) != 1 || got.Headers[0].Key != "X-Auth-Token" || got.Headers[0].Value != "secret" { + t.Errorf("expected the added header to reach the job, got %v", got.Headers) + } +} diff --git a/apps/web/src/content/pages/docs/concept/private-locations.mdx b/apps/web/src/content/pages/docs/concept/private-locations.mdx index 6b02a6009..a1f955ace 100644 --- a/apps/web/src/content/pages/docs/concept/private-locations.mdx +++ b/apps/web/src/content/pages/docs/concept/private-locations.mdx @@ -25,7 +25,7 @@ If your endpoint is already public and you only want geographic coverage, the [p A private location is not an inbound port openstatus calls into. The probe initiates everything: -1. You deploy the openstatus probe container — typically `ghcr.io/openstatushq/probe:latest` — somewhere inside the network that needs to be monitored. +1. You deploy the openstatus probe container — typically `ghcr.io/openstatushq/private-location:latest` — somewhere inside the network that needs to be monitored. 2. The probe opens an outbound, authenticated connection to the openstatus platform using the token issued when you created the location. 3. openstatus dispatches the checks assigned to that location down the connection. The probe runs them against your internal endpoints and streams the results back. diff --git a/apps/web/src/content/pages/docs/concept/probes-and-locations.mdx b/apps/web/src/content/pages/docs/concept/probes-and-locations.mdx index 5617c997d..0835cfec3 100644 --- a/apps/web/src/content/pages/docs/concept/probes-and-locations.mdx +++ b/apps/web/src/content/pages/docs/concept/probes-and-locations.mdx @@ -17,7 +17,7 @@ That's the relationship in shorthand. The rest of this page unpacks each term an A **probe** is the software that actually performs a check. It opens the TCP connection, sends the HTTP request, resolves the DNS record, measures the timing — and reports the result back. There are two kinds: - **Public probes.** Operated by openstatus, deployed across Fly.io, Railway, and Koyeb regions. You don't manage them. -- **Private probes.** A container image (`ghcr.io/openstatushq/probe:latest`) you run on your own infrastructure. See [Understanding Private Locations](/docs/concept/private-locations). +- **Private probes.** A container image (`ghcr.io/openstatushq/private-location:latest`) you run on your own infrastructure. See [Understanding Private Locations](/docs/concept/private-locations). In day-to-day usage you rarely interact with the "probe" abstraction directly — you assign a *location* to a monitor, and the location decides which probe runs the check. The word matters mostly when you're reading debug output, opening a support ticket, or deploying a private location. diff --git a/apps/web/src/content/pages/docs/guides/how-to-create-private-location.mdx b/apps/web/src/content/pages/docs/guides/how-to-create-private-location.mdx index 3181a942b..0e31f5699 100644 --- a/apps/web/src/content/pages/docs/guides/how-to-create-private-location.mdx +++ b/apps/web/src/content/pages/docs/guides/how-to-create-private-location.mdx @@ -37,7 +37,7 @@ docker run -d \ --name openstatus-probe \ --restart unless-stopped \ -e OPENSTATUS_TOKEN= \ - ghcr.io/openstatushq/probe:latest + ghcr.io/openstatushq/private-location:latest ``` Replace `` with the token from Step 1. @@ -51,8 +51,8 @@ docker ps --filter name=openstatus-probe You should see the container listed with a status of `Up`: ``` -CONTAINER ID IMAGE STATUS NAMES -abc123 ghcr.io/openstatushq/probe:latest Up 2 minutes openstatus-probe +CONTAINER ID IMAGE STATUS NAMES +abc123 ghcr.io/openstatushq/private-location:latest Up 2 minutes openstatus-probe ``` ## Step 3: assign monitors to your private location @@ -105,7 +105,7 @@ docker run -d \ --restart unless-stopped \ --network host \ -e OPENSTATUS_TOKEN= \ - ghcr.io/openstatushq/probe:latest + ghcr.io/openstatushq/private-location:latest ``` ## What's next -- 2.51.2 From f57d36e13b30170524a2689a480289cc52997520 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:59:10 +0200 Subject: [PATCH 003/109] feat: status-page monitor latency badge (#2392) * feat: status-page monitor latency badge * fix: format * fix: missing truncate on header left * style: css * fix: last day typo * fix: badge * fix: focus ring * fix: lowercase * fix: translations --- apps/status-page/messages/de.json | 3 +- apps/status-page/messages/en.json | 3 +- apps/status-page/messages/fr.json | 3 +- apps/status-page/messages/hi.json | 3 +- apps/status-page/messages/ko.json | 3 +- apps/status-page/messages/tr.json | 3 +- .../[domain]/[locale]/(public)/client.tsx | 74 +++++++++++++++++-- apps/status-page/src/data/metrics.client.ts | 21 ++++++ apps/status-page/src/lib/formatter.ts | 1 + .../components/blocks/status-component.tsx | 50 ++++++++++++- 10 files changed, 148 insertions(+), 16 deletions(-) diff --git a/apps/status-page/messages/de.json b/apps/status-page/messages/de.json index 54db17e6c..f34b08225 100644 --- a/apps/status-page/messages/de.json +++ b/apps/status-page/messages/de.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "Erhalten Sie E-Mail-Benachrichtigungen, wenn ein Bericht erstellt oder gelöst wird", "gczcC5": "Abonnieren", - "45YlLU": "Bestätigen Sie Ihre E-Mail-Adresse, um Updates zu erhalten, und schon sind Sie fertig." + "45YlLU": "Bestätigen Sie Ihre E-Mail-Adresse, um Updates zu erhalten, und schon sind Sie fertig.", + "dcqxLR": "letzter Tag" } diff --git a/apps/status-page/messages/en.json b/apps/status-page/messages/en.json index 3c3fdc8a9..12a25ad4e 100644 --- a/apps/status-page/messages/en.json +++ b/apps/status-page/messages/en.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "Get email notifications whenever a report has been created or resolved", "gczcC5": "Subscribe", - "45YlLU": "Validate your email to receive updates and you are all set." + "45YlLU": "Validate your email to receive updates and you are all set.", + "dcqxLR": "last day" } diff --git a/apps/status-page/messages/fr.json b/apps/status-page/messages/fr.json index 3fc108553..548bd3d0b 100644 --- a/apps/status-page/messages/fr.json +++ b/apps/status-page/messages/fr.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "Recevez des notifications par email à chaque création ou résolution d'un rapport", "gczcC5": "S'abonner", - "45YlLU": "Validez votre email pour recevoir les mises à jour et le tour est joué." + "45YlLU": "Validez votre email pour recevoir les mises à jour et le tour est joué.", + "dcqxLR": "dernier jour" } diff --git a/apps/status-page/messages/hi.json b/apps/status-page/messages/hi.json index dabb72489..80753c36a 100644 --- a/apps/status-page/messages/hi.json +++ b/apps/status-page/messages/hi.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "जब भी कोई रिपोर्ट बनाई जाती है या समाधान की जाती है तो ईमेल सूचनाएं प्राप्त करें", "gczcC5": "सदस्यता लें", - "45YlLU": "अपडेट प्राप्त करने के लिए अपने ईमेल की पुष्टि करें और आप तैयार हैं।" + "45YlLU": "अपडेट प्राप्त करने के लिए अपने ईमेल की पुष्टि करें और आप तैयार हैं।", + "dcqxLR": "पिछला दिन" } diff --git a/apps/status-page/messages/ko.json b/apps/status-page/messages/ko.json index 741c937ce..6263a9b06 100644 --- a/apps/status-page/messages/ko.json +++ b/apps/status-page/messages/ko.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "리포트가 생성되거나 해결될 때마다 이메일 알림을 받으세요", "gczcC5": "구독", - "45YlLU": "이메일을 인증하면 업데이트를 받을 준비가 완료됩니다." + "45YlLU": "이메일을 인증하면 업데이트를 받을 준비가 완료됩니다.", + "dcqxLR": "지난 하루" } diff --git a/apps/status-page/messages/tr.json b/apps/status-page/messages/tr.json index 5d13c54ec..15efc19e5 100644 --- a/apps/status-page/messages/tr.json +++ b/apps/status-page/messages/tr.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "Bir rapor oluşturulduğunda veya çözüldüğünde e-posta bildirimi alın", "gczcC5": "Abone Ol", - "45YlLU": "Güncellemeleri almak için e-posta adresinizi doğrulayın, hepsi bu kadar." + "45YlLU": "Güncellemeleri almak için e-posta adresinizi doğrulayın, hepsi bu kadar.", + "dcqxLR": "son gün" } diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/client.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/client.tsx index 0dc501745..e2acf3cf2 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/client.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/client.tsx @@ -9,6 +9,8 @@ import { StatusComponentHeaderLeft, StatusComponentHeaderRight, StatusComponentIcon, + StatusComponentLatency, + StatusComponentLatencySkeleton, StatusComponentStatus, StatusComponentTitle, StatusComponentUptime, @@ -24,6 +26,7 @@ import { import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; import { skipToken, useQuery } from "@tanstack/react-query"; +import { useExtracted } from "next-intl"; import { notFound, useParams } from "next/navigation"; import { useMemo } from "react"; @@ -50,6 +53,7 @@ import { StatusEventTimelineReportUpdate, } from "../../../../../components/status-page/status-events"; import { StatusFeed } from "../../../../../components/status-page/status-feed"; +import { latencyByMonitorId } from "../../../../../data/metrics.client"; import { useEmbed } from "../../../../../hooks/use-embed"; import { usePathnamePrefix } from "../../../../../hooks/use-pathname-prefix"; import { updatesWithImpactChanges } from "../../../../../lib/report-impacts"; @@ -61,6 +65,7 @@ export function Client() { const { cardType, barType, showUptime, numberOfDays } = useStatusPage(); const embed = useEmbed(); const trpc = useTRPC(); + const t = useExtracted(); // NOTE: we cannot use `cardType` and `barType` here because of queryKey changes // It wouldn't match the server prefetch keys and we would have to refetch the page here @@ -117,6 +122,37 @@ export function Client() { ), ); + // Non-blocking latency read for the per-monitor "p75 ms" tracker chip. Reuses + // the public monitors query (already registers the multi-latency pipe) so the + // tracker rows never wait on it — the chip fills in once resolved. + const { data: monitorsLatency, isFetching: isLatencyFetching } = useQuery( + trpc.statusPage.getMonitors.queryOptions( + componentsVisible && pageInitial && pageInitial.pageComponents.length > 0 + ? { slug: domain } + : skipToken, + ), + ); + const latencyMap = useMemo( + () => latencyByMonitorId(monitorsLatency, "p75"), + [monitorsLatency], + ); + const latencyPending = !monitorsLatency && isLatencyFetching; + + // Only public monitors get a chip: private monitors aren't in `getMonitors` + // (value never arrives) and static components have no monitor. + const buildLatency = ( + row: NonNullable[number] | undefined, + ) => { + if (!row?.monitor?.public) return undefined; + const monitorId = row.monitor.id.toString(); + return { + label: t("last day"), + isLoading: latencyPending, + value: latencyMap.get(monitorId), + href: `${prefix ? `/${prefix}` : ""}/monitors/${monitorId}`, + }; + }; + // NOTE: we need to filter out the incidents as we don't want to show all of them in the banner - a single one is enough // REMINDER: we could move that to the server - but we might wanna have the info of all openEvents actually const events = useMemo(() => { @@ -279,9 +315,10 @@ export function Client() { {page.trackers.map((tracker) => { if (tracker.type === "component") { const component = tracker.component; - const { data, uptime } = - uptimeData?.find((u) => u.pageComponentId === component.id) ?? - {}; + const uptimeRow = uptimeData?.find( + (u) => u.pageComponentId === component.id, + ); + const { data, uptime } = uptimeRow ?? {}; return ( ); } @@ -305,10 +343,10 @@ export function Client() { defaultOpen={tracker.defaultOpen} > {tracker.components.map((component) => { - const { data, uptime } = - uptimeData?.find( - (u) => u.pageComponentId === component.id, - ) ?? {}; + const uptimeRow = uptimeData?.find( + (u) => u.pageComponentId === component.id, + ); + const { data, uptime } = uptimeRow ?? {}; return ( ); })} @@ -377,6 +416,7 @@ function ComponentCard({ uptime, showUptime, isLoading, + latency, }: { name: string; description?: string | null; @@ -385,12 +425,32 @@ function ComponentCard({ uptime?: string; showUptime?: boolean; isLoading?: boolean; + latency?: { label: string; isLoading: boolean; value?: string; href: string }; }) { return ( {name} + {latency ? ( + latency.value ? ( + + + + {latency.label} + + {latency.value} + p75 + + + ) : latency.isLoading ? ( + + ) : null + ) : null} {description} diff --git a/apps/status-page/src/data/metrics.client.ts b/apps/status-page/src/data/metrics.client.ts index 9db66b9e0..c7e9dfd2c 100644 --- a/apps/status-page/src/data/metrics.client.ts +++ b/apps/status-page/src/data/metrics.client.ts @@ -310,6 +310,27 @@ export function mapLatency( }); } +// Reduce each public monitor's latency time-series to a single averaged +// percentile, keyed by monitorId — for the status-page tracker latency chip. +export function latencyByMonitorId( + monitors: RouterOutputs["statusPage"]["getMonitors"] = [], + percentile: (typeof PERCENTILES)[number] = "p75", +) { + const field = PERCENTILE_MAP[percentile]; + const map = new Map(); + for (const monitor of monitors ?? []) { + const points = + monitor.data + ?.map((metric) => metric[field]) + .filter((value): value is number => value != null) ?? []; + if (points.length === 0) continue; + const avg = points.reduce((sum, value) => sum + value, 0) / points.length; + // compact chip: drop the unit space ("117 ms" -> "117ms") + map.set(monitor.id.toString(), formatMilliseconds(avg).replace(" ", "")); + } + return map; +} + export function mapTimingPhases( timingPhases: RouterOutputs["tinybird"]["metricsTimingPhases"], percentile: (typeof PERCENTILES)[number], diff --git a/apps/status-page/src/lib/formatter.ts b/apps/status-page/src/lib/formatter.ts index e5e554f57..7a8f7c872 100644 --- a/apps/status-page/src/lib/formatter.ts +++ b/apps/status-page/src/lib/formatter.ts @@ -13,6 +13,7 @@ export function formatMilliseconds(ms: number) { return `${Intl.NumberFormat("en-US", { style: "unit", unit: "millisecond", + maximumFractionDigits: 0, }).format(ms)}`; } diff --git a/packages/ui/src/components/blocks/status-component.tsx b/packages/ui/src/components/blocks/status-component.tsx index a43c992b6..00cc144e6 100644 --- a/packages/ui/src/components/blocks/status-component.tsx +++ b/packages/ui/src/components/blocks/status-component.tsx @@ -16,7 +16,7 @@ import { import { useMediaQuery } from "@openstatus/ui/hooks/use-media-query"; import { cn } from "@openstatus/ui/lib/utils"; import { formatDistanceToNowStrict, subDays } from "date-fns"; -import { InfoIcon } from "lucide-react"; +import { ArrowUpRight, InfoIcon } from "lucide-react"; import { useState } from "react"; // ============================================================================ @@ -118,7 +118,7 @@ export function StatusComponentHeader({ return (
{children} @@ -156,7 +156,7 @@ export function StatusComponentHeaderLeft({ return (
{children} @@ -500,6 +500,50 @@ export function StatusComponentUptimeSkeleton({ return ; } +/** + * StatusComponentLatency - Latency chip shown next to the monitor title. + * + * Presentational only; wrap in the app's link primitive to navigate to the + * monitor page. The caller supplies the label text (e.g. "92ms p75"). + * + * @see StatusComponentLatencySkeleton - For loading state + */ +export function StatusComponentLatency({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} + +
+ ); +} +StatusComponentLatency.displayName = "StatusComponentLatency"; + +/** + * StatusComponentLatencySkeleton - Loading skeleton sized to the latency chip. + * + * @see StatusComponentLatency - For the actual chip + */ +export function StatusComponentLatencySkeleton({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} +StatusComponentLatencySkeleton.displayName = "StatusComponentLatencySkeleton"; + /** * StatusComponentStatus - Automatic status label display * -- 2.51.2 From bbe43a401ebc4e71c57f428202d80a4df1dd22bc Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:25:51 +0200 Subject: [PATCH 004/109] fix: status report update status (#2408) * fix: status report update status * fix: add status report * fix: review --- .../db/src/schema/audit_logs/validation.ts | 8 +- .../__tests__/derive-status.test.ts | 581 ++++++++++++++++++ .../services/src/status-report/add-update.ts | 13 +- packages/services/src/status-report/delete.ts | 5 + .../src/status-report/derive-status.ts | 90 +++ packages/services/src/status-report/update.ts | 5 + 6 files changed, 692 insertions(+), 10 deletions(-) create mode 100644 packages/services/src/status-report/__tests__/derive-status.test.ts create mode 100644 packages/services/src/status-report/derive-status.ts diff --git a/packages/db/src/schema/audit_logs/validation.ts b/packages/db/src/schema/audit_logs/validation.ts index ad2076dff..a2779e33e 100644 --- a/packages/db/src/schema/audit_logs/validation.ts +++ b/packages/db/src/schema/audit_logs/validation.ts @@ -127,8 +127,12 @@ const statusReportUpdateActions = [ action("status_report_update.create", "status_report_update", intId, { optionalMetadata: true, }), - action("status_report_update.update", "status_report_update", intId), - action("status_report_update.delete", "status_report_update", intId), + action("status_report_update.update", "status_report_update", intId, { + optionalMetadata: true, + }), + action("status_report_update.delete", "status_report_update", intId, { + optionalMetadata: true, + }), ] as const; const invitationActions = [ diff --git a/packages/services/src/status-report/__tests__/derive-status.test.ts b/packages/services/src/status-report/__tests__/derive-status.test.ts new file mode 100644 index 000000000..7d5bbf1e1 --- /dev/null +++ b/packages/services/src/status-report/__tests__/derive-status.test.ts @@ -0,0 +1,581 @@ +import { db, eq } from "@openstatus/db"; +import { + page, + statusReport, + statusReportUpdate, +} from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; + +import { SEEDED_WORKSPACE_TEAM_ID } from "../../../test/fixtures"; +import { + loadSeededWorkspace, + makeUserCtx, + readAuditLog, + withTestTransaction, +} from "../../../test/helpers"; +import type { DB, ServiceContext } from "../../context"; +import { addStatusReportUpdate } from "../add-update"; +import { deleteStatusReportUpdate } from "../delete"; +import { deriveReportStatus, recomputeReportStatus } from "../derive-status"; +import type { StatusReportStatus } from "../schemas"; +import { updateStatusReportUpdate } from "../update"; + +const TEST_PREFIX = "svc-derive-status-test"; + +let teamCtx: ServiceContext; +let testPageId: number; + +// the page is created outside a transaction and shared by every test — +// inserting it inside each one lengthens the write lock and starves the +// other suites running in parallel (SQLITE_BUSY) +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); + + const pageRow = await db + .insert(page) + .values({ + workspaceId: team.id, + title: `${TEST_PREFIX}-page`, + description: "test page", + slug: `${TEST_PREFIX}-page-slug`, + customDomain: "", + }) + .returning() + .get(); + testPageId = pageRow.id; +}); + +afterAll(async () => { + await db + .delete(page) + .where(eq(page.id, testPageId)) + .catch(() => undefined); +}); + +const u = (id: number, status: StatusReportStatus, iso: string) => ({ + id, + status, + date: new Date(iso), +}); + +describe("deriveReportStatus (pure)", () => { + test("empty set → null", () => { + expect(deriveReportStatus([])).toBe(null); + }); + + test("single update → its status", () => { + expect(deriveReportStatus([u(1, "identified", "2026-01-01")])).toBe( + "identified", + ); + }); + + test("newest date wins", () => { + expect( + deriveReportStatus([ + u(1, "investigating", "2026-01-01"), + u(2, "resolved", "2026-01-03"), + u(3, "monitoring", "2026-01-02"), + ]), + ).toBe("resolved"); + }); + + test("result is independent of input order", () => { + const updates = [ + u(1, "investigating", "2026-01-01"), + u(2, "resolved", "2026-01-03"), + u(3, "monitoring", "2026-01-02"), + ]; + expect(deriveReportStatus(updates)).toBe("resolved"); + expect(deriveReportStatus([...updates].reverse())).toBe("resolved"); + }); + + test("date drives the winner, not id", () => { + // higher id but older date must lose + expect( + deriveReportStatus([ + u(99, "investigating", "2026-01-01"), + u(1, "resolved", "2026-01-09"), + ]), + ).toBe("resolved"); + }); + + test("highest id breaks a same-timestamp tie", () => { + const d = "2026-01-01T00:00:00Z"; + expect( + deriveReportStatus([ + u(7, "monitoring", d), + u(9, "resolved", d), + u(8, "identified", d), + ]), + ).toBe("resolved"); + }); + + test("tie-break is direction-stable", () => { + const d = "2026-01-01T00:00:00Z"; + // same two updates, opposite input order → same winner (highest id) + expect( + deriveReportStatus([u(9, "investigating", d), u(8, "resolved", d)]), + ).toBe("investigating"); + expect( + deriveReportStatus([u(8, "resolved", d), u(9, "investigating", d)]), + ).toBe("investigating"); + }); + + test("sub-second differences are respected (getTime, not day)", () => { + expect( + deriveReportStatus([ + u(1, "monitoring", "2026-01-01T00:00:00.000Z"), + u(2, "resolved", "2026-01-01T00:00:00.500Z"), + ]), + ).toBe("resolved"); + }); + + test("does not mutate the input array", () => { + const updates = [ + u(2, "resolved", "2026-01-03"), + u(1, "investigating", "2026-01-01"), + ]; + const snapshot = updates.map((x) => x.id); + deriveReportStatus(updates); + expect(updates.map((x) => x.id)).toEqual(snapshot); + }); + + test("every enum value can be the winner", () => { + const statuses: StatusReportStatus[] = [ + "investigating", + "identified", + "monitoring", + "resolved", + ]; + statuses.forEach((status, i) => { + expect( + deriveReportStatus([ + u(1, "investigating", "2026-01-01"), + u(2, status, `2026-01-0${i + 2}`), + ]), + ).toBe(status); + }); + }); +}); + +const STALE_UPDATED_AT = new Date("2020-01-01T00:00:00Z"); + +async function seedReport( + tx: DB, + workspaceId: number, + status: StatusReportStatus, +) { + return tx + .insert(statusReport) + .values({ + workspaceId, + pageId: testPageId, + title: `${TEST_PREFIX}-report`, + status, + // far in the past: `updatedAt` is second-precision, so a same-second + // write would be indistinguishable from no write at all + updatedAt: STALE_UPDATED_AT, + }) + .returning() + .get(); +} + +async function addUpdate( + tx: DB, + statusReportId: number, + status: StatusReportStatus, + iso: string, +) { + return tx + .insert(statusReportUpdate) + .values({ statusReportId, status, date: new Date(iso), message: status }) + .returning() + .get(); +} + +const readRow = async (tx: DB, id: number) => + tx.select().from(statusReport).where(eq(statusReport.id, id)).get(); + +const readStatus = async (tx: DB, id: number) => + (await readRow(tx, id))?.status; + +describe("recomputeReportStatus (DB)", () => { + test("persists the latest update's status onto the report", async () => { + await withTestTransaction(async (tx) => { + const report = await seedReport( + tx, + teamCtx.workspace.id, + "investigating", + ); + await addUpdate(tx, report.id, "identified", "2026-01-02T00:00:00Z"); + await addUpdate(tx, report.id, "monitoring", "2026-01-03T00:00:00Z"); + + const updated = await recomputeReportStatus(tx, report.id); + + expect(updated?.status).toBe("monitoring"); + expect(await readStatus(tx, report.id)).toBe("monitoring"); + }); + }); + + test("returns the refreshed report row", async () => { + await withTestTransaction(async (tx) => { + const report = await seedReport( + tx, + teamCtx.workspace.id, + "investigating", + ); + await addUpdate(tx, report.id, "resolved", "2026-01-05T00:00:00Z"); + + const updated = await recomputeReportStatus(tx, report.id); + + expect(updated?.id).toBe(report.id); + expect(updated?.status).toBe("resolved"); + }); + }); + + test("no updates → returns null and leaves the column untouched", async () => { + await withTestTransaction(async (tx) => { + const report = await seedReport(tx, teamCtx.workspace.id, "identified"); + + const updated = await recomputeReportStatus(tx, report.id); + + expect(updated).toBe(null); + expect(await readStatus(tx, report.id)).toBe("identified"); + }); + }); + + test("resolves same-timestamp updates by id through the DB query", async () => { + await withTestTransaction(async (tx) => { + const report = await seedReport( + tx, + teamCtx.workspace.id, + "investigating", + ); + const d = "2026-02-01T00:00:00Z"; + // inserted in ascending-id order; the later insert (higher id) must win + await addUpdate(tx, report.id, "identified", d); + await addUpdate(tx, report.id, "monitoring", d); + + await recomputeReportStatus(tx, report.id); + + expect(await readStatus(tx, report.id)).toBe("monitoring"); + }); + }); + + test("a back-dated update does not become the status", async () => { + await withTestTransaction(async (tx) => { + const report = await seedReport( + tx, + teamCtx.workspace.id, + "investigating", + ); + await addUpdate(tx, report.id, "resolved", "2026-01-05T00:00:00Z"); + await addUpdate(tx, report.id, "identified", "2026-01-02T00:00:00Z"); + + await recomputeReportStatus(tx, report.id); + + expect(await readStatus(tx, report.id)).toBe("resolved"); + }); + }); + + test("is idempotent", async () => { + await withTestTransaction(async (tx) => { + const report = await seedReport( + tx, + teamCtx.workspace.id, + "investigating", + ); + await addUpdate(tx, report.id, "monitoring", "2026-01-02T00:00:00Z"); + + await recomputeReportStatus(tx, report.id); + const first = await readStatus(tx, report.id); + await recomputeReportStatus(tx, report.id); + const second = await readStatus(tx, report.id); + + expect(first).toBe("monitoring"); + expect(second).toBe("monitoring"); + }); + }); + + test("only touches the target report", async () => { + await withTestTransaction(async (tx) => { + const a = await seedReport(tx, teamCtx.workspace.id, "investigating"); + const b = await seedReport(tx, teamCtx.workspace.id, "investigating"); + await addUpdate(tx, a.id, "resolved", "2026-01-02T00:00:00Z"); + await addUpdate(tx, b.id, "identified", "2026-01-02T00:00:00Z"); + + await recomputeReportStatus(tx, a.id); + + expect(await readStatus(tx, a.id)).toBe("resolved"); + expect(await readStatus(tx, b.id)).toBe("investigating"); + }); + }); + + // regression: cal.com report 2259. Its updates were edited in place — + // the last one flipped to `resolved` — but the report column stayed on + // `identified`, so the badge read degraded for a month. + test("editing an update's status re-derives the report status", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const report = await seedReport(tx, teamCtx.workspace.id, "identified"); + await addUpdate(tx, report.id, "identified", "2026-06-22T22:00:47Z"); + const last = await addUpdate( + tx, + report.id, + "identified", + "2026-06-23T14:48:44Z", + ); + + await updateStatusReportUpdate({ + ctx, + input: { id: last.id, status: "resolved" }, + }); + + expect(await readStatus(tx, report.id)).toBe("resolved"); + }); + }); + + // `status_report_update.create` carries it; the update/delete rows did + // not, so an audit reader could not tell which report an edit belonged to + test("the update audit row carries statusReportId metadata", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const report = await seedReport(tx, teamCtx.workspace.id, "identified"); + const only = await addUpdate( + tx, + report.id, + "identified", + "2026-01-02T00:00:00Z", + ); + + await updateStatusReportUpdate({ + ctx, + input: { id: only.id, status: "resolved" }, + }); + + const rows = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "status_report_update", + entityId: String(only.id), + db: tx, + }); + const row = rows.find((r) => r.action === "status_report_update.update"); + expect(row).toBeDefined(); + expect(row?.metadata).toEqual({ statusReportId: report.id }); + }); + }); + + test("adding a back-dated update does not become the report status", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const report = await seedReport( + tx, + teamCtx.workspace.id, + "investigating", + ); + await addUpdate(tx, report.id, "resolved", "2026-01-05T00:00:00Z"); + await recomputeReportStatus(tx, report.id); + + // filed late, dated before the resolve + await addStatusReportUpdate({ + ctx, + input: { + statusReportId: report.id, + status: "identified", + message: "backfilled detail", + date: new Date("2026-01-02T00:00:00Z"), + }, + }); + + expect(await readStatus(tx, report.id)).toBe("resolved"); + }); + }); + + test("adding a newer update still advances the report status", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const report = await seedReport( + tx, + teamCtx.workspace.id, + "investigating", + ); + await addUpdate(tx, report.id, "investigating", "2026-01-01T00:00:00Z"); + await recomputeReportStatus(tx, report.id); + + const { statusReport: bumped } = await addStatusReportUpdate({ + ctx, + input: { + statusReportId: report.id, + status: "monitoring", + message: "moved on", + date: new Date("2026-01-04T00:00:00Z"), + }, + }); + + expect(bumped.status).toBe("monitoring"); + expect(await readStatus(tx, report.id)).toBe("monitoring"); + }); + }); + + test("the delete audit row carries statusReportId metadata", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const report = await seedReport(tx, teamCtx.workspace.id, "identified"); + await addUpdate(tx, report.id, "identified", "2026-01-02T00:00:00Z"); + const doomed = await addUpdate( + tx, + report.id, + "resolved", + "2026-01-03T00:00:00Z", + ); + + await deleteStatusReportUpdate({ ctx, input: { id: doomed.id } }); + + const rows = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "status_report_update", + entityId: String(doomed.id), + db: tx, + }); + const row = rows.find((r) => r.action === "status_report_update.delete"); + expect(row).toBeDefined(); + expect(row?.metadata).toEqual({ statusReportId: report.id }); + }); + }); + + test("a message-only edit does not bump the report's updatedAt", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const report = await seedReport(tx, teamCtx.workspace.id, "monitoring"); + const only = await addUpdate( + tx, + report.id, + "monitoring", + "2026-01-02T00:00:00Z", + ); + + await updateStatusReportUpdate({ + ctx, + input: { id: only.id, message: "typo fixed" }, + }); + + const after = await readRow(tx, report.id); + expect(after?.status).toBe("monitoring"); + // the RSS/Atom feed dates and sorts items by updatedAt + expect(after?.updatedAt?.getTime()).toBe(STALE_UPDATED_AT.getTime()); + }); + }); + + test("a same-status update still bumps the report's updatedAt", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const report = await seedReport(tx, teamCtx.workspace.id, "monitoring"); + await addUpdate(tx, report.id, "monitoring", "2026-01-02T00:00:00Z"); + + await addStatusReportUpdate({ + ctx, + input: { + statusReportId: report.id, + status: "monitoring", + message: "still working on it", + date: new Date("2026-01-03T00:00:00Z"), + }, + }); + + const after = await readRow(tx, report.id); + expect(after?.status).toBe("monitoring"); + // RSS/Atom consumers would miss the update otherwise + expect(after?.updatedAt?.getTime()).toBeGreaterThan( + STALE_UPDATED_AT.getTime(), + ); + }); + }); + + test("a back-dated same-status update does not bump updatedAt", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const report = await seedReport(tx, teamCtx.workspace.id, "monitoring"); + await addUpdate(tx, report.id, "monitoring", "2026-01-05T00:00:00Z"); + + await addStatusReportUpdate({ + ctx, + input: { + statusReportId: report.id, + status: "monitoring", + message: "backfilled detail", + date: new Date("2026-01-02T00:00:00Z"), + }, + }); + + const after = await readRow(tx, report.id); + expect(after?.updatedAt?.getTime()).toBe(STALE_UPDATED_AT.getTime()); + }); + }); + + test("deleting the latest update re-derives the report status", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const report = await seedReport(tx, teamCtx.workspace.id, "resolved"); + await addUpdate(tx, report.id, "investigating", "2026-01-02T00:00:00Z"); + const doomed = await addUpdate( + tx, + report.id, + "resolved", + "2026-01-03T00:00:00Z", + ); + + await deleteStatusReportUpdate({ ctx, input: { id: doomed.id } }); + + expect(await readStatus(tx, report.id)).toBe("investigating"); + }); + }); + + test("deleting a non-latest update leaves the report status alone", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const report = await seedReport(tx, teamCtx.workspace.id, "resolved"); + const doomed = await addUpdate( + tx, + report.id, + "investigating", + "2026-01-02T00:00:00Z", + ); + await addUpdate(tx, report.id, "resolved", "2026-01-03T00:00:00Z"); + + await deleteStatusReportUpdate({ ctx, input: { id: doomed.id } }); + + const after = await readRow(tx, report.id); + expect(after?.status).toBe("resolved"); + expect(after?.updatedAt?.getTime()).toBe(STALE_UPDATED_AT.getTime()); + }); + }); + + test("persisted column agrees with the pure function on the same rows", async () => { + await withTestTransaction(async (tx) => { + const report = await seedReport( + tx, + teamCtx.workspace.id, + "investigating", + ); + await addUpdate(tx, report.id, "identified", "2026-01-02T00:00:00Z"); + await addUpdate(tx, report.id, "monitoring", "2026-01-04T00:00:00Z"); + await addUpdate(tx, report.id, "resolved", "2026-01-03T00:00:00Z"); + + await recomputeReportStatus(tx, report.id); + + const rows = await tx + .select({ + id: statusReportUpdate.id, + status: statusReportUpdate.status, + date: statusReportUpdate.date, + }) + .from(statusReportUpdate) + .where(eq(statusReportUpdate.statusReportId, report.id)) + .all(); + + expect(await readStatus(tx, report.id)).toBe(deriveReportStatus(rows)); + }); + }); +}); diff --git a/packages/services/src/status-report/add-update.ts b/packages/services/src/status-report/add-update.ts index feccf5c68..1c008fe05 100644 --- a/packages/services/src/status-report/add-update.ts +++ b/packages/services/src/status-report/add-update.ts @@ -1,6 +1,4 @@ -import { eq } from "@openstatus/db"; import { - statusReport, statusReportUpdate, statusReportsToPageComponents, } from "@openstatus/db/src/schema"; @@ -10,6 +8,7 @@ import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; import { ConflictError, InternalServiceError } from "../errors"; import type { StatusReport, StatusReportUpdate } from "../types"; +import { recomputeReportStatus } from "./derive-status"; import { getCurrentImpactsForReport, getReportInWorkspace, @@ -106,12 +105,10 @@ export async function addStatusReportUpdate(args: { componentImpacts: writtenImpacts, }); - const updatedReport = await tx - .update(statusReport) - .set({ status: input.status, updatedAt: new Date() }) - .where(eq(statusReport.id, report.id)) - .returning() - .get(); + // derived, not assigned — a back-dated update must not win + const updatedReport = await recomputeReportStatus(tx, report.id, { + touchIfLatestIs: newUpdate.id, + }); if (!updatedReport) { throw new InternalServiceError( diff --git a/packages/services/src/status-report/delete.ts b/packages/services/src/status-report/delete.ts index 17264a9ba..d242e295b 100644 --- a/packages/services/src/status-report/delete.ts +++ b/packages/services/src/status-report/delete.ts @@ -4,6 +4,7 @@ import { statusReport, statusReportUpdate } from "@openstatus/db/src/schema"; import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; +import { recomputeReportStatus } from "./derive-status"; import { getReportInWorkspace, getReportUpdateInWorkspace } from "./internal"; import { DeleteStatusReportInput, @@ -60,11 +61,15 @@ export async function deleteStatusReportUpdate(args: { .delete(statusReportUpdate) .where(eq(statusReportUpdate.id, existing.id)); + // deleting the latest update hands the status back to the one before it + await recomputeReportStatus(tx, existing.statusReportId); + await emitAudit(tx, ctx, { action: "status_report_update.delete", entityType: "status_report_update", entityId: existing.id, before: existing, + metadata: { statusReportId: existing.statusReportId }, }); }); } diff --git a/packages/services/src/status-report/derive-status.ts b/packages/services/src/status-report/derive-status.ts new file mode 100644 index 000000000..7462c082b --- /dev/null +++ b/packages/services/src/status-report/derive-status.ts @@ -0,0 +1,90 @@ +import { asc, eq } from "@openstatus/db"; +import { statusReport, statusReportUpdate } from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import type { StatusReport } from "../types"; +import type { StatusReportStatus } from "./schemas"; + +type DerivableUpdate = { + id: number; + status: StatusReportStatus; + date: Date; +}; + +/** + * Latest update wins; `id` breaks date ties — must match + * `getCurrentImpactsForReport` or status and impacts can come from + * different updates. Null when there are no updates. + */ +export function deriveReportStatus( + updates: ReadonlyArray, +): StatusReportStatus | null { + return findLatestUpdate(updates)?.status ?? null; +} + +function findLatestUpdate( + updates: ReadonlyArray, +): DerivableUpdate | null { + let latest: DerivableUpdate | null = null; + for (const update of updates) { + if ( + latest === null || + update.date.getTime() > latest.date.getTime() || + (update.date.getTime() === latest.date.getTime() && update.id > latest.id) + ) { + latest = update; + } + } + return latest; +} + +/** + * Persist `status_report.status` from the report's updates. Every mutation + * touching the update set must end with this — the column is a cache, never + * an independently settable field. Null when no updates remain. + */ +export async function recomputeReportStatus( + tx: DB, + statusReportId: number, + options?: { + /** Bump `updatedAt` even if the status is unchanged, when this update is the latest one. */ + touchIfLatestIs?: number; + }, +): Promise { + const rows = await tx + .select({ + id: statusReportUpdate.id, + status: statusReportUpdate.status, + date: statusReportUpdate.date, + }) + .from(statusReportUpdate) + .where(eq(statusReportUpdate.statusReportId, statusReportId)) + .orderBy(asc(statusReportUpdate.date), asc(statusReportUpdate.id)) + .all(); + + const latest = findLatestUpdate(rows); + if (latest === null) return null; + + const current = await tx + .select() + .from(statusReport) + .where(eq(statusReport.id, statusReportId)) + .get(); + if (!current) return null; + + const isNewLatest = + options?.touchIfLatestIs !== undefined && + options.touchIfLatestIs === latest.id; + + // no-op edits (message, impacts) must not bump updatedAt — the RSS/Atom + // feed dates and sorts items by it. A new latest update always counts, + // even when it repeats the current status. + if (current.status === latest.status && !isNewLatest) return current; + + return tx + .update(statusReport) + .set({ status: latest.status, updatedAt: new Date() }) + .where(eq(statusReport.id, statusReportId)) + .returning() + .get(); +} diff --git a/packages/services/src/status-report/update.ts b/packages/services/src/status-report/update.ts index a89f2ca0f..5f92ec840 100644 --- a/packages/services/src/status-report/update.ts +++ b/packages/services/src/status-report/update.ts @@ -11,6 +11,7 @@ import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; import { ConflictError, InternalServiceError } from "../errors"; import type { StatusReport, StatusReportUpdate } from "../types"; +import { recomputeReportStatus } from "./derive-status"; import { getComponentImpactsForUpdate, getPageComponentIdsForReport, @@ -204,12 +205,16 @@ export async function updateStatusReportUpdate(args: { ); } + // editing status or date can change which update is latest + await recomputeReportStatus(tx, existing.statusReportId); + await emitAudit(tx, ctx, { action: "status_report_update.update", entityType: "status_report_update", entityId: updated.id, before: withComponentImpacts(existing, beforeImpacts), after: withComponentImpacts(updated, afterImpacts), + metadata: { statusReportId: existing.statusReportId }, }); return updated; -- 2.51.2 From 70b62eda4cde4512eb3dbe62893408fc36621f37 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Thu, 23 Jul 2026 06:58:53 +0000 Subject: [PATCH 005/109] private location: api (#2410) --- .gitignore | 2 + .../components/chat/tool-renderers/index.tsx | 7 + .../tool-renderers/list-private-locations.tsx | 52 ++ apps/server/src/routes/mcp/evals/cases.ts | 5 + apps/server/src/routes/mcp/handler.test.ts | 1 + apps/server/src/routes/mcp/server.ts | 2 + .../src/routes/mcp/tools/private-location.ts | 15 + .../server/src/routes/mcp/tools/tools.test.ts | 58 +- .../__tests__/private-location.test.ts | 609 ++++++++++++++++++ .../handlers/private-location/converters.ts | 45 ++ .../rpc/handlers/private-location/errors.ts | 59 ++ .../rpc/handlers/private-location/index.ts | 176 +++++ .../rpc/handlers/private-location/limits.ts | 15 + .../src/routes/rpc/interceptors/tracking.ts | 14 + apps/server/src/routes/rpc/router.ts | 5 +- apps/server/static/openapi.yaml | 421 ++++++++++++ packages/analytics/src/events.ts | 12 + packages/api/src/router/privateLocation.ts | 6 +- .../v1/private_location.proto | 52 ++ .../private_location/v1/service.proto | 131 ++++ packages/proto/gen/openapi.yaml | 421 ++++++++++++ .../openstatus/private_location/v1/index.ts | 3 + .../v1/private_location_pb.ts | 135 ++++ .../private_location/v1/service_pb.ts | 335 ++++++++++ packages/proto/package.json | 4 + packages/services/src/agent-tools/index.ts | 3 + .../src/agent-tools/private-location.ts | 85 +++ .../__tests__/private-location.test.ts | 591 +++++++++++++++++ .../services/src/private-location/create.ts | 5 +- packages/services/src/private-location/get.ts | 41 ++ .../services/src/private-location/index.ts | 2 + .../services/src/private-location/internal.ts | 12 +- .../services/src/private-location/list.ts | 30 +- .../services/src/private-location/schemas.ts | 16 +- .../services/src/private-location/update.ts | 69 +- 35 files changed, 3387 insertions(+), 52 deletions(-) create mode 100644 apps/dashboard/src/components/chat/tool-renderers/list-private-locations.tsx create mode 100644 apps/server/src/routes/mcp/tools/private-location.ts create mode 100644 apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts create mode 100644 apps/server/src/routes/rpc/handlers/private-location/converters.ts create mode 100644 apps/server/src/routes/rpc/handlers/private-location/errors.ts create mode 100644 apps/server/src/routes/rpc/handlers/private-location/index.ts create mode 100644 apps/server/src/routes/rpc/handlers/private-location/limits.ts create mode 100644 packages/proto/api/openstatus/private_location/v1/private_location.proto create mode 100644 packages/proto/api/openstatus/private_location/v1/service.proto create mode 100644 packages/proto/gen/ts/openstatus/private_location/v1/index.ts create mode 100644 packages/proto/gen/ts/openstatus/private_location/v1/private_location_pb.ts create mode 100644 packages/proto/gen/ts/openstatus/private_location/v1/service_pb.ts create mode 100644 packages/services/src/agent-tools/private-location.ts create mode 100644 packages/services/src/private-location/__tests__/private-location.test.ts create mode 100644 packages/services/src/private-location/get.ts diff --git a/.gitignore b/.gitignore index bc9ce4bf2..8d10b8ce9 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,5 @@ plan.md skills-lock.json .mcp.json + +tmp diff --git a/apps/dashboard/src/components/chat/tool-renderers/index.tsx b/apps/dashboard/src/components/chat/tool-renderers/index.tsx index 66d00ba1d..5c584622b 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/index.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/index.tsx @@ -24,6 +24,7 @@ import { listMaintenancesTable } from "./list-maintenances"; import { listMonitorsTable } from "./list-monitors"; import { listNotificationsTable } from "./list-notifications"; import { listPageComponentsTable } from "./list-page-components"; +import { listPrivateLocationsTable } from "./list-private-locations"; import { listResponseLogsTable } from "./list-response-logs"; import { listStatusPagesTable } from "./list-status-pages"; import { listStatusReportsTable } from "./list-status-reports"; @@ -151,6 +152,12 @@ export const toolRenderers: ToolRendererRegistry = { ), summary: (o) => itemsCountSummary(o.items), }, + list_private_locations: { + renderResult: ({ output }) => ( + + ), + summary: (o) => itemsCountSummary(o.items), + }, list_response_logs: { renderResult: ({ output }) => ( diff --git a/apps/dashboard/src/components/chat/tool-renderers/list-private-locations.tsx b/apps/dashboard/src/components/chat/tool-renderers/list-private-locations.tsx new file mode 100644 index 000000000..0e4f62770 --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/list-private-locations.tsx @@ -0,0 +1,52 @@ +import type { AgentToolOutput } from "@openstatus/services/agent-tools"; + +import { TableCellDate } from "@/components/data-table/table-cell-date"; +import { TableCellLink } from "@/components/data-table/table-cell-link"; +import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { TableCellText } from "@/components/data-table/table-cell-text"; + +import type { ResultTableData } from "./result-table"; + +type Output = AgentToolOutput<"list_private_locations">; + +export function listPrivateLocationsTable( + output: Output, +): ResultTableData<"name" | "monitors" | "lastSeenAt" | "id"> { + const items = output?.items ?? []; + return { + empty: "No private locations.", + columns: [ + { key: "name", header: "Name" }, + { key: "monitors", header: "Monitors" }, + { key: "lastSeenAt", header: "Last seen" }, + { key: "id", header: "ID" }, + ], + rows: items.map((location) => ({ + id: location.id, + cells: { + name: ( + + ), + monitors: ( + + ), + lastSeenAt: location.lastSeenAt ? ( + + ) : ( + + ), + id: , + }, + })), + }; +} diff --git a/apps/server/src/routes/mcp/evals/cases.ts b/apps/server/src/routes/mcp/evals/cases.ts index 6f2e7948b..a3577f8f0 100644 --- a/apps/server/src/routes/mcp/evals/cases.ts +++ b/apps/server/src/routes/mcp/evals/cases.ts @@ -150,4 +150,9 @@ export const cases: EvalCase[] = [ "What alerting channels do we have configured? Will anyone get paged if monitor 17 goes down?", expectedTool: "list_notifications", }, + { + id: "select.list_private_locations", + prompt: "Is our on-prem checker agent still reporting in?", + expectedTool: "list_private_locations", + }, ]; diff --git a/apps/server/src/routes/mcp/handler.test.ts b/apps/server/src/routes/mcp/handler.test.ts index 46ba666c2..fbffb4af3 100644 --- a/apps/server/src/routes/mcp/handler.test.ts +++ b/apps/server/src/routes/mcp/handler.test.ts @@ -97,6 +97,7 @@ describe("MCP transport", () => { "list_monitors", "list_notifications", "list_page_components", + "list_private_locations", "list_response_logs", "list_status_pages", "list_status_reports", diff --git a/apps/server/src/routes/mcp/server.ts b/apps/server/src/routes/mcp/server.ts index 91a873f70..280850c25 100644 --- a/apps/server/src/routes/mcp/server.ts +++ b/apps/server/src/routes/mcp/server.ts @@ -7,6 +7,7 @@ import { registerMaintenanceTools } from "./tools/maintenance"; import { registerMonitorTools } from "./tools/monitor"; import { registerNotificationTools } from "./tools/notification"; import { registerPageTools } from "./tools/page"; +import { registerPrivateLocationTools } from "./tools/private-location"; import { registerStatusReportTools } from "./tools/status-report"; /** @@ -29,6 +30,7 @@ export function createMcpServer(ctx: ServiceContext): McpServer { registerMaintenanceTools(server, ctx); registerMonitorTools(server, ctx); registerNotificationTools(server, ctx); + registerPrivateLocationTools(server, ctx); registerAuditTools(server, ctx); return server; } diff --git a/apps/server/src/routes/mcp/tools/private-location.ts b/apps/server/src/routes/mcp/tools/private-location.ts new file mode 100644 index 000000000..59354de3c --- /dev/null +++ b/apps/server/src/routes/mcp/tools/private-location.ts @@ -0,0 +1,15 @@ +import type { + McpServer, + RegisteredTool, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ServiceContext } from "@openstatus/services"; +import { listPrivateLocationsTool } from "@openstatus/services/agent-tools"; + +import { registerRegistryTools } from "./registry-adapter"; + +export function registerPrivateLocationTools( + server: McpServer, + ctx: ServiceContext, +): Map { + return registerRegistryTools(server, ctx, [listPrivateLocationsTool]); +} diff --git a/apps/server/src/routes/mcp/tools/tools.test.ts b/apps/server/src/routes/mcp/tools/tools.test.ts index 3e4e82b3a..34e5bde65 100644 --- a/apps/server/src/routes/mcp/tools/tools.test.ts +++ b/apps/server/src/routes/mcp/tools/tools.test.ts @@ -4,6 +4,7 @@ import { page, pageComponent, pageSubscriber, + privateLocation, statusReport, statusReportUpdate, } from "@openstatus/db/src/schema"; @@ -25,6 +26,7 @@ import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; import { toServiceCtx } from "../adapter"; import { registerMaintenanceTools } from "./maintenance"; import { registerPageTools } from "./page"; +import { registerPrivateLocationTools } from "./private-location"; import { registerStatusReportTools } from "./status-report"; /** @@ -107,7 +109,7 @@ afterAll(async () => { /** Build a fresh McpServer + register a tool group, return the tool map. */ function registered( - group: "page" | "status-report" | "maintenance", + group: "page" | "status-report" | "maintenance" | "private-location", ctx: ServiceContext, ) { const server = new McpServer( @@ -121,6 +123,8 @@ function registered( return registerStatusReportTools(server, ctx); case "maintenance": return registerMaintenanceTools(server, ctx); + case "private-location": + return registerPrivateLocationTools(server, ctx); } } @@ -581,6 +585,55 @@ describe("create_maintenance", () => { }); }); +describe("list_private_locations", () => { + test("never exposes the agent token", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + await tx.insert(privateLocation).values({ + name: `${TEST_PREFIX}-mcp-location`, + token: "super-secret-agent-token", + workspaceId: teamWorkspace.id, + }); + + const tools = registered("private-location", ctx); + const result = await callTool(tools, "list_private_locations", { + page: 1, + perPage: 50, + }); + + expect(result.isError).toBeFalsy(); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("super-secret-agent-token"); + expect(serialized).not.toContain("token"); + }); + }); + + test("returns locations for the workspace", async () => { + await withTestTransaction(async (tx) => { + const ctx = makeMcpToolCtx(teamWorkspace, { db: tx }); + await tx.insert(privateLocation).values({ + name: `${TEST_PREFIX}-mcp-listed`, + token: "another-token", + workspaceId: teamWorkspace.id, + }); + + const tools = registered("private-location", ctx); + const result = await callTool(tools, "list_private_locations", { + page: 1, + perPage: 50, + }); + + const structured = result.structuredContent as { + items: { name: string }[]; + }; + expect(structured.items.some((i) => i.name.includes("mcp-listed"))).toBe( + true, + ); + }); + }); +}); + describe("scope filter", () => { test("read-only key sees read tools, no write tools", async () => { const ctx = makeMcpToolCtx(teamWorkspace, { scopes: ["read"] }); @@ -592,6 +645,9 @@ describe("scope filter", () => { expect(pageTools.has("list_status_pages")).toBe(true); expect(reportTools.has("list_status_reports")).toBe(true); expect(maintenanceTools.has("list_maintenances")).toBe(true); + expect( + registered("private-location", ctx).has("list_private_locations"), + ).toBe(true); // Write tools must be filtered out — registry doesn't carry them // and `tools/list` will see only the read tools. diff --git a/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts b/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts new file mode 100644 index 000000000..b9ac28664 --- /dev/null +++ b/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts @@ -0,0 +1,609 @@ +import { db, eq } from "@openstatus/db"; +import { + monitor, + privateLocation, + privateLocationToMonitors, + workspace, +} from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; +import { expect } from "@std/expect"; +import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; +import { nanoid } from "nanoid"; + +import { app } from "../../../../../index"; + +async function connectRequest( + method: string, + body: Record = {}, + headers: Record = {}, +) { + return app.request( + `/rpc/openstatus.private_location.v1.PrivateLocationService/${method}`, + { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }, + ); +} + +const TEST_PREFIX = "rpc-private-location-test"; + +// Scoped to a dedicated workspace so parallel suites mutating the shared seed +// workspace can't delete our fixtures mid-run. In test, `validateKey` maps +// `x-openstatus-key` to the workspace id. +let entitledWorkspaceId: number; +let authKey: string; +let freeWorkspaceId: number; +let freeAuthKey: string; + +let testMonitorId: number; +let secondMonitorId: number; +let deletedMonitorId: number; +let foreignMonitorId: number; + +let testLocationId: number; +let locationToUpdateId: number; +let locationToDeleteId: number; + +const mkWorkspace = async ( + plan: "team" | "free", + privateLocations: boolean, +) => { + const limits = { ...getLimits(plan), "private-locations": privateLocations }; + const row = await db + .insert(workspace) + .values({ + slug: `${TEST_PREFIX}-ws-${nanoid(8)}`, + name: `${TEST_PREFIX}-workspace`, + plan, + limits: JSON.stringify(limits), + }) + .returning() + .get(); + return row.id; +}; + +const mkMonitor = async ( + name: string, + workspaceId: number, + deleted?: boolean, +) => { + const row = await db + .insert(monitor) + .values({ + name, + workspaceId, + url: "https://example.com", + periodicity: "10m", + deletedAt: deleted ? new Date() : null, + }) + .returning() + .get(); + return row.id; +}; + +const mkLocation = async (name: string, workspaceId: number) => { + const row = await db + .insert(privateLocation) + .values({ name, token: `token-${nanoid(8)}`, workspaceId }) + .returning() + .get(); + return row.id; +}; + +beforeAll(async () => { + entitledWorkspaceId = await mkWorkspace("team", true); + authKey = String(entitledWorkspaceId); + + freeWorkspaceId = await mkWorkspace("free", false); + freeAuthKey = String(freeWorkspaceId); + + testMonitorId = await mkMonitor( + `${TEST_PREFIX}-monitor`, + entitledWorkspaceId, + ); + secondMonitorId = await mkMonitor( + `${TEST_PREFIX}-monitor-2`, + entitledWorkspaceId, + ); + deletedMonitorId = await mkMonitor( + `${TEST_PREFIX}-monitor-deleted`, + entitledWorkspaceId, + true, + ); + foreignMonitorId = await mkMonitor( + `${TEST_PREFIX}-monitor-foreign`, + freeWorkspaceId, + ); + + testLocationId = await mkLocation(`${TEST_PREFIX}-main`, entitledWorkspaceId); + await db + .insert(privateLocationToMonitors) + .values({ privateLocationId: testLocationId, monitorId: testMonitorId }); + + locationToUpdateId = await mkLocation( + `${TEST_PREFIX}-to-update`, + entitledWorkspaceId, + ); + await db.insert(privateLocationToMonitors).values({ + privateLocationId: locationToUpdateId, + monitorId: testMonitorId, + }); + + locationToDeleteId = await mkLocation( + `${TEST_PREFIX}-to-delete`, + entitledWorkspaceId, + ); +}); + +afterAll(async () => { + for (const id of [entitledWorkspaceId, freeWorkspaceId]) { + await db.delete(privateLocation).where(eq(privateLocation.workspaceId, id)); + await db.delete(monitor).where(eq(monitor.workspaceId, id)); + await db.delete(workspace).where(eq(workspace.id, id)); + } +}); + +describe("PrivateLocationService.CreatePrivateLocation", () => { + test("creates a private location with a server-generated token", async () => { + const res = await connectRequest( + "CreatePrivateLocation", + { + name: `${TEST_PREFIX}-created`, + monitorIds: [String(testMonitorId)], + }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data).toHaveProperty("privateLocation"); + expect(data.privateLocation.name).toBe(`${TEST_PREFIX}-created`); + expect(data.privateLocation.token).toBeTruthy(); + expect(data.privateLocation.monitorIds).toContain(String(testMonitorId)); + + await db + .delete(privateLocation) + .where(eq(privateLocation.id, Number(data.privateLocation.id))); + }); + + test("generates a distinct token per location", async () => { + const mk = async (name: string) => { + const res = await connectRequest( + "CreatePrivateLocation", + { name }, + { "x-openstatus-key": authKey }, + ); + expect(res.status).toBe(200); + return await res.json(); + }; + + const a = await mk(`${TEST_PREFIX}-token-a`); + const b = await mk(`${TEST_PREFIX}-token-b`); + + expect(a.privateLocation.token).not.toBe(b.privateLocation.token); + + for (const data of [a, b]) { + await db + .delete(privateLocation) + .where(eq(privateLocation.id, Number(data.privateLocation.id))); + } + }); + + test("ignores a caller-supplied token", async () => { + const res = await connectRequest( + "CreatePrivateLocation", + { name: `${TEST_PREFIX}-ignores-token`, token: "attacker-chosen" }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.privateLocation.token).not.toBe("attacker-chosen"); + + await db + .delete(privateLocation) + .where(eq(privateLocation.id, Number(data.privateLocation.id))); + }); + + test("returns 403 for a workspace without the entitlement", async () => { + const res = await connectRequest( + "CreatePrivateLocation", + { name: `${TEST_PREFIX}-not-entitled` }, + { "x-openstatus-key": freeAuthKey }, + ); + + expect(res.status).toBe(403); + }); + + test("returns 403 for a monitor in another workspace", async () => { + const res = await connectRequest( + "CreatePrivateLocation", + { + name: `${TEST_PREFIX}-foreign-monitor`, + monitorIds: [String(foreignMonitorId)], + }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(403); + }); + + test("returns 403 for a soft-deleted monitor", async () => { + const res = await connectRequest( + "CreatePrivateLocation", + { + name: `${TEST_PREFIX}-deleted-monitor`, + monitorIds: [String(deletedMonitorId)], + }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(403); + }); + + test("returns 400 when name is empty", async () => { + const res = await connectRequest( + "CreatePrivateLocation", + { name: "" }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(400); + }); + + test("returns 401 when no auth key provided", async () => { + const res = await connectRequest("CreatePrivateLocation", { + name: `${TEST_PREFIX}-unauth`, + }); + + expect(res.status).toBe(401); + }); +}); + +describe("PrivateLocationService.GetPrivateLocation", () => { + test("returns the location including its token", async () => { + const res = await connectRequest( + "GetPrivateLocation", + { id: String(testLocationId) }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.privateLocation.id).toBe(String(testLocationId)); + expect(data.privateLocation.name).toBe(`${TEST_PREFIX}-main`); + expect(data.privateLocation.token).toBeTruthy(); + expect(data.privateLocation.monitorIds).toContain(String(testMonitorId)); + expect(data.privateLocation.createdAt).toBeTruthy(); + }); + + test("returns 404 for a non-existent id", async () => { + const res = await connectRequest( + "GetPrivateLocation", + { id: "99999999" }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(404); + expect(res.headers.get("error-reason")).toBe("PRIVATE_LOCATION_NOT_FOUND"); + }); + + test("returns 404 for a location in another workspace", async () => { + const foreignId = await mkLocation( + `${TEST_PREFIX}-foreign`, + freeWorkspaceId, + ); + + const res = await connectRequest( + "GetPrivateLocation", + { id: String(foreignId) }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(404); + + await db.delete(privateLocation).where(eq(privateLocation.id, foreignId)); + }); + + test("returns 400 when id is empty", async () => { + const res = await connectRequest( + "GetPrivateLocation", + { id: "" }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(400); + }); + + test("returns 401 when no auth key provided", async () => { + const res = await connectRequest("GetPrivateLocation", { + id: String(testLocationId), + }); + + expect(res.status).toBe(401); + }); +}); + +describe("PrivateLocationService.ListPrivateLocations", () => { + test("returns workspace-scoped summaries without tokens", async () => { + const res = await connectRequest( + "ListPrivateLocations", + {}, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(Array.isArray(data.privateLocations)).toBe(true); + expect(data.totalSize).toBeGreaterThan(0); + + for (const summary of data.privateLocations) { + expect(summary.token).toBeUndefined(); + } + + const ids = data.privateLocations.map( + (l: { id: string }) => l.id, + ) as string[]; + expect(ids).toContain(String(testLocationId)); + }); + + test("reports monitorCount for an attached location", async () => { + const res = await connectRequest( + "ListPrivateLocations", + {}, + { "x-openstatus-key": authKey }, + ); + + const data = await res.json(); + const found = data.privateLocations.find( + (l: { id: string; monitorCount?: number }) => + l.id === String(testLocationId), + ); + expect(found.monitorCount).toBe(1); + }); + + test("applies limit and offset", async () => { + const first = await connectRequest( + "ListPrivateLocations", + { limit: 1, offset: 0 }, + { "x-openstatus-key": authKey }, + ); + const second = await connectRequest( + "ListPrivateLocations", + { limit: 1, offset: 1 }, + { "x-openstatus-key": authKey }, + ); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + + const firstData = await first.json(); + const secondData = await second.json(); + expect(firstData.privateLocations.length).toBe(1); + expect(secondData.privateLocations.length).toBe(1); + expect(firstData.privateLocations[0].id).not.toBe( + secondData.privateLocations[0].id, + ); + }); + + test("returns 400 when limit exceeds the maximum", async () => { + const res = await connectRequest( + "ListPrivateLocations", + { limit: 500 }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(400); + }); + + test("returns 401 when no auth key provided", async () => { + const res = await connectRequest("ListPrivateLocations", {}); + expect(res.status).toBe(401); + }); +}); + +describe("PrivateLocationService.UpdatePrivateLocation", () => { + test("updates the name and leaves associations untouched", async () => { + const res = await connectRequest( + "UpdatePrivateLocation", + { id: String(locationToUpdateId), name: `${TEST_PREFIX}-renamed` }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.privateLocation.name).toBe(`${TEST_PREFIX}-renamed`); + expect(data.privateLocation.monitorIds).toEqual([String(testMonitorId)]); + }); + + test("ignores monitorIds when updateMonitorIds is absent", async () => { + const res = await connectRequest( + "UpdatePrivateLocation", + { + id: String(locationToUpdateId), + monitorIds: [String(secondMonitorId)], + }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.privateLocation.monitorIds).toEqual([String(testMonitorId)]); + }); + + test("replaces associations when updateMonitorIds is true", async () => { + const target = await mkLocation( + `${TEST_PREFIX}-replace`, + entitledWorkspaceId, + ); + await db + .insert(privateLocationToMonitors) + .values({ privateLocationId: target, monitorId: testMonitorId }); + + const res = await connectRequest( + "UpdatePrivateLocation", + { + id: String(target), + monitorIds: [String(secondMonitorId)], + updateMonitorIds: true, + }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.privateLocation.monitorIds).toEqual([String(secondMonitorId)]); + + await db.delete(privateLocation).where(eq(privateLocation.id, target)); + }); + + test("clears associations with an empty list and the flag set", async () => { + const target = await mkLocation( + `${TEST_PREFIX}-clear`, + entitledWorkspaceId, + ); + await db + .insert(privateLocationToMonitors) + .values({ privateLocationId: target, monitorId: testMonitorId }); + + const res = await connectRequest( + "UpdatePrivateLocation", + { id: String(target), monitorIds: [], updateMonitorIds: true }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.privateLocation.monitorIds ?? []).toEqual([]); + + await db.delete(privateLocation).where(eq(privateLocation.id, target)); + }); + + test("returns 403 for a workspace without the entitlement", async () => { + const res = await connectRequest( + "UpdatePrivateLocation", + { id: String(testLocationId), name: "nope" }, + { "x-openstatus-key": freeAuthKey }, + ); + + expect(res.status).toBe(403); + }); + + test("returns 404 for a non-existent id", async () => { + const res = await connectRequest( + "UpdatePrivateLocation", + { id: "99999999", name: "nope" }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(404); + }); + + test("returns 401 when no auth key provided", async () => { + const res = await connectRequest("UpdatePrivateLocation", { + id: String(testLocationId), + name: "nope", + }); + + expect(res.status).toBe(401); + }); +}); + +describe("PrivateLocationService.DeletePrivateLocation", () => { + test("deletes the location and cascades its links", async () => { + const res = await connectRequest( + "DeletePrivateLocation", + { id: String(locationToDeleteId) }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.success).toBe(true); + + const remaining = await db + .select() + .from(privateLocation) + .where(eq(privateLocation.id, locationToDeleteId)) + .all(); + expect(remaining.length).toBe(0); + + const links = await db + .select() + .from(privateLocationToMonitors) + .where( + eq(privateLocationToMonitors.privateLocationId, locationToDeleteId), + ) + .all(); + expect(links.length).toBe(0); + }); + + test("is idempotent for a non-existent id", async () => { + const res = await connectRequest( + "DeletePrivateLocation", + { id: "99999999" }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + }); + + test("succeeds for a workspace without the entitlement", async () => { + const target = await mkLocation( + `${TEST_PREFIX}-free-delete`, + freeWorkspaceId, + ); + + const res = await connectRequest( + "DeletePrivateLocation", + { id: String(target) }, + { "x-openstatus-key": freeAuthKey }, + ); + + expect(res.status).toBe(200); + }); + + test("does not delete another workspace's location", async () => { + const foreignId = await mkLocation( + `${TEST_PREFIX}-foreign-delete`, + freeWorkspaceId, + ); + + const res = await connectRequest( + "DeletePrivateLocation", + { id: String(foreignId) }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + + const remaining = await db + .select() + .from(privateLocation) + .where(eq(privateLocation.id, foreignId)) + .all(); + expect(remaining.length).toBe(1); + + await db.delete(privateLocation).where(eq(privateLocation.id, foreignId)); + }); + + test("returns 401 when no auth key provided", async () => { + const res = await connectRequest("DeletePrivateLocation", { + id: String(testLocationId), + }); + + expect(res.status).toBe(401); + }); +}); diff --git a/apps/server/src/routes/rpc/handlers/private-location/converters.ts b/apps/server/src/routes/rpc/handlers/private-location/converters.ts new file mode 100644 index 000000000..70555f90b --- /dev/null +++ b/apps/server/src/routes/rpc/handlers/private-location/converters.ts @@ -0,0 +1,45 @@ +import type { + PrivateLocation, + PrivateLocationSummary, +} from "@openstatus/proto/private_location/v1"; + +type DBPrivateLocation = { + id: number; + name: string; + token: string; + lastSeenAt: Date | null; + createdAt: Date | null; + updatedAt: Date | null; +}; + +export function dbPrivateLocationToProto( + location: DBPrivateLocation, + monitorIds: string[], +): PrivateLocation { + return { + $typeName: "openstatus.private_location.v1.PrivateLocation" as const, + id: String(location.id), + name: location.name, + token: location.token, + monitorIds, + lastSeenAt: location.lastSeenAt?.toISOString() ?? "", + createdAt: location.createdAt?.toISOString() ?? "", + updatedAt: location.updatedAt?.toISOString() ?? "", + }; +} + +/** The agent token is deliberately absent — list responses must not leak it. */ +export function dbPrivateLocationToProtoSummary( + location: DBPrivateLocation, + monitorCount: number, +): PrivateLocationSummary { + return { + $typeName: "openstatus.private_location.v1.PrivateLocationSummary" as const, + id: String(location.id), + name: location.name, + monitorCount, + lastSeenAt: location.lastSeenAt?.toISOString() ?? "", + createdAt: location.createdAt?.toISOString() ?? "", + updatedAt: location.updatedAt?.toISOString() ?? "", + }; +} diff --git a/apps/server/src/routes/rpc/handlers/private-location/errors.ts b/apps/server/src/routes/rpc/handlers/private-location/errors.ts new file mode 100644 index 000000000..612981bbe --- /dev/null +++ b/apps/server/src/routes/rpc/handlers/private-location/errors.ts @@ -0,0 +1,59 @@ +import { Code, ConnectError } from "@connectrpc/connect"; + +export const ErrorReason = { + PRIVATE_LOCATION_NOT_FOUND: "PRIVATE_LOCATION_NOT_FOUND", + PRIVATE_LOCATION_ID_REQUIRED: "PRIVATE_LOCATION_ID_REQUIRED", + INVALID_MONITOR_ID: "INVALID_MONITOR_ID", +} as const; + +export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; + +const DOMAIN = "openstatus.dev"; + +function createError( + message: string, + code: Code, + reason: ErrorReason, + metadata?: Record, +): ConnectError { + const headers = new Headers({ + "error-domain": DOMAIN, + "error-reason": reason, + }); + + if (metadata) { + for (const [key, value] of Object.entries(metadata)) { + headers.set(`error-${key}`, value); + } + } + + return new ConnectError(message, code, headers); +} + +export function privateLocationNotFoundError( + privateLocationId: string, +): ConnectError { + return createError( + "Private location not found", + Code.NotFound, + ErrorReason.PRIVATE_LOCATION_NOT_FOUND, + { "private-location-id": privateLocationId }, + ); +} + +export function privateLocationIdRequiredError(): ConnectError { + return createError( + "Private location ID is required", + Code.InvalidArgument, + ErrorReason.PRIVATE_LOCATION_ID_REQUIRED, + ); +} + +export function invalidMonitorIdError(monitorId: string): ConnectError { + return createError( + `Invalid monitor id: "${monitorId}"`, + Code.InvalidArgument, + ErrorReason.INVALID_MONITOR_ID, + { "monitor-id": monitorId }, + ); +} diff --git a/apps/server/src/routes/rpc/handlers/private-location/index.ts b/apps/server/src/routes/rpc/handlers/private-location/index.ts new file mode 100644 index 000000000..b2e7df351 --- /dev/null +++ b/apps/server/src/routes/rpc/handlers/private-location/index.ts @@ -0,0 +1,176 @@ +import type { ServiceImpl } from "@connectrpc/connect"; +import type { PrivateLocationService } from "@openstatus/proto/private_location/v1"; +import { NotFoundError } from "@openstatus/services"; +import { + createPrivateLocation, + deletePrivateLocation, + getPrivateLocation, + listPrivateLocations, + updatePrivateLocation, +} from "@openstatus/services/private-location"; + +import { toConnectError, toServiceCtx } from "../../adapter"; +import { getRpcContext } from "../../interceptors"; +import { + dbPrivateLocationToProto, + dbPrivateLocationToProtoSummary, +} from "./converters"; +import { + invalidMonitorIdError, + privateLocationIdRequiredError, + privateLocationNotFoundError, +} from "./errors"; +import { checkPrivateLocationsEnabled } from "./limits"; + +// Map the service's generic NotFoundError to a ConnectError carrying the +// documented error-reason header for this entity, then defer everything else +// to the shared mapper. +function toPrivateLocationConnectError(err: unknown, id: string): never { + if (err instanceof NotFoundError && err.entity === "private_location") { + throw privateLocationNotFoundError(id); + } + toConnectError(err); +} + +// `Number.parseInt` rather than `Number` — `Number("")` is 0 (finite!), which +// would silently target row 0 instead of failing. +function parseId(id: string): number { + const n = Number.parseInt(id, 10); + if (!Number.isFinite(n)) throw privateLocationIdRequiredError(); + return n; +} + +function parseMonitorIds(ids: ReadonlyArray): number[] { + return ids.map((id) => { + const n = Number.parseInt(id, 10); + if (!Number.isFinite(n)) throw invalidMonitorIdError(id); + return n; + }); +} + +function requireId(id: string): number { + if (!id || id.trim() === "") throw privateLocationIdRequiredError(); + return parseId(id); +} + +export const privateLocationServiceImpl: ServiceImpl< + typeof PrivateLocationService +> = { + async createPrivateLocation(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + checkPrivateLocationsEnabled(rpcCtx.workspace.limits); + const sCtx = toServiceCtx(rpcCtx); + + const created = await createPrivateLocation({ + ctx: sCtx, + input: { + name: req.name, + monitors: parseMonitorIds(req.monitorIds), + }, + }); + + // Re-fetch so the response reflects stored ids (deduped), not the request. + const full = await getPrivateLocation({ + ctx: sCtx, + input: { id: created.id }, + }); + + return { + privateLocation: dbPrivateLocationToProto( + full, + full.monitors.map((m) => String(m.id)), + ), + }; + } catch (err) { + toConnectError(err); + } + }, + + async getPrivateLocation(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + const full = await getPrivateLocation({ + ctx: toServiceCtx(rpcCtx), + input: { id: requireId(req.id) }, + }); + + return { + privateLocation: dbPrivateLocationToProto( + full, + full.monitors.map((m) => String(m.id)), + ), + }; + } catch (err) { + toPrivateLocationConnectError(err, req.id); + } + }, + + async listPrivateLocations(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + + const { items, totalSize } = await listPrivateLocations({ + ctx: toServiceCtx(rpcCtx), + input: { + limit: Math.min(Math.max(req.limit ?? 50, 1), 100), + offset: req.offset ?? 0, + }, + }); + + return { + privateLocations: items.map((item) => + dbPrivateLocationToProtoSummary(item, item.monitors.length), + ), + totalSize, + }; + } catch (err) { + toConnectError(err); + } + }, + + async updatePrivateLocation(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + checkPrivateLocationsEnabled(rpcCtx.workspace.limits); + const sCtx = toServiceCtx(rpcCtx); + const id = requireId(req.id); + + await updatePrivateLocation({ + ctx: sCtx, + input: { + id, + name: + req.name !== undefined && req.name !== "" ? req.name : undefined, + monitors: req.updateMonitorIds + ? parseMonitorIds(req.monitorIds) + : undefined, + }, + }); + + const full = await getPrivateLocation({ ctx: sCtx, input: { id } }); + + return { + privateLocation: dbPrivateLocationToProto( + full, + full.monitors.map((m) => String(m.id)), + ), + }; + } catch (err) { + toPrivateLocationConnectError(err, req.id); + } + }, + + async deletePrivateLocation(req, ctx) { + try { + const rpcCtx = getRpcContext(ctx); + await deletePrivateLocation({ + ctx: toServiceCtx(rpcCtx), + input: { id: requireId(req.id) }, + }); + return { success: true }; + } catch (err) { + toConnectError(err); + } + }, +}; diff --git a/apps/server/src/routes/rpc/handlers/private-location/limits.ts b/apps/server/src/routes/rpc/handlers/private-location/limits.ts new file mode 100644 index 000000000..81fec25d0 --- /dev/null +++ b/apps/server/src/routes/rpc/handlers/private-location/limits.ts @@ -0,0 +1,15 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import type { Limits } from "@openstatus/db/src/schema/plan/schema"; + +/** + * `private-locations` is a boolean entitlement, not a row-count cap — there is + * no per-workspace limit on how many agents a paid plan may register. + */ +export function checkPrivateLocationsEnabled(limits: Limits): void { + if (!limits["private-locations"]) { + throw new ConnectError( + "Upgrade to use private locations", + Code.PermissionDenied, + ); + } +} diff --git a/apps/server/src/routes/rpc/interceptors/tracking.ts b/apps/server/src/routes/rpc/interceptors/tracking.ts index b49a50551..0cb8794f3 100644 --- a/apps/server/src/routes/rpc/interceptors/tracking.ts +++ b/apps/server/src/routes/rpc/interceptors/tracking.ts @@ -99,6 +99,20 @@ export const RPC_EVENT_MAP: Record = { "openstatus.notification.v1.NotificationService/DeleteNotification": { event: Events.DeleteNotification, }, + + // PrivateLocationService + "openstatus.private_location.v1.PrivateLocationService/CreatePrivateLocation": + { + event: Events.CreatePrivateLocation, + }, + "openstatus.private_location.v1.PrivateLocationService/UpdatePrivateLocation": + { + event: Events.UpdatePrivateLocation, + }, + "openstatus.private_location.v1.PrivateLocationService/DeletePrivateLocation": + { + event: Events.DeletePrivateLocation, + }, }; /** diff --git a/apps/server/src/routes/rpc/router.ts b/apps/server/src/routes/rpc/router.ts index 203ece040..6233edaa4 100644 --- a/apps/server/src/routes/rpc/router.ts +++ b/apps/server/src/routes/rpc/router.ts @@ -3,6 +3,7 @@ import { HealthService } from "@openstatus/proto/health/v1"; import { MaintenanceService } from "@openstatus/proto/maintenance/v1"; import { MonitorService } from "@openstatus/proto/monitor/v1"; import { NotificationService } from "@openstatus/proto/notification/v1"; +import { PrivateLocationService } from "@openstatus/proto/private_location/v1"; import { StatusPageService } from "@openstatus/proto/status_page/v1"; import { StatusReportService } from "@openstatus/proto/status_report/v1"; @@ -10,6 +11,7 @@ import { healthServiceImpl } from "./handlers/health"; import { maintenanceServiceImpl } from "./handlers/maintenance"; import { monitorServiceImpl } from "./handlers/monitor"; import { notificationServiceImpl } from "./handlers/notification"; +import { privateLocationServiceImpl } from "./handlers/private-location"; import { statusPageServiceImpl } from "./handlers/status-page"; import { statusReportServiceImpl } from "./handlers/status-report"; import { @@ -43,4 +45,5 @@ export const routes = createConnectRouter({ .service(StatusReportService, statusReportServiceImpl) .service(StatusPageService, statusPageServiceImpl) .service(MaintenanceService, maintenanceServiceImpl) - .service(NotificationService, notificationServiceImpl); + .service(NotificationService, notificationServiceImpl) + .service(PrivateLocationService, privateLocationServiceImpl); diff --git a/apps/server/static/openapi.yaml b/apps/server/static/openapi.yaml index 9ad73d881..324d1369a 100644 --- a/apps/server/static/openapi.yaml +++ b/apps/server/static/openapi.yaml @@ -2325,6 +2325,233 @@ components: title: WhatsappData additionalProperties: false description: WhatsappData contains configuration for WhatsApp notifications. + openstatus.private_location.v1.CreatePrivateLocationRequest: + type: object + properties: + name: + type: string + examples: + - eu-west-agent + title: name + maxLength: 256 + minLength: 1 + description: Display name for the private location (required, 1-256 characters). + monitorIds: + type: array + items: + type: string + title: monitor_ids + description: IDs of monitors this private location should run (optional). + title: CreatePrivateLocationRequest + additionalProperties: false + description: CreatePrivateLocationRequest is the request to create a new private location. + openstatus.private_location.v1.CreatePrivateLocationResponse: + type: object + properties: + privateLocation: + title: private_location + description: The created private location, including its generated agent token. + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocation' + title: CreatePrivateLocationResponse + additionalProperties: false + description: CreatePrivateLocationResponse is the response after creating a private location. + openstatus.private_location.v1.DeletePrivateLocationRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: ID of the private location to delete (required). + title: DeletePrivateLocationRequest + additionalProperties: false + description: DeletePrivateLocationRequest is the request to delete a private location. + openstatus.private_location.v1.DeletePrivateLocationResponse: + type: object + properties: + success: + type: boolean + title: success + description: Whether the deletion was successful. + title: DeletePrivateLocationResponse + additionalProperties: false + description: DeletePrivateLocationResponse is the response after deleting a private location. + openstatus.private_location.v1.GetPrivateLocationRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: ID of the private location to retrieve (required). + title: GetPrivateLocationRequest + additionalProperties: false + description: GetPrivateLocationRequest is the request to get a private location by ID. + openstatus.private_location.v1.GetPrivateLocationResponse: + type: object + properties: + privateLocation: + title: private_location + description: The requested private location. + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocation' + title: GetPrivateLocationResponse + additionalProperties: false + description: GetPrivateLocationResponse is the response containing the private location. + openstatus.private_location.v1.ListPrivateLocationsRequest: + type: object + properties: + limit: + type: + - integer + - "null" + title: limit + maximum: 100 + minimum: 1 + format: int32 + description: Maximum number of private locations to return (1-100, defaults to 50). + offset: + type: + - integer + - "null" + title: offset + minimum: 0 + format: int32 + description: Number of private locations to skip for pagination (defaults to 0). + title: ListPrivateLocationsRequest + additionalProperties: false + description: ListPrivateLocationsRequest is the request to list private locations. + openstatus.private_location.v1.ListPrivateLocationsResponse: + type: object + properties: + privateLocations: + type: array + items: + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocationSummary' + title: private_locations + description: List of private locations. + totalSize: + type: integer + title: total_size + format: int32 + description: Total number of private locations in the workspace. + title: ListPrivateLocationsResponse + additionalProperties: false + description: ListPrivateLocationsResponse is the response containing private location summaries. + openstatus.private_location.v1.PrivateLocation: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the private location. + name: + type: string + title: name + description: Display name for the private location. + token: + type: string + title: token + description: |- + Agent credential. Generated by the server on creation and used by the + agent to authenticate; treat it as a secret. + monitorIds: + type: array + items: + type: string + title: monitor_ids + description: IDs of monitors this private location runs. + lastSeenAt: + type: string + title: last_seen_at + description: Last time the agent reported a result (RFC 3339), empty if it never has. + createdAt: + type: string + title: created_at + description: Timestamp when the private location was created (RFC 3339 format). + updatedAt: + type: string + title: updated_at + description: Timestamp when the private location was last updated (RFC 3339 format). + title: PrivateLocation + additionalProperties: false + description: PrivateLocation represents a self-hosted checker agent with full details. + openstatus.private_location.v1.PrivateLocationSummary: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the private location. + name: + type: string + title: name + description: Display name for the private location. + monitorCount: + type: integer + title: monitor_count + format: int32 + description: Number of monitors this private location runs. + lastSeenAt: + type: string + title: last_seen_at + description: Last time the agent reported a result (RFC 3339), empty if it never has. + createdAt: + type: string + title: created_at + description: Timestamp when the private location was created (RFC 3339 format). + updatedAt: + type: string + title: updated_at + description: Timestamp when the private location was last updated (RFC 3339 format). + title: PrivateLocationSummary + additionalProperties: false + description: |- + PrivateLocationSummary represents metadata for a private location (used in + list responses). The agent token is intentionally omitted. + openstatus.private_location.v1.UpdatePrivateLocationRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: ID of the private location to update (required). + name: + type: + - string + - "null" + title: name + maxLength: 256 + minLength: 1 + description: New display name for the private location (optional). + monitorIds: + type: array + items: + type: string + title: monitor_ids + description: New list of monitor IDs. Only applied when update_monitor_ids is true. + updateMonitorIds: + type: + - boolean + - "null" + title: update_monitor_ids + description: |- + When true, monitor_ids replaces the current associations (an empty list + clears all). When false or unset, monitor_ids is ignored and existing + associations are preserved. + title: UpdatePrivateLocationRequest + additionalProperties: false + description: UpdatePrivateLocationRequest is the request to update a private location. + openstatus.private_location.v1.UpdatePrivateLocationResponse: + type: object + properties: + privateLocation: + title: private_location + description: The updated private location. + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocation' + title: UpdatePrivateLocationResponse + additionalProperties: false + description: UpdatePrivateLocationResponse is the response after updating a private location. openstatus.status_page.v1.AddMonitorComponentRequest: type: object properties: @@ -4381,6 +4608,10 @@ tags: notified automatically when maintenance is created. - name: HealthService description: Health check endpoint for load balancer probes. No authentication required. + - name: PrivateLocationService + description: |- + PrivateLocationService provides CRUD operations for private locations — + self-hosted checker agents that run monitors from your own network. paths: /rpc/openstatus.health.v1.HealthService/Check: get: @@ -5400,6 +5631,196 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.UpdateNotificationResponse' + /rpc/openstatus.private_location.v1.PrivateLocationService/CreatePrivateLocation: + post: + tags: + - PrivateLocationService + summary: CreatePrivateLocation + description: Creates a private location. The agent token is generated by the server and returned in the response - it cannot be supplied by the caller. Use the token to configure the agent so it can pull its monitors and report results. + operationId: PrivateLocationService_CreatePrivateLocation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.CreatePrivateLocationRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.CreatePrivateLocationResponse' + /rpc/openstatus.private_location.v1.PrivateLocationService/DeletePrivateLocation: + post: + tags: + - PrivateLocationService + summary: DeletePrivateLocation + description: DeletePrivateLocation removes a private location. + operationId: PrivateLocationService_DeletePrivateLocation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.DeletePrivateLocationRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.DeletePrivateLocationResponse' + /rpc/openstatus.private_location.v1.PrivateLocationService/GetPrivateLocation: + get: + tags: + - PrivateLocationService + summary: GetPrivateLocation + description: |- + GetPrivateLocation retrieves a single private location by ID, including + its agent token. + operationId: PrivateLocationService_GetPrivateLocation.get + parameters: + - name: message + in: query + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.GetPrivateLocationRequest' + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.GetPrivateLocationResponse' + post: + tags: + - PrivateLocationService + summary: GetPrivateLocation + description: |- + GetPrivateLocation retrieves a single private location by ID, including + its agent token. + operationId: PrivateLocationService_GetPrivateLocation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.GetPrivateLocationRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.GetPrivateLocationResponse' + /rpc/openstatus.private_location.v1.PrivateLocationService/ListPrivateLocations: + get: + tags: + - PrivateLocationService + summary: ListPrivateLocations + description: |- + ListPrivateLocations returns a paginated list of private location + summaries. Agent tokens are not included - use GetPrivateLocation. + operationId: PrivateLocationService_ListPrivateLocations.get + parameters: + - name: message + in: query + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.ListPrivateLocationsRequest' + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.ListPrivateLocationsResponse' + post: + tags: + - PrivateLocationService + summary: ListPrivateLocations + description: |- + ListPrivateLocations returns a paginated list of private location + summaries. Agent tokens are not included - use GetPrivateLocation. + operationId: PrivateLocationService_ListPrivateLocations + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.ListPrivateLocationsRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.ListPrivateLocationsResponse' + /rpc/openstatus.private_location.v1.PrivateLocationService/UpdatePrivateLocation: + post: + tags: + - PrivateLocationService + summary: UpdatePrivateLocation + description: UpdatePrivateLocation updates a private location. + operationId: PrivateLocationService_UpdatePrivateLocation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.UpdatePrivateLocationRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.UpdatePrivateLocationResponse' /rpc/openstatus.status_page.v1.StatusPageService/AddMonitorComponent: post: tags: diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts index 220b61b9d..114a64fb8 100644 --- a/packages/analytics/src/events.ts +++ b/packages/analytics/src/events.ts @@ -136,6 +136,18 @@ export const Events = { name: "notification_deleted", channel: "notification", }, + CreatePrivateLocation: { + name: "private_location_created", + channel: "private_location", + }, + UpdatePrivateLocation: { + name: "private_location_updated", + channel: "private_location", + }, + DeletePrivateLocation: { + name: "private_location_deleted", + channel: "private_location", + }, AcknowledgeIncident: { name: "incident_acknowledged", channel: "incident", diff --git a/packages/api/src/router/privateLocation.ts b/packages/api/src/router/privateLocation.ts index b21a35049..0ae5f821b 100644 --- a/packages/api/src/router/privateLocation.ts +++ b/packages/api/src/router/privateLocation.ts @@ -15,7 +15,11 @@ export const privateLocationRouter = createTRPCRouter({ .input(ListPrivateLocationsInput.optional()) .query(async ({ ctx, input }) => { try { - return await listPrivateLocations({ ctx: toServiceCtx(ctx), input }); + const { items } = await listPrivateLocations({ + ctx: toServiceCtx(ctx), + input, + }); + return items; } catch (err) { toTRPCError(err); } diff --git a/packages/proto/api/openstatus/private_location/v1/private_location.proto b/packages/proto/api/openstatus/private_location/v1/private_location.proto new file mode 100644 index 000000000..cb0c63e30 --- /dev/null +++ b/packages/proto/api/openstatus/private_location/v1/private_location.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +package openstatus.private_location.v1; + +option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/private_location/v1;privatelocationv1"; + +// PrivateLocation represents a self-hosted checker agent with full details. +message PrivateLocation { + // Unique identifier for the private location. + string id = 1; + + // Display name for the private location. + string name = 2; + + // Agent credential. Generated by the server on creation and used by the + // agent to authenticate; treat it as a secret. + string token = 3; + + // IDs of monitors this private location runs. + repeated string monitor_ids = 4; + + // Last time the agent reported a result (RFC 3339), empty if it never has. + string last_seen_at = 5; + + // Timestamp when the private location was created (RFC 3339 format). + string created_at = 6; + + // Timestamp when the private location was last updated (RFC 3339 format). + string updated_at = 7; +} + +// PrivateLocationSummary represents metadata for a private location (used in +// list responses). The agent token is intentionally omitted. +message PrivateLocationSummary { + // Unique identifier for the private location. + string id = 1; + + // Display name for the private location. + string name = 2; + + // Number of monitors this private location runs. + int32 monitor_count = 3; + + // Last time the agent reported a result (RFC 3339), empty if it never has. + string last_seen_at = 4; + + // Timestamp when the private location was created (RFC 3339 format). + string created_at = 5; + + // Timestamp when the private location was last updated (RFC 3339 format). + string updated_at = 6; +} diff --git a/packages/proto/api/openstatus/private_location/v1/service.proto b/packages/proto/api/openstatus/private_location/v1/service.proto new file mode 100644 index 000000000..6f80c5783 --- /dev/null +++ b/packages/proto/api/openstatus/private_location/v1/service.proto @@ -0,0 +1,131 @@ +syntax = "proto3"; + +package openstatus.private_location.v1; + +import "buf/validate/validate.proto"; +import "gnostic/openapi/v3/annotations.proto"; +import "openstatus/private_location/v1/private_location.proto"; + +option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/private_location/v1;privatelocationv1"; + +// PrivateLocationService provides CRUD operations for private locations — +// self-hosted checker agents that run monitors from your own network. +service PrivateLocationService { + // CreatePrivateLocation creates a new private location and returns its + // generated agent token. + rpc CreatePrivateLocation(CreatePrivateLocationRequest) returns (CreatePrivateLocationResponse) { + option (gnostic.openapi.v3.operation) = { + description: "Creates a private location. The agent token is generated by the server and returned in the response - it cannot be supplied by the caller. Use the token to configure the agent so it can pull its monitors and report results." + }; + } + + // GetPrivateLocation retrieves a single private location by ID, including + // its agent token. + rpc GetPrivateLocation(GetPrivateLocationRequest) returns (GetPrivateLocationResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // ListPrivateLocations returns a paginated list of private location + // summaries. Agent tokens are not included - use GetPrivateLocation. + rpc ListPrivateLocations(ListPrivateLocationsRequest) returns (ListPrivateLocationsResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // UpdatePrivateLocation updates a private location. + rpc UpdatePrivateLocation(UpdatePrivateLocationRequest) returns (UpdatePrivateLocationResponse); + + // DeletePrivateLocation removes a private location. + rpc DeletePrivateLocation(DeletePrivateLocationRequest) returns (DeletePrivateLocationResponse); +} + +// CreatePrivateLocationRequest is the request to create a new private location. +message CreatePrivateLocationRequest { + // Display name for the private location (required, 1-256 characters). + string name = 1 [ + (buf.validate.field).string = { + min_len: 1 + max_len: 256 + }, + (gnostic.openapi.v3.property) = {example: {yaml: "eu-west-agent"}} + ]; + + // IDs of monitors this private location should run (optional). + repeated string monitor_ids = 2; +} + +// CreatePrivateLocationResponse is the response after creating a private location. +message CreatePrivateLocationResponse { + // The created private location, including its generated agent token. + PrivateLocation private_location = 1; +} + +// GetPrivateLocationRequest is the request to get a private location by ID. +message GetPrivateLocationRequest { + // ID of the private location to retrieve (required). + string id = 1 [(buf.validate.field).string.min_len = 1]; +} + +// GetPrivateLocationResponse is the response containing the private location. +message GetPrivateLocationResponse { + // The requested private location. + PrivateLocation private_location = 1; +} + +// ListPrivateLocationsRequest is the request to list private locations. +message ListPrivateLocationsRequest { + // Maximum number of private locations to return (1-100, defaults to 50). + optional int32 limit = 1 [(buf.validate.field).int32 = { + gte: 1 + lte: 100 + }]; + + // Number of private locations to skip for pagination (defaults to 0). + optional int32 offset = 2 [(buf.validate.field).int32.gte = 0]; +} + +// ListPrivateLocationsResponse is the response containing private location summaries. +message ListPrivateLocationsResponse { + // List of private locations. + repeated PrivateLocationSummary private_locations = 1; + + // Total number of private locations in the workspace. + int32 total_size = 2; +} + +// UpdatePrivateLocationRequest is the request to update a private location. +message UpdatePrivateLocationRequest { + // ID of the private location to update (required). + string id = 1 [(buf.validate.field).string.min_len = 1]; + + // New display name for the private location (optional). + optional string name = 2 [(buf.validate.field).string = { + min_len: 1 + max_len: 256 + }]; + + // New list of monitor IDs. Only applied when update_monitor_ids is true. + repeated string monitor_ids = 3; + + // When true, monitor_ids replaces the current associations (an empty list + // clears all). When false or unset, monitor_ids is ignored and existing + // associations are preserved. + optional bool update_monitor_ids = 4; +} + +// UpdatePrivateLocationResponse is the response after updating a private location. +message UpdatePrivateLocationResponse { + // The updated private location. + PrivateLocation private_location = 1; +} + +// DeletePrivateLocationRequest is the request to delete a private location. +message DeletePrivateLocationRequest { + // ID of the private location to delete (required). + string id = 1 [(buf.validate.field).string.min_len = 1]; +} + +// DeletePrivateLocationResponse is the response after deleting a private location. +message DeletePrivateLocationResponse { + // Whether the deletion was successful. + bool success = 1; +} diff --git a/packages/proto/gen/openapi.yaml b/packages/proto/gen/openapi.yaml index 9ad73d881..324d1369a 100644 --- a/packages/proto/gen/openapi.yaml +++ b/packages/proto/gen/openapi.yaml @@ -2325,6 +2325,233 @@ components: title: WhatsappData additionalProperties: false description: WhatsappData contains configuration for WhatsApp notifications. + openstatus.private_location.v1.CreatePrivateLocationRequest: + type: object + properties: + name: + type: string + examples: + - eu-west-agent + title: name + maxLength: 256 + minLength: 1 + description: Display name for the private location (required, 1-256 characters). + monitorIds: + type: array + items: + type: string + title: monitor_ids + description: IDs of monitors this private location should run (optional). + title: CreatePrivateLocationRequest + additionalProperties: false + description: CreatePrivateLocationRequest is the request to create a new private location. + openstatus.private_location.v1.CreatePrivateLocationResponse: + type: object + properties: + privateLocation: + title: private_location + description: The created private location, including its generated agent token. + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocation' + title: CreatePrivateLocationResponse + additionalProperties: false + description: CreatePrivateLocationResponse is the response after creating a private location. + openstatus.private_location.v1.DeletePrivateLocationRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: ID of the private location to delete (required). + title: DeletePrivateLocationRequest + additionalProperties: false + description: DeletePrivateLocationRequest is the request to delete a private location. + openstatus.private_location.v1.DeletePrivateLocationResponse: + type: object + properties: + success: + type: boolean + title: success + description: Whether the deletion was successful. + title: DeletePrivateLocationResponse + additionalProperties: false + description: DeletePrivateLocationResponse is the response after deleting a private location. + openstatus.private_location.v1.GetPrivateLocationRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: ID of the private location to retrieve (required). + title: GetPrivateLocationRequest + additionalProperties: false + description: GetPrivateLocationRequest is the request to get a private location by ID. + openstatus.private_location.v1.GetPrivateLocationResponse: + type: object + properties: + privateLocation: + title: private_location + description: The requested private location. + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocation' + title: GetPrivateLocationResponse + additionalProperties: false + description: GetPrivateLocationResponse is the response containing the private location. + openstatus.private_location.v1.ListPrivateLocationsRequest: + type: object + properties: + limit: + type: + - integer + - "null" + title: limit + maximum: 100 + minimum: 1 + format: int32 + description: Maximum number of private locations to return (1-100, defaults to 50). + offset: + type: + - integer + - "null" + title: offset + minimum: 0 + format: int32 + description: Number of private locations to skip for pagination (defaults to 0). + title: ListPrivateLocationsRequest + additionalProperties: false + description: ListPrivateLocationsRequest is the request to list private locations. + openstatus.private_location.v1.ListPrivateLocationsResponse: + type: object + properties: + privateLocations: + type: array + items: + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocationSummary' + title: private_locations + description: List of private locations. + totalSize: + type: integer + title: total_size + format: int32 + description: Total number of private locations in the workspace. + title: ListPrivateLocationsResponse + additionalProperties: false + description: ListPrivateLocationsResponse is the response containing private location summaries. + openstatus.private_location.v1.PrivateLocation: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the private location. + name: + type: string + title: name + description: Display name for the private location. + token: + type: string + title: token + description: |- + Agent credential. Generated by the server on creation and used by the + agent to authenticate; treat it as a secret. + monitorIds: + type: array + items: + type: string + title: monitor_ids + description: IDs of monitors this private location runs. + lastSeenAt: + type: string + title: last_seen_at + description: Last time the agent reported a result (RFC 3339), empty if it never has. + createdAt: + type: string + title: created_at + description: Timestamp when the private location was created (RFC 3339 format). + updatedAt: + type: string + title: updated_at + description: Timestamp when the private location was last updated (RFC 3339 format). + title: PrivateLocation + additionalProperties: false + description: PrivateLocation represents a self-hosted checker agent with full details. + openstatus.private_location.v1.PrivateLocationSummary: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the private location. + name: + type: string + title: name + description: Display name for the private location. + monitorCount: + type: integer + title: monitor_count + format: int32 + description: Number of monitors this private location runs. + lastSeenAt: + type: string + title: last_seen_at + description: Last time the agent reported a result (RFC 3339), empty if it never has. + createdAt: + type: string + title: created_at + description: Timestamp when the private location was created (RFC 3339 format). + updatedAt: + type: string + title: updated_at + description: Timestamp when the private location was last updated (RFC 3339 format). + title: PrivateLocationSummary + additionalProperties: false + description: |- + PrivateLocationSummary represents metadata for a private location (used in + list responses). The agent token is intentionally omitted. + openstatus.private_location.v1.UpdatePrivateLocationRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: ID of the private location to update (required). + name: + type: + - string + - "null" + title: name + maxLength: 256 + minLength: 1 + description: New display name for the private location (optional). + monitorIds: + type: array + items: + type: string + title: monitor_ids + description: New list of monitor IDs. Only applied when update_monitor_ids is true. + updateMonitorIds: + type: + - boolean + - "null" + title: update_monitor_ids + description: |- + When true, monitor_ids replaces the current associations (an empty list + clears all). When false or unset, monitor_ids is ignored and existing + associations are preserved. + title: UpdatePrivateLocationRequest + additionalProperties: false + description: UpdatePrivateLocationRequest is the request to update a private location. + openstatus.private_location.v1.UpdatePrivateLocationResponse: + type: object + properties: + privateLocation: + title: private_location + description: The updated private location. + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocation' + title: UpdatePrivateLocationResponse + additionalProperties: false + description: UpdatePrivateLocationResponse is the response after updating a private location. openstatus.status_page.v1.AddMonitorComponentRequest: type: object properties: @@ -4381,6 +4608,10 @@ tags: notified automatically when maintenance is created. - name: HealthService description: Health check endpoint for load balancer probes. No authentication required. + - name: PrivateLocationService + description: |- + PrivateLocationService provides CRUD operations for private locations — + self-hosted checker agents that run monitors from your own network. paths: /rpc/openstatus.health.v1.HealthService/Check: get: @@ -5400,6 +5631,196 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.UpdateNotificationResponse' + /rpc/openstatus.private_location.v1.PrivateLocationService/CreatePrivateLocation: + post: + tags: + - PrivateLocationService + summary: CreatePrivateLocation + description: Creates a private location. The agent token is generated by the server and returned in the response - it cannot be supplied by the caller. Use the token to configure the agent so it can pull its monitors and report results. + operationId: PrivateLocationService_CreatePrivateLocation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.CreatePrivateLocationRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.CreatePrivateLocationResponse' + /rpc/openstatus.private_location.v1.PrivateLocationService/DeletePrivateLocation: + post: + tags: + - PrivateLocationService + summary: DeletePrivateLocation + description: DeletePrivateLocation removes a private location. + operationId: PrivateLocationService_DeletePrivateLocation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.DeletePrivateLocationRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.DeletePrivateLocationResponse' + /rpc/openstatus.private_location.v1.PrivateLocationService/GetPrivateLocation: + get: + tags: + - PrivateLocationService + summary: GetPrivateLocation + description: |- + GetPrivateLocation retrieves a single private location by ID, including + its agent token. + operationId: PrivateLocationService_GetPrivateLocation.get + parameters: + - name: message + in: query + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.GetPrivateLocationRequest' + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.GetPrivateLocationResponse' + post: + tags: + - PrivateLocationService + summary: GetPrivateLocation + description: |- + GetPrivateLocation retrieves a single private location by ID, including + its agent token. + operationId: PrivateLocationService_GetPrivateLocation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.GetPrivateLocationRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.GetPrivateLocationResponse' + /rpc/openstatus.private_location.v1.PrivateLocationService/ListPrivateLocations: + get: + tags: + - PrivateLocationService + summary: ListPrivateLocations + description: |- + ListPrivateLocations returns a paginated list of private location + summaries. Agent tokens are not included - use GetPrivateLocation. + operationId: PrivateLocationService_ListPrivateLocations.get + parameters: + - name: message + in: query + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.ListPrivateLocationsRequest' + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.ListPrivateLocationsResponse' + post: + tags: + - PrivateLocationService + summary: ListPrivateLocations + description: |- + ListPrivateLocations returns a paginated list of private location + summaries. Agent tokens are not included - use GetPrivateLocation. + operationId: PrivateLocationService_ListPrivateLocations + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.ListPrivateLocationsRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.ListPrivateLocationsResponse' + /rpc/openstatus.private_location.v1.PrivateLocationService/UpdatePrivateLocation: + post: + tags: + - PrivateLocationService + summary: UpdatePrivateLocation + description: UpdatePrivateLocation updates a private location. + operationId: PrivateLocationService_UpdatePrivateLocation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.UpdatePrivateLocationRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.private_location.v1.UpdatePrivateLocationResponse' /rpc/openstatus.status_page.v1.StatusPageService/AddMonitorComponent: post: tags: diff --git a/packages/proto/gen/ts/openstatus/private_location/v1/index.ts b/packages/proto/gen/ts/openstatus/private_location/v1/index.ts new file mode 100644 index 000000000..60fd4d266 --- /dev/null +++ b/packages/proto/gen/ts/openstatus/private_location/v1/index.ts @@ -0,0 +1,3 @@ +// Private location service exports +export * from "./private_location_pb.js"; +export * from "./service_pb.js"; diff --git a/packages/proto/gen/ts/openstatus/private_location/v1/private_location_pb.ts b/packages/proto/gen/ts/openstatus/private_location/v1/private_location_pb.ts new file mode 100644 index 000000000..1618cef8f --- /dev/null +++ b/packages/proto/gen/ts/openstatus/private_location/v1/private_location_pb.ts @@ -0,0 +1,135 @@ +// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=.ts" +// @generated from file openstatus/private_location/v1/private_location.proto (package openstatus.private_location.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file openstatus/private_location/v1/private_location.proto. + */ +export const file_openstatus_private_location_v1_private_location: GenFile = /*@__PURE__*/ + fileDesc("CjVvcGVuc3RhdHVzL3ByaXZhdGVfbG9jYXRpb24vdjEvcHJpdmF0ZV9sb2NhdGlvbi5wcm90bxIeb3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxIo0BCg9Qcml2YXRlTG9jYXRpb24SCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRINCgV0b2tlbhgDIAEoCRITCgttb25pdG9yX2lkcxgEIAMoCRIUCgxsYXN0X3NlZW5fYXQYBSABKAkSEgoKY3JlYXRlZF9hdBgGIAEoCRISCgp1cGRhdGVkX2F0GAcgASgJIocBChZQcml2YXRlTG9jYXRpb25TdW1tYXJ5EgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkSFQoNbW9uaXRvcl9jb3VudBgDIAEoBRIUCgxsYXN0X3NlZW5fYXQYBCABKAkSEgoKY3JlYXRlZF9hdBgFIAEoCRISCgp1cGRhdGVkX2F0GAYgASgJQmRaYmdpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9wcml2YXRlX2xvY2F0aW9uL3YxO3ByaXZhdGVsb2NhdGlvbnYxYgZwcm90bzM"); + +/** + * PrivateLocation represents a self-hosted checker agent with full details. + * + * @generated from message openstatus.private_location.v1.PrivateLocation + */ +export type PrivateLocation = Message<"openstatus.private_location.v1.PrivateLocation"> & { + /** + * Unique identifier for the private location. + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * Display name for the private location. + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Agent credential. Generated by the server on creation and used by the + * agent to authenticate; treat it as a secret. + * + * @generated from field: string token = 3; + */ + token: string; + + /** + * IDs of monitors this private location runs. + * + * @generated from field: repeated string monitor_ids = 4; + */ + monitorIds: string[]; + + /** + * Last time the agent reported a result (RFC 3339), empty if it never has. + * + * @generated from field: string last_seen_at = 5; + */ + lastSeenAt: string; + + /** + * Timestamp when the private location was created (RFC 3339 format). + * + * @generated from field: string created_at = 6; + */ + createdAt: string; + + /** + * Timestamp when the private location was last updated (RFC 3339 format). + * + * @generated from field: string updated_at = 7; + */ + updatedAt: string; +}; + +/** + * Describes the message openstatus.private_location.v1.PrivateLocation. + * Use `create(PrivateLocationSchema)` to create a new message. + */ +export const PrivateLocationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_private_location, 0); + +/** + * PrivateLocationSummary represents metadata for a private location (used in + * list responses). The agent token is intentionally omitted. + * + * @generated from message openstatus.private_location.v1.PrivateLocationSummary + */ +export type PrivateLocationSummary = Message<"openstatus.private_location.v1.PrivateLocationSummary"> & { + /** + * Unique identifier for the private location. + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * Display name for the private location. + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Number of monitors this private location runs. + * + * @generated from field: int32 monitor_count = 3; + */ + monitorCount: number; + + /** + * Last time the agent reported a result (RFC 3339), empty if it never has. + * + * @generated from field: string last_seen_at = 4; + */ + lastSeenAt: string; + + /** + * Timestamp when the private location was created (RFC 3339 format). + * + * @generated from field: string created_at = 5; + */ + createdAt: string; + + /** + * Timestamp when the private location was last updated (RFC 3339 format). + * + * @generated from field: string updated_at = 6; + */ + updatedAt: string; +}; + +/** + * Describes the message openstatus.private_location.v1.PrivateLocationSummary. + * Use `create(PrivateLocationSummarySchema)` to create a new message. + */ +export const PrivateLocationSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_private_location, 1); + diff --git a/packages/proto/gen/ts/openstatus/private_location/v1/service_pb.ts b/packages/proto/gen/ts/openstatus/private_location/v1/service_pb.ts new file mode 100644 index 000000000..a8d4c7395 --- /dev/null +++ b/packages/proto/gen/ts/openstatus/private_location/v1/service_pb.ts @@ -0,0 +1,335 @@ +// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=.ts" +// @generated from file openstatus/private_location/v1/service.proto (package openstatus.private_location.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb.ts"; +import { file_gnostic_openapi_v3_annotations } from "../../../gnostic/openapi/v3/annotations_pb.ts"; +import type { PrivateLocation, PrivateLocationSummary } from "./private_location_pb.ts"; +import { file_openstatus_private_location_v1_private_location } from "./private_location_pb.ts"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file openstatus/private_location/v1/service.proto. + */ +export const file_openstatus_private_location_v1_service: GenFile = /*@__PURE__*/ + fileDesc("CixvcGVuc3RhdHVzL3ByaXZhdGVfbG9jYXRpb24vdjEvc2VydmljZS5wcm90bxIeb3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxImEKHENyZWF0ZVByaXZhdGVMb2NhdGlvblJlcXVlc3QSLAoEbmFtZRgBIAEoCUIeukcROg8SDWV1LXdlc3QtYWdlbnS6SAdyBRABGIACEhMKC21vbml0b3JfaWRzGAIgAygJImoKHUNyZWF0ZVByaXZhdGVMb2NhdGlvblJlc3BvbnNlEkkKEHByaXZhdGVfbG9jYXRpb24YASABKAsyLy5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuUHJpdmF0ZUxvY2F0aW9uIjAKGUdldFByaXZhdGVMb2NhdGlvblJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEiZwoaR2V0UHJpdmF0ZUxvY2F0aW9uUmVzcG9uc2USSQoQcHJpdmF0ZV9sb2NhdGlvbhgBIAEoCzIvLm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5Qcml2YXRlTG9jYXRpb24ibwobTGlzdFByaXZhdGVMb2NhdGlvbnNSZXF1ZXN0Eh0KBWxpbWl0GAEgASgFQgm6SAYaBBhkKAFIAIgBARIcCgZvZmZzZXQYAiABKAVCB7pIBBoCKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCKFAQocTGlzdFByaXZhdGVMb2NhdGlvbnNSZXNwb25zZRJRChFwcml2YXRlX2xvY2F0aW9ucxgBIAMoCzI2Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5Qcml2YXRlTG9jYXRpb25TdW1tYXJ5EhIKCnRvdGFsX3NpemUYAiABKAUiqAEKHFVwZGF0ZVByaXZhdGVMb2NhdGlvblJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESHQoEbmFtZRgCIAEoCUIKukgHcgUQARiAAkgAiAEBEhMKC21vbml0b3JfaWRzGAMgAygJEh8KEnVwZGF0ZV9tb25pdG9yX2lkcxgEIAEoCEgBiAEBQgcKBV9uYW1lQhUKE191cGRhdGVfbW9uaXRvcl9pZHMiagodVXBkYXRlUHJpdmF0ZUxvY2F0aW9uUmVzcG9uc2USSQoQcHJpdmF0ZV9sb2NhdGlvbhgBIAEoCzIvLm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5Qcml2YXRlTG9jYXRpb24iMwocRGVsZXRlUHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIwCh1EZWxldGVQcml2YXRlTG9jYXRpb25SZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIMvIHChZQcml2YXRlTG9jYXRpb25TZXJ2aWNlEv0CChVDcmVhdGVQcml2YXRlTG9jYXRpb24SPC5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuQ3JlYXRlUHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBo9Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5DcmVhdGVQcml2YXRlTG9jYXRpb25SZXNwb25zZSLmAbpH4gEa3wFDcmVhdGVzIGEgcHJpdmF0ZSBsb2NhdGlvbi4gVGhlIGFnZW50IHRva2VuIGlzIGdlbmVyYXRlZCBieSB0aGUgc2VydmVyIGFuZCByZXR1cm5lZCBpbiB0aGUgcmVzcG9uc2UgLSBpdCBjYW5ub3QgYmUgc3VwcGxpZWQgYnkgdGhlIGNhbGxlci4gVXNlIHRoZSB0b2tlbiB0byBjb25maWd1cmUgdGhlIGFnZW50IHNvIGl0IGNhbiBwdWxsIGl0cyBtb25pdG9ycyBhbmQgcmVwb3J0IHJlc3VsdHMuEpABChJHZXRQcml2YXRlTG9jYXRpb24SOS5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuR2V0UHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBo6Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5HZXRQcml2YXRlTG9jYXRpb25SZXNwb25zZSIDkAIBEpYBChRMaXN0UHJpdmF0ZUxvY2F0aW9ucxI7Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5MaXN0UHJpdmF0ZUxvY2F0aW9uc1JlcXVlc3QaPC5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuTGlzdFByaXZhdGVMb2NhdGlvbnNSZXNwb25zZSIDkAIBEpQBChVVcGRhdGVQcml2YXRlTG9jYXRpb24SPC5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuVXBkYXRlUHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBo9Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5VcGRhdGVQcml2YXRlTG9jYXRpb25SZXNwb25zZRKUAQoVRGVsZXRlUHJpdmF0ZUxvY2F0aW9uEjwub3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxLkRlbGV0ZVByaXZhdGVMb2NhdGlvblJlcXVlc3QaPS5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuRGVsZXRlUHJpdmF0ZUxvY2F0aW9uUmVzcG9uc2VCZFpiZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL3ByaXZhdGVfbG9jYXRpb24vdjE7cHJpdmF0ZWxvY2F0aW9udjFiBnByb3RvMw", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_private_location_v1_private_location]); + +/** + * CreatePrivateLocationRequest is the request to create a new private location. + * + * @generated from message openstatus.private_location.v1.CreatePrivateLocationRequest + */ +export type CreatePrivateLocationRequest = Message<"openstatus.private_location.v1.CreatePrivateLocationRequest"> & { + /** + * Display name for the private location (required, 1-256 characters). + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * IDs of monitors this private location should run (optional). + * + * @generated from field: repeated string monitor_ids = 2; + */ + monitorIds: string[]; +}; + +/** + * Describes the message openstatus.private_location.v1.CreatePrivateLocationRequest. + * Use `create(CreatePrivateLocationRequestSchema)` to create a new message. + */ +export const CreatePrivateLocationRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_service, 0); + +/** + * CreatePrivateLocationResponse is the response after creating a private location. + * + * @generated from message openstatus.private_location.v1.CreatePrivateLocationResponse + */ +export type CreatePrivateLocationResponse = Message<"openstatus.private_location.v1.CreatePrivateLocationResponse"> & { + /** + * The created private location, including its generated agent token. + * + * @generated from field: openstatus.private_location.v1.PrivateLocation private_location = 1; + */ + privateLocation?: PrivateLocation | undefined; +}; + +/** + * Describes the message openstatus.private_location.v1.CreatePrivateLocationResponse. + * Use `create(CreatePrivateLocationResponseSchema)` to create a new message. + */ +export const CreatePrivateLocationResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_service, 1); + +/** + * GetPrivateLocationRequest is the request to get a private location by ID. + * + * @generated from message openstatus.private_location.v1.GetPrivateLocationRequest + */ +export type GetPrivateLocationRequest = Message<"openstatus.private_location.v1.GetPrivateLocationRequest"> & { + /** + * ID of the private location to retrieve (required). + * + * @generated from field: string id = 1; + */ + id: string; +}; + +/** + * Describes the message openstatus.private_location.v1.GetPrivateLocationRequest. + * Use `create(GetPrivateLocationRequestSchema)` to create a new message. + */ +export const GetPrivateLocationRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_service, 2); + +/** + * GetPrivateLocationResponse is the response containing the private location. + * + * @generated from message openstatus.private_location.v1.GetPrivateLocationResponse + */ +export type GetPrivateLocationResponse = Message<"openstatus.private_location.v1.GetPrivateLocationResponse"> & { + /** + * The requested private location. + * + * @generated from field: openstatus.private_location.v1.PrivateLocation private_location = 1; + */ + privateLocation?: PrivateLocation | undefined; +}; + +/** + * Describes the message openstatus.private_location.v1.GetPrivateLocationResponse. + * Use `create(GetPrivateLocationResponseSchema)` to create a new message. + */ +export const GetPrivateLocationResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_service, 3); + +/** + * ListPrivateLocationsRequest is the request to list private locations. + * + * @generated from message openstatus.private_location.v1.ListPrivateLocationsRequest + */ +export type ListPrivateLocationsRequest = Message<"openstatus.private_location.v1.ListPrivateLocationsRequest"> & { + /** + * Maximum number of private locations to return (1-100, defaults to 50). + * + * @generated from field: optional int32 limit = 1; + */ + limit?: number | undefined; + + /** + * Number of private locations to skip for pagination (defaults to 0). + * + * @generated from field: optional int32 offset = 2; + */ + offset?: number | undefined; +}; + +/** + * Describes the message openstatus.private_location.v1.ListPrivateLocationsRequest. + * Use `create(ListPrivateLocationsRequestSchema)` to create a new message. + */ +export const ListPrivateLocationsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_service, 4); + +/** + * ListPrivateLocationsResponse is the response containing private location summaries. + * + * @generated from message openstatus.private_location.v1.ListPrivateLocationsResponse + */ +export type ListPrivateLocationsResponse = Message<"openstatus.private_location.v1.ListPrivateLocationsResponse"> & { + /** + * List of private locations. + * + * @generated from field: repeated openstatus.private_location.v1.PrivateLocationSummary private_locations = 1; + */ + privateLocations: PrivateLocationSummary[]; + + /** + * Total number of private locations in the workspace. + * + * @generated from field: int32 total_size = 2; + */ + totalSize: number; +}; + +/** + * Describes the message openstatus.private_location.v1.ListPrivateLocationsResponse. + * Use `create(ListPrivateLocationsResponseSchema)` to create a new message. + */ +export const ListPrivateLocationsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_service, 5); + +/** + * UpdatePrivateLocationRequest is the request to update a private location. + * + * @generated from message openstatus.private_location.v1.UpdatePrivateLocationRequest + */ +export type UpdatePrivateLocationRequest = Message<"openstatus.private_location.v1.UpdatePrivateLocationRequest"> & { + /** + * ID of the private location to update (required). + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * New display name for the private location (optional). + * + * @generated from field: optional string name = 2; + */ + name?: string | undefined; + + /** + * New list of monitor IDs. Only applied when update_monitor_ids is true. + * + * @generated from field: repeated string monitor_ids = 3; + */ + monitorIds: string[]; + + /** + * When true, monitor_ids replaces the current associations (an empty list + * clears all). When false or unset, monitor_ids is ignored and existing + * associations are preserved. + * + * @generated from field: optional bool update_monitor_ids = 4; + */ + updateMonitorIds?: boolean | undefined; +}; + +/** + * Describes the message openstatus.private_location.v1.UpdatePrivateLocationRequest. + * Use `create(UpdatePrivateLocationRequestSchema)` to create a new message. + */ +export const UpdatePrivateLocationRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_service, 6); + +/** + * UpdatePrivateLocationResponse is the response after updating a private location. + * + * @generated from message openstatus.private_location.v1.UpdatePrivateLocationResponse + */ +export type UpdatePrivateLocationResponse = Message<"openstatus.private_location.v1.UpdatePrivateLocationResponse"> & { + /** + * The updated private location. + * + * @generated from field: openstatus.private_location.v1.PrivateLocation private_location = 1; + */ + privateLocation?: PrivateLocation | undefined; +}; + +/** + * Describes the message openstatus.private_location.v1.UpdatePrivateLocationResponse. + * Use `create(UpdatePrivateLocationResponseSchema)` to create a new message. + */ +export const UpdatePrivateLocationResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_service, 7); + +/** + * DeletePrivateLocationRequest is the request to delete a private location. + * + * @generated from message openstatus.private_location.v1.DeletePrivateLocationRequest + */ +export type DeletePrivateLocationRequest = Message<"openstatus.private_location.v1.DeletePrivateLocationRequest"> & { + /** + * ID of the private location to delete (required). + * + * @generated from field: string id = 1; + */ + id: string; +}; + +/** + * Describes the message openstatus.private_location.v1.DeletePrivateLocationRequest. + * Use `create(DeletePrivateLocationRequestSchema)` to create a new message. + */ +export const DeletePrivateLocationRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_service, 8); + +/** + * DeletePrivateLocationResponse is the response after deleting a private location. + * + * @generated from message openstatus.private_location.v1.DeletePrivateLocationResponse + */ +export type DeletePrivateLocationResponse = Message<"openstatus.private_location.v1.DeletePrivateLocationResponse"> & { + /** + * Whether the deletion was successful. + * + * @generated from field: bool success = 1; + */ + success: boolean; +}; + +/** + * Describes the message openstatus.private_location.v1.DeletePrivateLocationResponse. + * Use `create(DeletePrivateLocationResponseSchema)` to create a new message. + */ +export const DeletePrivateLocationResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_private_location_v1_service, 9); + +/** + * PrivateLocationService provides CRUD operations for private locations — + * self-hosted checker agents that run monitors from your own network. + * + * @generated from service openstatus.private_location.v1.PrivateLocationService + */ +export const PrivateLocationService: GenService<{ + /** + * CreatePrivateLocation creates a new private location and returns its + * generated agent token. + * + * @generated from rpc openstatus.private_location.v1.PrivateLocationService.CreatePrivateLocation + */ + createPrivateLocation: { + methodKind: "unary"; + input: typeof CreatePrivateLocationRequestSchema; + output: typeof CreatePrivateLocationResponseSchema; + }, + /** + * GetPrivateLocation retrieves a single private location by ID, including + * its agent token. + * + * @generated from rpc openstatus.private_location.v1.PrivateLocationService.GetPrivateLocation + */ + getPrivateLocation: { + methodKind: "unary"; + input: typeof GetPrivateLocationRequestSchema; + output: typeof GetPrivateLocationResponseSchema; + }, + /** + * ListPrivateLocations returns a paginated list of private location + * summaries. Agent tokens are not included - use GetPrivateLocation. + * + * @generated from rpc openstatus.private_location.v1.PrivateLocationService.ListPrivateLocations + */ + listPrivateLocations: { + methodKind: "unary"; + input: typeof ListPrivateLocationsRequestSchema; + output: typeof ListPrivateLocationsResponseSchema; + }, + /** + * UpdatePrivateLocation updates a private location. + * + * @generated from rpc openstatus.private_location.v1.PrivateLocationService.UpdatePrivateLocation + */ + updatePrivateLocation: { + methodKind: "unary"; + input: typeof UpdatePrivateLocationRequestSchema; + output: typeof UpdatePrivateLocationResponseSchema; + }, + /** + * DeletePrivateLocation removes a private location. + * + * @generated from rpc openstatus.private_location.v1.PrivateLocationService.DeletePrivateLocation + */ + deletePrivateLocation: { + methodKind: "unary"; + input: typeof DeletePrivateLocationRequestSchema; + output: typeof DeletePrivateLocationResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_openstatus_private_location_v1_service, 0); + diff --git a/packages/proto/package.json b/packages/proto/package.json index a9773b68e..be04f46c3 100644 --- a/packages/proto/package.json +++ b/packages/proto/package.json @@ -32,6 +32,10 @@ "./notification/v1": { "import": "./gen/ts/openstatus/notification/v1/index.ts", "types": "./gen/ts/openstatus/notification/v1/index.ts" + }, + "./private_location/v1": { + "import": "./gen/ts/openstatus/private_location/v1/index.ts", + "types": "./gen/ts/openstatus/private_location/v1/index.ts" } }, "scripts": { diff --git a/packages/services/src/agent-tools/index.ts b/packages/services/src/agent-tools/index.ts index 71a81abe0..72ebc266f 100644 --- a/packages/services/src/agent-tools/index.ts +++ b/packages/services/src/agent-tools/index.ts @@ -14,6 +14,7 @@ import { import { listNotificationsTool } from "./notification"; import { listStatusPagesTool } from "./page"; import { listPageComponentsTool } from "./page-component"; +import { listPrivateLocationsTool } from "./private-location"; import { addStatusReportUpdateTool, createStatusReportTool, @@ -37,6 +38,7 @@ export { export { listNotificationsTool } from "./notification"; export { listStatusPagesTool } from "./page"; export { listPageComponentsTool } from "./page-component"; +export { listPrivateLocationsTool } from "./private-location"; export { addStatusReportUpdateTool, createStatusReportTool, @@ -87,6 +89,7 @@ export const agentTools = { list_response_logs: listResponseLogsTool, get_response_log: getResponseLogTool, list_notifications: listNotificationsTool, + list_private_locations: listPrivateLocationsTool, list_audit_logs: listAuditLogsTool, get_audit_log: getAuditLogTool, search_docs: searchDocsTool, diff --git a/packages/services/src/agent-tools/private-location.ts b/packages/services/src/agent-tools/private-location.ts new file mode 100644 index 000000000..003cda944 --- /dev/null +++ b/packages/services/src/agent-tools/private-location.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { listPrivateLocations } from "../private-location"; +import type { AgentTool } from "./types"; + +const PER_PAGE_DEFAULT = 50; +const PER_PAGE_MAX = 100; + +const ListPrivateLocationsInputShape = z.object({ + page: z + .number() + .int() + .min(1) + .default(1) + .describe("1-indexed page number (default 1)."), + perPage: z + .number() + .int() + .min(1) + .max(PER_PAGE_MAX) + .default(PER_PAGE_DEFAULT) + .describe( + `Items per page (default ${PER_PAGE_DEFAULT}, max ${PER_PAGE_MAX}).`, + ), +}); + +// `token` is omitted — it's the agent's bearer credential, and possession of it +// grants access to monitor configs including secret request headers. +const ListPrivateLocationsOutput = z.object({ + items: z.array( + z.object({ + id: z.number().int(), + name: z.string(), + monitorIds: z.array(z.number().int()), + lastSeenAt: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + }), + ), + pagination: z.object({ + page: z.number().int(), + perPage: z.number().int(), + totalSize: z.number().int(), + totalPages: z.number().int(), + }), +}); + +export const listPrivateLocationsTool: AgentTool< + z.infer, + z.infer +> = { + name: "list_private_locations", + description: + "List private locations (self-hosted checker agents) in this workspace, with the monitors each one runs and when it last reported in. Read-only — agent tokens are NOT exposed. Use `lastSeenAt` to tell whether an agent is still alive before blaming a monitor for missing checks.", + scope: "read", + destructive: false, + inputSchema: ListPrivateLocationsInputShape, + outputSchema: ListPrivateLocationsOutput, + async run({ ctx, input }) { + const { page, perPage } = input; + const result = await listPrivateLocations({ + ctx, + input: { + limit: perPage, + offset: (page - 1) * perPage, + }, + }); + return { + items: result.items.map((location) => ({ + id: location.id, + name: location.name, + monitorIds: location.monitors.map((m) => m.id), + lastSeenAt: location.lastSeenAt?.toISOString() ?? null, + createdAt: location.createdAt?.toISOString() ?? null, + updatedAt: location.updatedAt?.toISOString() ?? null, + })), + pagination: { + page, + perPage, + totalSize: result.totalSize, + totalPages: Math.max(1, Math.ceil(result.totalSize / perPage)), + }, + }; + }, +}; diff --git a/packages/services/src/private-location/__tests__/private-location.test.ts b/packages/services/src/private-location/__tests__/private-location.test.ts new file mode 100644 index 000000000..93e3cafd6 --- /dev/null +++ b/packages/services/src/private-location/__tests__/private-location.test.ts @@ -0,0 +1,591 @@ +import { and, db, eq } from "@openstatus/db"; +import { + monitor, + privateLocation, + privateLocationToMonitors, + selectWorkspaceSchema, + workspace, +} from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; +import { expect } from "@std/expect"; +import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; + +import { SEEDED_WORKSPACE_TEAM_ID } from "../../../test/fixtures"; +import { + expectAuditRow, + loadSeededWorkspace, + makeApiKeyCtx, + makeUserCtx, + readAuditLog, + withTestTransaction, +} from "../../../test/helpers"; +import type { ServiceContext } from "../../context"; +import { ForbiddenError, NotFoundError } from "../../errors"; +import { createPrivateLocation } from "../create"; +import { deletePrivateLocation } from "../delete"; +import { getPrivateLocation } from "../get"; +import { listPrivateLocations } from "../list"; +import { updatePrivateLocation } from "../update"; + +const TEST_PREFIX = "svc-private-location-test"; + +let teamCtx: ServiceContext; +let teamMonitorId: number; +let secondMonitorId: number; +let deletedMonitorId: number; +let otherWorkspaceMonitorId: number; +let otherWorkspaceId: number; + +const OTHER_WS_SLUG = `${TEST_PREFIX}-other-ws`; + +beforeAll(async () => { + const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); + teamCtx = makeUserCtx(team, { userId: 1 }); + + const mk = async (name: string, workspaceId: number, deleted?: boolean) => { + const row = await db + .insert(monitor) + .values({ + name, + workspaceId, + url: "https://example.com", + periodicity: "10m", + deletedAt: deleted ? new Date() : null, + }) + .returning() + .get(); + return row.id; + }; + + teamMonitorId = await mk(`${TEST_PREFIX}-monitor`, teamCtx.workspace.id); + secondMonitorId = await mk(`${TEST_PREFIX}-monitor-2`, teamCtx.workspace.id); + deletedMonitorId = await mk( + `${TEST_PREFIX}-monitor-deleted`, + teamCtx.workspace.id, + true, + ); + + await db + .delete(workspace) + .where(eq(workspace.slug, OTHER_WS_SLUG)) + .catch(() => undefined); + const otherRow = await db + .insert(workspace) + .values({ + slug: OTHER_WS_SLUG, + name: `${TEST_PREFIX}-other`, + plan: "team", + limits: JSON.stringify(getLimits("team")), + }) + .returning() + .get(); + const otherWs = selectWorkspaceSchema.parse(otherRow); + otherWorkspaceId = otherWs.id; + otherWorkspaceMonitorId = await mk( + `${TEST_PREFIX}-other-monitor`, + otherWs.id, + ); +}); + +afterAll(async () => { + for (const id of [ + teamMonitorId, + secondMonitorId, + deletedMonitorId, + otherWorkspaceMonitorId, + ]) { + await db.delete(monitor).where(eq(monitor.id, id)); + } + await db.delete(workspace).where(eq(workspace.slug, OTHER_WS_SLUG)); +}); + +describe("createPrivateLocation", () => { + test("generates a token when none is supplied", async () => { + await withTestTransaction(async (tx) => { + const row = await createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { name: `${TEST_PREFIX}-generated`, monitors: [] }, + }); + + expect(row.token).toBeTruthy(); + expect(row.token.length).toBeGreaterThan(20); + }); + }); + + test("generates a distinct token per location", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const a = await createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-a`, monitors: [] }, + }); + const b = await createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-b`, monitors: [] }, + }); + + expect(a.token).not.toBe(b.token); + }); + }); + + test("honors a caller-supplied token", async () => { + await withTestTransaction(async (tx) => { + const row = await createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-supplied`, + token: "caller-chosen-token", + monitors: [], + }, + }); + + expect(row.token).toBe("caller-chosen-token"); + }); + }); + + test("attaches monitors and audits the create", async () => { + await withTestTransaction(async (tx) => { + const row = await createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-with-monitors`, + monitors: [teamMonitorId], + }, + }); + + const links = await tx + .select() + .from(privateLocationToMonitors) + .where(eq(privateLocationToMonitors.privateLocationId, row.id)) + .all(); + expect(links.map((l) => l.monitorId)).toEqual([teamMonitorId]); + + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "private_location.create", + entityType: "private_location", + entityId: row.id, + db: tx, + }); + }); + }); + + test("dedupes duplicate monitor ids", async () => { + await withTestTransaction(async (tx) => { + const row = await createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-dupes`, + monitors: [teamMonitorId, teamMonitorId, teamMonitorId], + }, + }); + + const links = await tx + .select() + .from(privateLocationToMonitors) + .where(eq(privateLocationToMonitors.privateLocationId, row.id)) + .all(); + expect(links.length).toBe(1); + }); + }); + + test("rejects a monitor from another workspace", async () => { + await withTestTransaction(async (tx) => { + await expect( + createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-foreign`, + monitors: [otherWorkspaceMonitorId], + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("rejects a soft-deleted monitor", async () => { + await withTestTransaction(async (tx) => { + await expect( + createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-deleted-monitor`, + monitors: [deletedMonitorId], + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + const ctx = { + ...makeApiKeyCtx(teamCtx.workspace, { + keyId: "k", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + await expect( + createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-readonly`, monitors: [] }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); + +describe("updatePrivateLocation", () => { + const seed = async ( + tx: Parameters[0]>[0], + monitors: number[] = [], + ) => + createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { name: `${TEST_PREFIX}-seed`, monitors }, + }); + + test("updates the name and leaves associations untouched", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await seed(tx, [teamMonitorId]); + + const updated = await updatePrivateLocation({ + ctx, + input: { id: created.id, name: `${TEST_PREFIX}-renamed` }, + }); + expect(updated.name).toBe(`${TEST_PREFIX}-renamed`); + + const links = await tx + .select() + .from(privateLocationToMonitors) + .where(eq(privateLocationToMonitors.privateLocationId, created.id)) + .all(); + expect(links.map((l) => l.monitorId)).toEqual([teamMonitorId]); + }); + }); + + test("replaces associations without touching the name", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await seed(tx, [teamMonitorId]); + + const updated = await updatePrivateLocation({ + ctx, + input: { id: created.id, monitors: [secondMonitorId] }, + }); + expect(updated.name).toBe(created.name); + + const links = await tx + .select() + .from(privateLocationToMonitors) + .where(eq(privateLocationToMonitors.privateLocationId, created.id)) + .all(); + expect(links.map((l) => l.monitorId)).toEqual([secondMonitorId]); + }); + }); + + test("clears associations when monitors is an empty array", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await seed(tx, [teamMonitorId]); + + await updatePrivateLocation({ + ctx, + input: { id: created.id, monitors: [] }, + }); + + const links = await tx + .select() + .from(privateLocationToMonitors) + .where(eq(privateLocationToMonitors.privateLocationId, created.id)) + .all(); + expect(links.length).toBe(0); + }); + }); + + test("emits no audit row when nothing changes", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await seed(tx, [teamMonitorId]); + + await updatePrivateLocation({ ctx, input: { id: created.id } }); + + const rows = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "private_location", + entityId: String(created.id), + db: tx, + }); + expect(rows.some((r) => r.action === "private_location.update")).toBe( + false, + ); + }); + }); + + test("audits a real update", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await seed(tx); + + await updatePrivateLocation({ + ctx, + input: { id: created.id, name: `${TEST_PREFIX}-audited` }, + }); + + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "private_location.update", + entityType: "private_location", + entityId: created.id, + db: tx, + }); + }); + }); + + test("rejects a soft-deleted monitor", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await seed(tx); + + await expect( + updatePrivateLocation({ + ctx, + input: { id: created.id, monitors: [deletedMonitorId] }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("throws NotFoundError for an unknown id", async () => { + await withTestTransaction(async (tx) => { + await expect( + updatePrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { id: 999_999_999, name: "nope" }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); + + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + const ctx = { + ...makeApiKeyCtx(teamCtx.workspace, { + keyId: "k", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + await expect( + updatePrivateLocation({ ctx, input: { id: 1, name: "nope" } }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); + +describe("getPrivateLocation", () => { + test("returns the location with its monitors", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-get`, monitors: [teamMonitorId] }, + }); + + const found = await getPrivateLocation({ + ctx, + input: { id: created.id }, + }); + expect(found.id).toBe(created.id); + expect(found.token).toBe(created.token); + expect(found.monitors.map((m) => m.id)).toEqual([teamMonitorId]); + }); + }); + + test("throws NotFoundError for an unknown id", async () => { + await withTestTransaction(async (tx) => { + await expect( + getPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { id: 999_999_999 }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); + + test("throws NotFoundError for another workspace's location", async () => { + await withTestTransaction(async (tx) => { + const other = await tx + .insert(privateLocation) + .values({ + name: `${TEST_PREFIX}-foreign-loc`, + token: "foreign-token", + workspaceId: otherWorkspaceId, + }) + .returning() + .get(); + + await expect( + getPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { id: other.id }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); +}); + +describe("listPrivateLocations", () => { + test("returns items and a workspace-scoped totalSize", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const before = await listPrivateLocations({ ctx }); + + await createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-list-1`, monitors: [teamMonitorId] }, + }); + await createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-list-2`, monitors: [] }, + }); + + const after = await listPrivateLocations({ ctx }); + expect(after.totalSize).toBe(before.totalSize + 2); + expect( + after.items.every((i) => i.workspaceId === teamCtx.workspace.id), + ).toBe(true); + }); + }); + + test("applies limit and offset over a stable ordering", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + await createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-page-1`, monitors: [] }, + }); + await createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-page-2`, monitors: [] }, + }); + + const firstPage = await listPrivateLocations({ + ctx, + input: { limit: 1, offset: 0 }, + }); + const secondPage = await listPrivateLocations({ + ctx, + input: { limit: 1, offset: 1 }, + }); + + expect(firstPage.items.length).toBe(1); + expect(secondPage.items.length).toBe(1); + expect(firstPage.items[0].id).not.toBe(secondPage.items[0].id); + }); + }); +}); + +describe("deletePrivateLocation", () => { + test("removes the row, cascades links, and audits", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-delete`, monitors: [teamMonitorId] }, + }); + + await deletePrivateLocation({ ctx, input: { id: created.id } }); + + const remaining = await tx + .select() + .from(privateLocation) + .where(eq(privateLocation.id, created.id)) + .all(); + expect(remaining.length).toBe(0); + + const links = await tx + .select() + .from(privateLocationToMonitors) + .where(eq(privateLocationToMonitors.privateLocationId, created.id)) + .all(); + expect(links.length).toBe(0); + + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "private_location.delete", + entityType: "private_location", + entityId: created.id, + db: tx, + }); + }); + }); + + test("is idempotent for an unknown id", async () => { + await withTestTransaction(async (tx) => { + await deletePrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { id: 999_999_999 }, + }); + + const rows = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "private_location", + entityId: "999999999", + db: tx, + }); + expect(rows.length).toBe(0); + }); + }); + + test("does not delete another workspace's location", async () => { + await withTestTransaction(async (tx) => { + const other = await tx + .insert(privateLocation) + .values({ + name: `${TEST_PREFIX}-foreign-delete`, + token: "foreign-token-2", + workspaceId: otherWorkspaceId, + }) + .returning() + .get(); + + await deletePrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { id: other.id }, + }); + + const remaining = await tx + .select() + .from(privateLocation) + .where( + and( + eq(privateLocation.id, other.id), + eq(privateLocation.workspaceId, otherWorkspaceId), + ), + ) + .all(); + expect(remaining.length).toBe(1); + }); + }); + + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + const ctx = { + ...makeApiKeyCtx(teamCtx.workspace, { + keyId: "k", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + await expect( + deletePrivateLocation({ ctx, input: { id: 1 } }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); diff --git a/packages/services/src/private-location/create.ts b/packages/services/src/private-location/create.ts index b590a3ea7..22d2e98d2 100644 --- a/packages/services/src/private-location/create.ts +++ b/packages/services/src/private-location/create.ts @@ -28,6 +28,9 @@ export async function createPrivateLocation(args: { requireScope(ctx, "write"); const input = CreatePrivateLocationInput.parse(args.input); + // Callers that can't mint their own credential (the public API) omit it. + const token = input.token ?? crypto.randomUUID(); + // The pivot table has no UNIQUE (private_location_id, monitor_id) // constraint, so duplicate input ids would silently produce duplicate // FK rows. Dedupe before insert; the audit snapshot follows suit. @@ -44,7 +47,7 @@ export async function createPrivateLocation(args: { .insert(privateLocation) .values({ name: input.name, - token: input.token, + token, workspaceId: ctx.workspace.id, }) .returning() diff --git a/packages/services/src/private-location/get.ts b/packages/services/src/private-location/get.ts new file mode 100644 index 000000000..5df3be235 --- /dev/null +++ b/packages/services/src/private-location/get.ts @@ -0,0 +1,41 @@ +import { and, eq } from "@openstatus/db"; +import { privateLocation } from "@openstatus/db/src/schema"; + +import { type ServiceContext, getReadDb } from "../context"; +import { NotFoundError } from "../errors"; +import { GetPrivateLocationInput } from "./schemas"; + +/** + * Fetch one private location in the caller's workspace, flattened the same + * way `listPrivateLocations` flattens its items. + * + * Return type is deliberately inferred — see the note in `list.ts`. + */ +export async function getPrivateLocation(args: { + ctx: ServiceContext; + input: GetPrivateLocationInput; +}) { + const input = GetPrivateLocationInput.parse(args.input); + const db = getReadDb(args.ctx); + + const row = await db.query.privateLocation.findFirst({ + where: and( + eq(privateLocation.id, input.id), + eq(privateLocation.workspaceId, args.ctx.workspace.id), + ), + with: { + privateLocationToMonitors: { + with: { monitor: true }, + }, + }, + }); + + if (!row) throw new NotFoundError("private_location", input.id); + + return { + ...row, + monitors: row.privateLocationToMonitors + .map((link) => link.monitor) + .filter((m) => m !== null), + }; +} diff --git a/packages/services/src/private-location/index.ts b/packages/services/src/private-location/index.ts index c74e86825..83fb2d5ec 100644 --- a/packages/services/src/private-location/index.ts +++ b/packages/services/src/private-location/index.ts @@ -1,10 +1,12 @@ export { createPrivateLocation } from "./create"; export { deletePrivateLocation } from "./delete"; +export { getPrivateLocation } from "./get"; export { listPrivateLocations } from "./list"; export { updatePrivateLocation } from "./update"; export { CreatePrivateLocationInput, DeletePrivateLocationInput, + GetPrivateLocationInput, ListPrivateLocationsInput, UpdatePrivateLocationInput, } from "./schemas"; diff --git a/packages/services/src/private-location/internal.ts b/packages/services/src/private-location/internal.ts index 9407f5466..50ee14c75 100644 --- a/packages/services/src/private-location/internal.ts +++ b/packages/services/src/private-location/internal.ts @@ -1,14 +1,15 @@ -import { and, eq, inArray } from "@openstatus/db"; +import { and, eq, inArray, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import type { DB } from "../context"; import { ForbiddenError } from "../errors"; /** - * Ensure every id in `monitorIds` belongs to the given workspace. No-op - * when the list is empty. Throws `ForbiddenError` (not `NotFound`) to - * match the legacy router's `FORBIDDEN` response — surfacing "this id - * doesn't exist" vs. "this id isn't yours" would leak workspace state. + * Ensure every id in `monitorIds` belongs to the given workspace and is not + * soft-deleted. No-op when the list is empty. Throws `ForbiddenError` (not + * `NotFound`) to match the legacy router's `FORBIDDEN` response — surfacing + * "this id doesn't exist" vs. "this id isn't yours" would leak workspace + * state. */ export async function assertMonitorsInWorkspace(args: { tx: DB; @@ -24,6 +25,7 @@ export async function assertMonitorsInWorkspace(args: { where: and( eq(monitor.workspaceId, workspaceId), inArray(monitor.id, uniqueIds), + isNull(monitor.deletedAt), ), }); if (valid.length !== uniqueIds.length) { diff --git a/packages/services/src/private-location/list.ts b/packages/services/src/private-location/list.ts index 8a5b73a4a..6ad121428 100644 --- a/packages/services/src/private-location/list.ts +++ b/packages/services/src/private-location/list.ts @@ -1,9 +1,11 @@ -import { eq } from "@openstatus/db"; +import { count, desc, eq } from "@openstatus/db"; import { privateLocation } from "@openstatus/db/src/schema"; import { type ServiceContext, getReadDb } from "../context"; import { ListPrivateLocationsInput } from "./schemas"; +const LIMIT_DEFAULT = 50; + /** * List private locations in the caller's workspace, each one flattened to * include the monitors it's attached to (the relational join's `monitor` @@ -18,7 +20,7 @@ export async function listPrivateLocations(args: { ctx: ServiceContext; input?: ListPrivateLocationsInput; }) { - ListPrivateLocationsInput.parse(args.input ?? {}); + const input = ListPrivateLocationsInput.parse(args.input ?? {}); const db = getReadDb(args.ctx); const rows = await db.query.privateLocation.findMany({ @@ -28,12 +30,24 @@ export async function listPrivateLocations(args: { with: { monitor: true }, }, }, + orderBy: desc(privateLocation.id), + limit: input.limit ?? LIMIT_DEFAULT, + offset: input.offset ?? 0, }); - return rows.map((row) => ({ - ...row, - monitors: row.privateLocationToMonitors - .map((link) => link.monitor) - .filter((m) => m !== null), - })); + const total = await db + .select({ count: count() }) + .from(privateLocation) + .where(eq(privateLocation.workspaceId, args.ctx.workspace.id)) + .get(); + + return { + items: rows.map((row) => ({ + ...row, + monitors: row.privateLocationToMonitors + .map((link) => link.monitor) + .filter((m) => m !== null), + })), + totalSize: total?.count ?? 0, + }; } diff --git a/packages/services/src/private-location/schemas.ts b/packages/services/src/private-location/schemas.ts index 5b308103e..1db4ae5c6 100644 --- a/packages/services/src/private-location/schemas.ts +++ b/packages/services/src/private-location/schemas.ts @@ -1,6 +1,11 @@ import { z } from "zod"; -export const ListPrivateLocationsInput = z.object({}).strict(); +export const ListPrivateLocationsInput = z + .object({ + limit: z.number().int().min(1).max(100).optional(), + offset: z.number().int().min(0).optional(), + }) + .strict(); export type ListPrivateLocationsInput = z.infer< typeof ListPrivateLocationsInput >; @@ -9,7 +14,7 @@ const monitorIds = z.array(z.number().int().positive()); export const CreatePrivateLocationInput = z.object({ name: z.string().min(1), - token: z.string().min(1), + token: z.string().min(1).optional(), monitors: monitorIds, }); export type CreatePrivateLocationInput = z.infer< @@ -18,13 +23,16 @@ export type CreatePrivateLocationInput = z.infer< export const UpdatePrivateLocationInput = z.object({ id: z.number().int(), - name: z.string().min(1), - monitors: monitorIds, + name: z.string().min(1).optional(), + monitors: monitorIds.optional(), }); export type UpdatePrivateLocationInput = z.infer< typeof UpdatePrivateLocationInput >; +export const GetPrivateLocationInput = z.object({ id: z.number().int() }); +export type GetPrivateLocationInput = z.infer; + export const DeletePrivateLocationInput = z.object({ id: z.number().int() }); export type DeletePrivateLocationInput = z.infer< typeof DeletePrivateLocationInput diff --git a/packages/services/src/private-location/update.ts b/packages/services/src/private-location/update.ts index 096177c95..adc335611 100644 --- a/packages/services/src/private-location/update.ts +++ b/packages/services/src/private-location/update.ts @@ -14,12 +14,8 @@ import { assertMonitorsInWorkspace } from "./internal"; import { UpdatePrivateLocationInput } from "./schemas"; /** - * Replace the name and monitor associations of a private location. - * - * `updatedAt` is set explicitly — same as the legacy router. - * Associations are bulk-replaced: delete-all followed by insert-all for - * the new set. The delete is unconditional inside the workspace-scoped - * tx, so there's no opportunity for a stale row to leak through. + * Patch a private location. Omitted fields are left untouched; passing an + * empty `monitors` array clears every association. */ export async function updatePrivateLocation(args: { ctx: ServiceContext; @@ -32,7 +28,9 @@ export async function updatePrivateLocation(args: { // The pivot table has no UNIQUE (private_location_id, monitor_id) // constraint, so duplicate input ids would silently produce duplicate // FK rows. Dedupe before insert; the audit snapshot follows suit. - const monitorIds = Array.from(new Set(input.monitors)); + const monitorIds = input.monitors + ? Array.from(new Set(input.monitors)) + : undefined; return withTransaction(ctx, async (tx) => { const existing = await tx.query.privateLocation.findFirst({ @@ -49,15 +47,21 @@ export async function updatePrivateLocation(args: { throw new NotFoundError("private_location", input.id); } - await assertMonitorsInWorkspace({ - tx, - workspaceId: ctx.workspace.id, - monitorIds, - }); + if (monitorIds) { + await assertMonitorsInWorkspace({ + tx, + workspaceId: ctx.workspace.id, + monitorIds, + }); + } const row = await tx .update(privateLocation) - .set({ name: input.name, updatedAt: new Date() }) + .set( + input.name !== undefined + ? { name: input.name, updatedAt: new Date() } + : { updatedAt: new Date() }, + ) .where( and( eq(privateLocation.id, input.id), @@ -74,37 +78,40 @@ export async function updatePrivateLocation(args: { // undefined`. Re-throw NotFound so the failure mode stays sane. if (!row) throw new NotFoundError("private_location", input.id); - await tx - .delete(privateLocationToMonitors) - .where(eq(privateLocationToMonitors.privateLocationId, row.id)); + if (monitorIds) { + await tx + .delete(privateLocationToMonitors) + .where(eq(privateLocationToMonitors.privateLocationId, row.id)); - if (monitorIds.length > 0) { - await tx.insert(privateLocationToMonitors).values( - monitorIds.map((monitorId) => ({ - privateLocationId: row.id, - monitorId, - })), - ); + if (monitorIds.length > 0) { + await tx.insert(privateLocationToMonitors).values( + monitorIds.map((monitorId) => ({ + privateLocationId: row.id, + monitorId, + })), + ); + } } const { privateLocationToMonitors: beforeLinks, ...beforeRow } = existing; const beforeParsed = selectPrivateLocationSchema.parse(beforeRow); const afterParsed = selectPrivateLocationSchema.parse(row); + const beforeMonitorIds = beforeLinks + .map((l) => l.monitorId) + .filter((id): id is number => id !== null) + .sort((a, b) => a - b); + await emitAudit(tx, ctx, { action: "private_location.update", entityType: "private_location", entityId: afterParsed.id, - before: { - ...beforeParsed, - monitorIds: beforeLinks - .map((l) => l.monitorId) - .filter((id): id is number => id !== null) - .sort((a, b) => a - b), - }, + before: { ...beforeParsed, monitorIds: beforeMonitorIds }, after: { ...afterParsed, - monitorIds: [...monitorIds].sort((a, b) => a - b), + monitorIds: monitorIds + ? [...monitorIds].sort((a, b) => a - b) + : beforeMonitorIds, }, }); -- 2.51.2 From f7f9c85a8d47aba4f59060aac259a4f4c69f1a28 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:53:08 +0200 Subject: [PATCH 006/109] feat: skip monitor endpoint test on save for self-host (#2413) Self-host has no access to the openstatus checker fleet, so the pre-save endpoint test always failed and blocked saving monitors. Gate it behind a new SELF_HOST env flag. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/api/src/env.ts | 2 ++ packages/api/src/router/monitor.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index a7c67f813..77d6c73f4 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -14,6 +14,7 @@ export const env = createEnv({ UNKEY_API_ID: z.string(), SLACK_FEEDBACK_WEBHOOK_URL: z.string().optional(), EXTERNAL_REPORT_SALT: z.string().optional(), + SELF_HOST: z.stringbool().prefault("false"), }, runtimeEnv: { @@ -28,6 +29,7 @@ export const env = createEnv({ UNKEY_API_ID: process.env.UNKEY_API_ID, SLACK_FEEDBACK_WEBHOOK_URL: process.env.SLACK_FEEDBACK_WEBHOOK_URL, EXTERNAL_REPORT_SALT: process.env.EXTERNAL_REPORT_SALT, + SELF_HOST: process.env.SELF_HOST, }, skipValidation: process.env.NODE_ENV === "test", }); diff --git a/packages/api/src/router/monitor.ts b/packages/api/src/router/monitor.ts index d91fc5321..08f0fadfe 100644 --- a/packages/api/src/router/monitor.ts +++ b/packages/api/src/router/monitor.ts @@ -32,10 +32,15 @@ import { } from "@openstatus/services/monitor"; import { z } from "zod"; +import { env } from "../env"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { testDns, testHttp, testTcp } from "./checker"; +// self-host has no access to the openstatus checker fleet, so the pre-save +// endpoint test can never succeed — skip it entirely. +const isSelfHost = env.SELF_HOST; + // tRPC-side input schemas. These preserve the existing wire contract exactly. const headerPair = z.object({ key: z.string(), value: z.string() }); @@ -315,7 +320,7 @@ export const monitorRouter = createTRPCRouter({ // Pre-save endpoint check — kept at the tRPC layer because the // `testHttp` / `testTcp` / `testDns` helpers hit external URLs and // are tRPC-specific UX; services unconditionally save. - if (!input.skipCheck && input.active) { + if (!isSelfHost && !input.skipCheck && input.active) { if (input.jobType === "http") { await testHttp({ url: input.url, @@ -379,7 +384,7 @@ export const monitorRouter = createTRPCRouter({ .input(newMonitorTRPCInput) .mutation(async ({ ctx, input }) => { try { - if (!input.skipCheck) { + if (!isSelfHost && !input.skipCheck) { if (input.jobType === "http") { await testHttp({ url: input.url, -- 2.51.2 From 9f194f45a42abf61ae8e693fb2a37f4feed58226 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:18:47 +0200 Subject: [PATCH 007/109] chore: custom theme addon (#2414) --- .../src/app/(dashboard)/settings/billing/client.tsx | 8 ++++++++ packages/api/src/router/stripe/utils.ts | 11 +++++++++++ packages/db/src/schema/plan/config.ts | 8 ++++++++ packages/db/src/schema/plan/schema.ts | 1 + 4 files changed, 28 insertions(+) diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx index be65811f9..7d35cd48b 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx @@ -212,6 +212,14 @@ export function Client() { workspace={workspace} /> ) : null} + {planAddons["custom-theme"] ? ( + + ) : null} {planAddons["status-pages"] ? ( = { EUR: 300, }, }, + "custom-theme": { + title: "Custom Theme", + description: "Customize your status page colors and appearance.", + price: { + USD: 20, + EUR: 20, + }, + }, "status-pages": { title: "Status Pages", description: "Create and manage status pages for your workspace.", diff --git a/packages/db/src/schema/plan/schema.ts b/packages/db/src/schema/plan/schema.ts index cdb172a56..386ffa73c 100644 --- a/packages/db/src/schema/plan/schema.ts +++ b/packages/db/src/schema/plan/schema.ts @@ -91,6 +91,7 @@ export const addons = [ "email-domain-protection", "ip-restriction", "white-label", + "custom-theme", "status-pages", ] as const satisfies Partial[]; -- 2.51.2 From 7aea98d866fc2da9cd3a01f766bd0b0d729ff2f1 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Thu, 23 Jul 2026 13:39:28 +0000 Subject: [PATCH 008/109] private location: metadata + status (#2415) * private location: metadata + status * ci: apply automated fixes * Apply suggestions from code review Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../settings/private-locations/client.tsx | 4 + .../private-locations/nav-actions.tsx | 3 + .../data-table/private-locations/columns.tsx | 20 + .../data-table-row-actions.tsx | 6 + .../forms/private-location/form.tsx | 84 +- .../__tests__/private-location.test.ts | 107 + .../handlers/private-location/converters.ts | 19 +- .../rpc/handlers/private-location/index.ts | 2 + apps/server/static/openapi.yaml | 102 + apps/workflows/src/cron/index.ts | 5 + .../src/cron/private-location-health.test.ts | 126 + .../src/cron/private-location-health.ts | 158 + .../api/src/router/privateLocation.test.ts | 30 + packages/api/src/router/privateLocation.ts | 3 + packages/db/drizzle/0081_charming_unus.sql | 2 + packages/db/drizzle/meta/0081_snapshot.json | 4832 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../private_locations/private_locations.ts | 8 + .../schema/private_locations/validation.ts | 18 +- .../emails/emails/private-location-alert.tsx | 76 + packages/emails/src/client.tsx | 57 + packages/emails/src/index.ts | 1 + .../v1/private_location.proto | 25 + .../private_location/v1/service.proto | 18 + packages/proto/gen/openapi.yaml | 102 + .../v1/private_location_pb.ts | 69 +- .../private_location/v1/service_pb.ts | 24 +- .../src/agent-tools/private-location.ts | 6 +- .../__tests__/private-location.test.ts | 241 + .../services/src/private-location/create.ts | 4 + .../services/src/private-location/index.ts | 3 + .../services/src/private-location/schemas.ts | 15 + .../src/private-location/set-status.ts | 70 + .../services/src/private-location/update.ts | 15 +- 34 files changed, 6244 insertions(+), 18 deletions(-) create mode 100644 apps/workflows/src/cron/private-location-health.test.ts create mode 100644 apps/workflows/src/cron/private-location-health.ts create mode 100644 packages/db/drizzle/0081_charming_unus.sql create mode 100644 packages/db/drizzle/meta/0081_snapshot.json create mode 100644 packages/emails/emails/private-location-alert.tsx create mode 100644 packages/services/src/private-location/set-status.ts diff --git a/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx index 231f1d473..88624caa7 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx @@ -28,6 +28,8 @@ const EXAMPLES = [ id: 1, name: "Private Location 1", token: "my-secret-token", + status: "active", + metadata: null, createdAt: new Date("2025-01-01"), updatedAt: new Date("2025-01-01"), workspaceId: 1, @@ -39,6 +41,8 @@ const EXAMPLES = [ id: 2, name: "Private Location 2", token: "my-secret-token", + status: "error", + metadata: null, createdAt: new Date("2025-01-01"), updatedAt: new Date("2025-01-01"), workspaceId: 1, diff --git a/apps/dashboard/src/app/(dashboard)/settings/private-locations/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/settings/private-locations/nav-actions.tsx index ee8def266..f68f0a309 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/private-locations/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/private-locations/nav-actions.tsx @@ -49,6 +49,9 @@ export function NavActions() { name: values.name, monitors: values.monitors, token: values.token, + metadata: Object.fromEntries( + values.metadata.map((m) => [m.key, m.value]), + ), }); }} > diff --git a/apps/dashboard/src/components/data-table/private-locations/columns.tsx b/apps/dashboard/src/components/data-table/private-locations/columns.tsx index 19feb6dd6..38d4c6554 100644 --- a/apps/dashboard/src/components/data-table/private-locations/columns.tsx +++ b/apps/dashboard/src/components/data-table/private-locations/columns.tsx @@ -1,6 +1,7 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Badge } from "@openstatus/ui/components/ui/badge"; import type { ColumnDef } from "@tanstack/react-table"; import Link from "next/link"; @@ -20,6 +21,25 @@ export const columns: ColumnDef[] = [ ), enableHiding: false, }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + enableHiding: false, + cell: ({ row }) => { + const status = row.getValue("status"); + const isError = status === "error"; + return ( + + {isError ? "Error" : "Active"} + + ); + }, + }, { accessorKey: "lastSeenAt", header: ({ column }) => ( diff --git a/apps/dashboard/src/components/data-table/private-locations/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/private-locations/data-table-row-actions.tsx index c7c7f229f..59ae3d0cb 100644 --- a/apps/dashboard/src/components/data-table/private-locations/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/private-locations/data-table-row-actions.tsx @@ -61,6 +61,9 @@ export function DataTableRowActions(props: DataTableRowActionsProps) { name: props.row.original.name, token: props.row.original.token.toString(), monitors: props.row.original.monitors.map((m) => m.id), + metadata: Object.entries(props.row.original.metadata ?? {}).map( + ([key, value]) => ({ key, value }), + ), }} monitors={monitors ?? []} onSubmit={async (values) => { @@ -68,6 +71,9 @@ export function DataTableRowActions(props: DataTableRowActionsProps) { id: props.row.original.id, name: values.name, monitors: values.monitors, + metadata: Object.fromEntries( + values.metadata.map((m) => [m.key, m.value]), + ), }); }} > diff --git a/apps/dashboard/src/components/forms/private-location/form.tsx b/apps/dashboard/src/components/forms/private-location/form.tsx index b2bce7302..006206861 100644 --- a/apps/dashboard/src/components/forms/private-location/form.tsx +++ b/apps/dashboard/src/components/forms/private-location/form.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, @@ -20,7 +21,7 @@ import { import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; -import { Check, Copy } from "lucide-react"; +import { Check, Copy, Plus, X } from "lucide-react"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -41,6 +42,18 @@ const schema = z.object({ name: z.string().min(1, "Name is required"), token: z.string(), monitors: z.array(z.number()), + metadata: z + .array( + z.object({ + key: z.string().min(1, "Key is required").max(64), + value: z.string().max(256), + }), + ) + .max(20, "At most 20 metadata entries") + .refine( + (rows) => new Set(rows.map((r) => r.key)).size === rows.length, + "Metadata keys must be unique", + ), }); export type FormValues = z.infer; @@ -62,6 +75,7 @@ export function FormPrivateLocation({ name: "", token: crypto.randomUUID(), monitors: [], + metadata: [], }, }); const [isPending, startTransition] = useTransition(); @@ -193,6 +207,74 @@ export function FormPrivateLocation({ )} /> + + + ( + + Metadata + + Optional key/value labels to store custom information about + this location. + + {field.value.map((entry, index) => ( +
+ { + const next = [...field.value]; + next[index] = { ...next[index], key: e.target.value }; + field.onChange(next); + }} + /> + { + const next = [...field.value]; + next[index] = { ...next[index], value: e.target.value }; + field.onChange(next); + }} + /> + +
+ ))} +
+ +
+ +
+ )} + /> +
); diff --git a/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts b/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts index b9ac28664..791c79d61 100644 --- a/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts +++ b/apps/server/src/routes/rpc/handlers/private-location/__tests__/private-location.test.ts @@ -208,6 +208,30 @@ describe("PrivateLocationService.CreatePrivateLocation", () => { .where(eq(privateLocation.id, Number(data.privateLocation.id))); }); + test("roundtrips metadata and defaults status to error", async () => { + const res = await connectRequest( + "CreatePrivateLocation", + { + name: `${TEST_PREFIX}-metadata`, + metadata: { datacenter: "eu-west", rack: "A3" }, + }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.privateLocation.metadata).toEqual({ + datacenter: "eu-west", + rack: "A3", + }); + expect(data.privateLocation.status).toBe("PRIVATE_LOCATION_STATUS_ERROR"); + + await db + .delete(privateLocation) + .where(eq(privateLocation.id, Number(data.privateLocation.id))); + }); + test("returns 403 for a workspace without the entitlement", async () => { const res = await connectRequest( "CreatePrivateLocation", @@ -352,6 +376,20 @@ describe("PrivateLocationService.ListPrivateLocations", () => { expect(ids).toContain(String(testLocationId)); }); + test("includes status on summaries", async () => { + const res = await connectRequest( + "ListPrivateLocations", + {}, + { "x-openstatus-key": authKey }, + ); + + const data = await res.json(); + const found = data.privateLocations.find( + (l: { id: string }) => l.id === String(testLocationId), + ); + expect(found.status).toBe("PRIVATE_LOCATION_STATUS_ERROR"); + }); + test("reports monitorCount for an attached location", async () => { const res = await connectRequest( "ListPrivateLocations", @@ -488,6 +526,75 @@ describe("PrivateLocationService.UpdatePrivateLocation", () => { await db.delete(privateLocation).where(eq(privateLocation.id, target)); }); + test("replaces metadata when updateMetadata is true", async () => { + const target = await mkLocation( + `${TEST_PREFIX}-meta-update`, + entitledWorkspaceId, + ); + + const res = await connectRequest( + "UpdatePrivateLocation", + { + id: String(target), + metadata: { env: "prod" }, + updateMetadata: true, + }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.privateLocation.metadata).toEqual({ env: "prod" }); + + await db.delete(privateLocation).where(eq(privateLocation.id, target)); + }); + + test("ignores metadata when updateMetadata is absent", async () => { + const target = await mkLocation( + `${TEST_PREFIX}-meta-preserve`, + entitledWorkspaceId, + ); + await db + .update(privateLocation) + .set({ metadata: { keep: "yes" } }) + .where(eq(privateLocation.id, target)); + + const res = await connectRequest( + "UpdatePrivateLocation", + { id: String(target), metadata: { env: "prod" } }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.privateLocation.metadata).toEqual({ keep: "yes" }); + + await db.delete(privateLocation).where(eq(privateLocation.id, target)); + }); + + test("clears metadata with an empty map and the flag set", async () => { + const target = await mkLocation( + `${TEST_PREFIX}-meta-clear`, + entitledWorkspaceId, + ); + await db + .update(privateLocation) + .set({ metadata: { drop: "me" } }) + .where(eq(privateLocation.id, target)); + + const res = await connectRequest( + "UpdatePrivateLocation", + { id: String(target), metadata: {}, updateMetadata: true }, + { "x-openstatus-key": authKey }, + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.privateLocation.metadata ?? {}).toEqual({}); + + await db.delete(privateLocation).where(eq(privateLocation.id, target)); + }); + test("returns 403 for a workspace without the entitlement", async () => { const res = await connectRequest( "UpdatePrivateLocation", diff --git a/apps/server/src/routes/rpc/handlers/private-location/converters.ts b/apps/server/src/routes/rpc/handlers/private-location/converters.ts index 70555f90b..6a7a2d07d 100644 --- a/apps/server/src/routes/rpc/handlers/private-location/converters.ts +++ b/apps/server/src/routes/rpc/handlers/private-location/converters.ts @@ -1,17 +1,26 @@ -import type { - PrivateLocation, - PrivateLocationSummary, +import { + type PrivateLocation, + PrivateLocationStatus, + type PrivateLocationSummary, } from "@openstatus/proto/private_location/v1"; type DBPrivateLocation = { id: number; name: string; token: string; + status: "active" | "error"; + metadata: Record | null; lastSeenAt: Date | null; createdAt: Date | null; updatedAt: Date | null; }; +function toProtoStatus(status: "active" | "error"): PrivateLocationStatus { + return status === "error" + ? PrivateLocationStatus.ERROR + : PrivateLocationStatus.ACTIVE; +} + export function dbPrivateLocationToProto( location: DBPrivateLocation, monitorIds: string[], @@ -25,6 +34,8 @@ export function dbPrivateLocationToProto( lastSeenAt: location.lastSeenAt?.toISOString() ?? "", createdAt: location.createdAt?.toISOString() ?? "", updatedAt: location.updatedAt?.toISOString() ?? "", + metadata: location.metadata ?? {}, + status: toProtoStatus(location.status), }; } @@ -41,5 +52,7 @@ export function dbPrivateLocationToProtoSummary( lastSeenAt: location.lastSeenAt?.toISOString() ?? "", createdAt: location.createdAt?.toISOString() ?? "", updatedAt: location.updatedAt?.toISOString() ?? "", + status: toProtoStatus(location.status), + metadata: location.metadata ?? {}, }; } diff --git a/apps/server/src/routes/rpc/handlers/private-location/index.ts b/apps/server/src/routes/rpc/handlers/private-location/index.ts index b2e7df351..7baf2d1da 100644 --- a/apps/server/src/routes/rpc/handlers/private-location/index.ts +++ b/apps/server/src/routes/rpc/handlers/private-location/index.ts @@ -67,6 +67,7 @@ export const privateLocationServiceImpl: ServiceImpl< input: { name: req.name, monitors: parseMonitorIds(req.monitorIds), + metadata: req.metadata, }, }); @@ -145,6 +146,7 @@ export const privateLocationServiceImpl: ServiceImpl< monitors: req.updateMonitorIds ? parseMonitorIds(req.monitorIds) : undefined, + metadata: req.updateMetadata ? req.metadata : undefined, }, }); diff --git a/apps/server/static/openapi.yaml b/apps/server/static/openapi.yaml index 324d1369a..2099c29f6 100644 --- a/apps/server/static/openapi.yaml +++ b/apps/server/static/openapi.yaml @@ -2342,9 +2342,29 @@ components: type: string title: monitor_ids description: IDs of monitors this private location should run (optional). + metadata: + type: object + title: metadata + maxProperties: 20 + additionalProperties: + type: string + title: value + maxLength: 256 + description: User-defined key/value labels (optional, up to 20 entries). title: CreatePrivateLocationRequest additionalProperties: false description: CreatePrivateLocationRequest is the request to create a new private location. + openstatus.private_location.v1.CreatePrivateLocationRequest.MetadataEntry: + type: object + properties: + key: + type: string + title: key + value: + type: string + title: value + title: MetadataEntry + additionalProperties: false openstatus.private_location.v1.CreatePrivateLocationResponse: type: object properties: @@ -2472,9 +2492,41 @@ components: type: string title: updated_at description: Timestamp when the private location was last updated (RFC 3339 format). + metadata: + type: object + title: metadata + additionalProperties: + type: string + title: value + description: User-defined key/value labels attached to this private location. + status: + title: status + description: Computed health of the agent. Read-only. + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocationStatus' title: PrivateLocation additionalProperties: false description: PrivateLocation represents a self-hosted checker agent with full details. + openstatus.private_location.v1.PrivateLocation.MetadataEntry: + type: object + properties: + key: + type: string + title: key + value: + type: string + title: value + title: MetadataEntry + additionalProperties: false + openstatus.private_location.v1.PrivateLocationStatus: + type: string + title: PrivateLocationStatus + enum: + - PRIVATE_LOCATION_STATUS_UNSPECIFIED + - PRIVATE_LOCATION_STATUS_ACTIVE + - PRIVATE_LOCATION_STATUS_ERROR + description: |- + PrivateLocationStatus is the computed health of a private location, derived + from the agent heartbeat. Read-only — it cannot be set through the API. openstatus.private_location.v1.PrivateLocationSummary: type: object properties: @@ -2503,11 +2555,33 @@ components: type: string title: updated_at description: Timestamp when the private location was last updated (RFC 3339 format). + status: + title: status + description: Computed health of the agent. Read-only. + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocationStatus' + metadata: + type: object + title: metadata + additionalProperties: + type: string + title: value + description: User-defined key/value labels attached to this private location. title: PrivateLocationSummary additionalProperties: false description: |- PrivateLocationSummary represents metadata for a private location (used in list responses). The agent token is intentionally omitted. + openstatus.private_location.v1.PrivateLocationSummary.MetadataEntry: + type: object + properties: + key: + type: string + title: key + value: + type: string + title: value + title: MetadataEntry + additionalProperties: false openstatus.private_location.v1.UpdatePrivateLocationRequest: type: object properties: @@ -2539,9 +2613,37 @@ components: When true, monitor_ids replaces the current associations (an empty list clears all). When false or unset, monitor_ids is ignored and existing associations are preserved. + metadata: + type: object + title: metadata + maxProperties: 20 + additionalProperties: + type: string + title: value + maxLength: 256 + description: New key/value labels. Only applied when update_metadata is true. + updateMetadata: + type: + - boolean + - "null" + title: update_metadata + description: |- + When true, metadata replaces the current labels (an empty map clears all). + When false or unset, metadata is ignored and existing labels are preserved. title: UpdatePrivateLocationRequest additionalProperties: false description: UpdatePrivateLocationRequest is the request to update a private location. + openstatus.private_location.v1.UpdatePrivateLocationRequest.MetadataEntry: + type: object + properties: + key: + type: string + title: key + value: + type: string + title: value + title: MetadataEntry + additionalProperties: false openstatus.private_location.v1.UpdatePrivateLocationResponse: type: object properties: diff --git a/apps/workflows/src/cron/index.ts b/apps/workflows/src/cron/index.ts index f6444ddc5..3a9a9caa2 100644 --- a/apps/workflows/src/cron/index.ts +++ b/apps/workflows/src/cron/index.ts @@ -16,6 +16,7 @@ import { StepPaused, workflowStepSchema, } from "./monitor"; +import { handlePrivateLocationHealthCron } from "./private-location-health"; import { handleUptimeFreezeCron } from "./uptime-freeze"; const app = new Hono({ strict: false }); @@ -98,6 +99,10 @@ app.get("/uptime-freeze", async (c) => { return handleUptimeFreezeCron(c); }); +app.get("/private-location-health", async (c) => { + return handlePrivateLocationHealthCron(c); +}); + app.get("/emails/follow-up", async (c) => { try { await sendFollowUpEmails(); diff --git a/apps/workflows/src/cron/private-location-health.test.ts b/apps/workflows/src/cron/private-location-health.test.ts new file mode 100644 index 000000000..01aa47c86 --- /dev/null +++ b/apps/workflows/src/cron/private-location-health.test.ts @@ -0,0 +1,126 @@ +import { db, eq } from "@openstatus/db"; +import { auditLog, privateLocation } from "@openstatus/db/src/schema"; +import { + afterEach, + assertSpyCalls, + beforeEach, + describe, + expect, + type Stub, + stub, + test, +} from "@openstatus/test-utils"; + +import { + emailClient, + runPrivateLocationHealth, + STALE_THRESHOLD_MS, +} from "./private-location-health"; + +const TEST_LOCATION_ID = 9301; +const TEST_WORKSPACE_ID = 1; +const NOW = new Date("2026-07-23T12:00:00Z"); +const FRESH = new Date(NOW.getTime() - 60 * 1000); +const STALE = new Date(NOW.getTime() - STALE_THRESHOLD_MS - 60 * 1000); + +// biome-ignore lint/suspicious/noExplicitAny: email stub +type AnyStub = Stub; + +async function seedLocation( + status: "active" | "error", + lastSeenAt: Date | null, +) { + await db + .delete(privateLocation) + .where(eq(privateLocation.id, TEST_LOCATION_ID)); + await db + .insert(privateLocation) + .values({ + id: TEST_LOCATION_ID, + name: "health-test-location", + token: "health-test-token", + status, + lastSeenAt, + workspaceId: TEST_WORKSPACE_ID, + createdAt: new Date(), + }) + .run(); +} + +function readStatus() { + return db + .select({ status: privateLocation.status }) + .from(privateLocation) + .where(eq(privateLocation.id, TEST_LOCATION_ID)) + .get(); +} + +describe("runPrivateLocationHealth", () => { + let mockEmail: AnyStub; + + beforeEach(() => { + mockEmail = stub(emailClient, "sendPrivateLocationAlert", () => + Promise.resolve(), + ) as AnyStub; + }); + + afterEach(async () => { + mockEmail.restore(); + await db + .delete(auditLog) + .where(eq(auditLog.entityId, String(TEST_LOCATION_ID))); + await db + .delete(privateLocation) + .where(eq(privateLocation.id, TEST_LOCATION_ID)); + }); + + test("active + stale → error + email", async () => { + await seedLocation("active", STALE); + + await runPrivateLocationHealth(NOW); + + expect((await readStatus())?.status).toBe("error"); + assertSpyCalls(mockEmail, 1); + expect(mockEmail.calls[0].args[0].status).toBe("error"); + expect(mockEmail.calls[0].args[0].locationName).toBe( + "health-test-location", + ); + }); + + test("error + fresh → active + email", async () => { + await seedLocation("error", FRESH); + + await runPrivateLocationHealth(NOW); + + expect((await readStatus())?.status).toBe("active"); + assertSpyCalls(mockEmail, 1); + expect(mockEmail.calls[0].args[0].status).toBe("recovered"); + }); + + test("active + fresh → untouched, no email", async () => { + await seedLocation("active", FRESH); + + await runPrivateLocationHealth(NOW); + + expect((await readStatus())?.status).toBe("active"); + assertSpyCalls(mockEmail, 0); + }); + + test("error + stale → untouched, no email", async () => { + await seedLocation("error", STALE); + + await runPrivateLocationHealth(NOW); + + expect((await readStatus())?.status).toBe("error"); + assertSpyCalls(mockEmail, 0); + }); + + test("lastSeenAt NULL → skipped regardless of status", async () => { + await seedLocation("error", null); + + await runPrivateLocationHealth(NOW); + + expect((await readStatus())?.status).toBe("error"); + assertSpyCalls(mockEmail, 0); + }); +}); diff --git a/apps/workflows/src/cron/private-location-health.ts b/apps/workflows/src/cron/private-location-health.ts new file mode 100644 index 000000000..b6a56fa22 --- /dev/null +++ b/apps/workflows/src/cron/private-location-health.ts @@ -0,0 +1,158 @@ +import { getLogger } from "@logtape/logtape"; +import { db, eq, isNotNull } from "@openstatus/db"; +import { + privateLocation, + selectWorkspaceSchema, + user, + usersToWorkspaces, + workspace, +} from "@openstatus/db/src/schema"; +import { EmailClient } from "@openstatus/emails"; +import { setPrivateLocationStatus } from "@openstatus/services/private-location"; +import type { Context } from "hono"; + +import { env } from "../env"; +import { reportBackgroundError, runSentryCron } from "../lib/sentry"; + +const logger = getLogger(["workflow", "private-location-health"]); + +export const emailClient = new EmailClient({ apiKey: env().RESEND_API_KEY }); + +export const STALE_THRESHOLD_MS = 15 * 60 * 1000; + +type HealthSummary = { checked: number; toError: number; toActive: number }; + +/** + * Flip private-location status from the agent heartbeat and email members on + * every transition. Locations that have never reported (`lastSeenAt IS NULL`) + * stay `active` and are skipped. + */ +export async function runPrivateLocationHealth( + now: Date = new Date(), +): Promise { + const locations = await db + .select({ + id: privateLocation.id, + name: privateLocation.name, + status: privateLocation.status, + lastSeenAt: privateLocation.lastSeenAt, + workspaceId: privateLocation.workspaceId, + }) + .from(privateLocation) + .where(isNotNull(privateLocation.lastSeenAt)) + .all(); + + const summary: HealthSummary = { + checked: locations.length, + toError: 0, + toActive: 0, + }; + + for (const location of locations) { + if (location.lastSeenAt === null || location.workspaceId === null) continue; + + const stale = + now.getTime() - location.lastSeenAt.getTime() > STALE_THRESHOLD_MS; + const nextStatus = + location.status === "active" && stale + ? "error" + : location.status === "error" && !stale + ? "active" + : null; + + if (nextStatus === null) continue; + + const workspaceRow = await db + .select() + .from(workspace) + .where(eq(workspace.id, location.workspaceId)) + .get(); + if (!workspaceRow) continue; + + await setPrivateLocationStatus({ + ctx: { + workspace: selectWorkspaceSchema.parse(workspaceRow), + actor: { type: "system", job: "private-location-health" }, + }, + input: { id: location.id, status: nextStatus }, + }); + + if (nextStatus === "error") summary.toError++; + else summary.toActive++; + + await notifyMembers({ + workspaceId: location.workspaceId, + locationName: location.name, + status: nextStatus === "error" ? "error" : "recovered", + lastSeenAt: location.lastSeenAt, + }); + } + + return summary; +} + +async function notifyMembers(args: { + workspaceId: number; + locationName: string; + status: "error" | "recovered"; + lastSeenAt: Date; +}) { + const members = await db + .select({ email: user.email }) + .from(usersToWorkspaces) + .innerJoin(user, eq(usersToWorkspaces.userId, user.id)) + .where(eq(usersToWorkspaces.workspaceId, args.workspaceId)) + .all(); + + const to = members + .map((m) => m.email) + .filter((email): email is string => !!email && email.trim() !== ""); + + if (to.length === 0) return; + + try { + await emailClient.sendPrivateLocationAlert({ + to, + locationName: args.locationName, + status: args.status, + lastSeenAt: args.lastSeenAt, + }); + } catch (error) { + logger.error("Failed to send private location alert", { + workspace_id: args.workspaceId, + location_name: args.locationName, + error_message: error instanceof Error ? error.message : String(error), + }); + void reportBackgroundError( + `private-location-health: email failed for ${args.locationName}`, + ); + } +} + +export async function handlePrivateLocationHealthCron(c: Context) { + const { cronCompleted, cronFailed } = runSentryCron( + "private-location-health", + ); + + // Background chain: must not capture `c` — the handler returns 200 before + // this resolves (see uptime-freeze.ts). + void runPrivateLocationHealth() + .then((summary) => { + logger.info( + "private-location-health complete: checked={checked} toError={toError} toActive={toActive}", + summary, + ); + void cronCompleted(); + }) + .catch((error) => { + logger.error("private-location-health errored: {message}", { + message: error instanceof Error ? error.message : String(error), + }); + void reportBackgroundError( + `private-location-health failed: ${error instanceof Error ? error.message : String(error)}`, + ); + void cronFailed(); + }); + + return c.json({ success: true }, 200); +} diff --git a/packages/api/src/router/privateLocation.test.ts b/packages/api/src/router/privateLocation.test.ts index c621f3514..9c0737d21 100644 --- a/packages/api/src/router/privateLocation.test.ts +++ b/packages/api/src/router/privateLocation.test.ts @@ -201,3 +201,33 @@ test("privateLocation.update rejects monitors from another workspace", async () expect((e as TRPCError).code).toBe("FORBIDDEN"); } }); + +test("privateLocation.new persists metadata and defaults status", async () => { + const caller = callerFor(); + + const result = await caller.privateLocation.new({ + name: "Metadata Location", + token: "test-token-metadata", + monitors: [], + metadata: { datacenter: "eu-west" }, + }); + + expect(result.metadata).toEqual({ datacenter: "eu-west" }); + expect(result.status).toBe("error"); + + await db.delete(privateLocation).where(eq(privateLocation.id, result.id)); +}); + +test("privateLocation.update replaces metadata", async () => { + const caller = callerFor(); + + const result = await caller.privateLocation.update({ + id: ownWorkspaceLocationId, + name: "Metadata Update", + monitors: [ownMonitorId], + metadata: { env: "prod" }, + }); + + expect(result.metadata).toEqual({ env: "prod" }); + expect(result.status).toBe("error"); +}); diff --git a/packages/api/src/router/privateLocation.ts b/packages/api/src/router/privateLocation.ts index 0ae5f821b..cef0f2844 100644 --- a/packages/api/src/router/privateLocation.ts +++ b/packages/api/src/router/privateLocation.ts @@ -31,6 +31,7 @@ export const privateLocationRouter = createTRPCRouter({ name: z.string(), monitors: z.array(z.number()), token: z.string(), + metadata: z.record(z.string(), z.string()).optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -41,6 +42,7 @@ export const privateLocationRouter = createTRPCRouter({ name: input.name, token: input.token, monitors: input.monitors, + metadata: input.metadata, }, }); } catch (err) { @@ -54,6 +56,7 @@ export const privateLocationRouter = createTRPCRouter({ id: z.number(), name: z.string(), monitors: z.array(z.number()), + metadata: z.record(z.string(), z.string()).optional(), }), ) .mutation(async ({ ctx, input }) => { diff --git a/packages/db/drizzle/0081_charming_unus.sql b/packages/db/drizzle/0081_charming_unus.sql new file mode 100644 index 000000000..809068d5b --- /dev/null +++ b/packages/db/drizzle/0081_charming_unus.sql @@ -0,0 +1,2 @@ +ALTER TABLE `private_location` ADD `status` text DEFAULT 'error' NOT NULL;--> statement-breakpoint +ALTER TABLE `private_location` ADD `metadata` text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0081_snapshot.json b/packages/db/drizzle/meta/0081_snapshot.json new file mode 100644 index 000000000..454c88621 --- /dev/null +++ b/packages/db/drizzle/meta/0081_snapshot.json @@ -0,0 +1,4832 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a48b4954-9494-47cd-97ef-c71992f445ad", + "prevId": "dd6a23ac-b31e-45b7-ad9a-d4a3929c3382", + "tables": { + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_until": { + "name": "paid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "limits": { + "name": "limits", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "workspace_stripe_id_unique": { + "name": "workspace_stripe_id_unique", + "columns": [ + "stripe_id" + ], + "isUnique": true + }, + "workspace_id_dsn_unique": { + "name": "workspace_id_dsn_unique", + "columns": [ + "id", + "dsn" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": [ + "provider", + "provider_account_id" + ], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_tenant_id_unique": { + "name": "user_tenant_id_unique", + "columns": [ + "tenant_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_to_workspaces": { + "name": "users_to_workspaces", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "users_to_workspaces_workspace_id_idx": { + "name": "users_to_workspaces_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_workspaces_user_id_user_id_fk": { + "name": "users_to_workspaces_user_id_user_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_to_workspaces_workspace_id_workspace_id_fk": { + "name": "users_to_workspaces_workspace_id_workspace_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_workspaces_user_id_workspace_id_pk": { + "columns": [ + "user_id", + "workspace_id" + ], + "name": "users_to_workspaces_user_id_workspace_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_token": { + "name": "verification_token", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verification_token_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report": { + "name": "status_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_workspace_created_idx": { + "name": "status_report_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "status_report_page_id_idx": { + "name": "status_report_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_workspace_id_workspace_id_fk": { + "name": "status_report_workspace_id_workspace_id_fk", + "tableFrom": "status_report", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_report_page_id_page_id_fk": { + "name": "status_report_page_id_page_id_fk", + "tableFrom": "status_report", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_update": { + "name": "status_report_update", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_status_report_id_idx": { + "name": "status_report_update_status_report_id_idx", + "columns": [ + "status_report_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_status_report_id_status_report_id_fk": { + "name": "status_report_update_status_report_id_status_report_id_fk", + "tableFrom": "status_report_update", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential": { + "name": "credential", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_workspace_id_idx": { + "name": "integration_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_workspace_id_workspace_id_fk": { + "name": "integration_workspace_id_workspace_id_fk", + "tableFrom": "integration", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page": { + "name": "page", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "slug": { + "name": "slug", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "force_theme": { + "name": "force_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "custom_theme": { + "name": "custom_theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_protected": { + "name": "password_protected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'public'" + }, + "auth_email_domains": { + "name": "auth_email_domains", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ip_ranges": { + "name": "allowed_ip_ranges", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homepage_url": { + "name": "homepage_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_url": { + "name": "contact_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "locales": { + "name": "locales", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_page": { + "name": "legacy_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_index": { + "name": "allow_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_monitor_values": { + "name": "show_monitor_values", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_slug_unique": { + "name": "page_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "page_lower_slug_idx": { + "name": "page_lower_slug_idx", + "columns": [ + "LOWER(\"slug\")" + ], + "isUnique": false + }, + "page_lower_custom_domain_idx": { + "name": "page_lower_custom_domain_idx", + "columns": [ + "LOWER(\"custom_domain\")" + ], + "isUnique": false + }, + "page_workspace_id_idx": { + "name": "page_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_workspace_id_workspace_id_fk": { + "name": "page_workspace_id_workspace_id_fk", + "tableFrom": "page", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor": { + "name": "monitor", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "periodicity": { + "name": "periodicity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "external_name": { + "name": "external_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 45000 + }, + "degraded_after": { + "name": "degraded_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assertions": { + "name": "assertions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_endpoint": { + "name": "otel_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_headers": { + "name": "otel_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "retry": { + "name": "retry", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "follow_redirects": { + "name": "follow_redirects", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_workspace_id_active_idx": { + "name": "monitor_workspace_id_active_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false, + "where": "\"monitor\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "monitor_workspace_id_workspace_id_fk": { + "name": "monitor_workspace_id_workspace_id_fk", + "tableFrom": "monitor", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_subscriber": { + "name": "page_subscriber", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_type": { + "name": "channel_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_config": { + "name": "channel_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'self_signup'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "idx_page_subscriber_email_page_active": { + "name": "idx_page_subscriber_email_page_active", + "columns": [ + "LOWER(\"email\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'email'" + }, + "idx_page_subscriber_webhook_page_active": { + "name": "idx_page_subscriber_webhook_page_active", + "columns": [ + "LOWER(\"webhook_url\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'webhook'" + }, + "idx_page_subscriber_slack_channel_page_active": { + "name": "idx_page_subscriber_slack_channel_page_active", + "columns": [ + "slack_channel_id", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'slack'" + } + }, + "foreignKeys": { + "page_subscriber_page_id_page_id_fk": { + "name": "page_subscriber_page_id_page_id_fk", + "tableFrom": "page_subscriber", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_subscriber_channel_check": { + "name": "page_subscriber_channel_check", + "value": "(\"page_subscriber\".\"channel_type\" = 'email' AND \"page_subscriber\".\"email\" IS NOT NULL AND \"page_subscriber\".\"webhook_url\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'webhook' AND \"page_subscriber\".\"webhook_url\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'slack' AND \"page_subscriber\".\"slack_channel_id\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL AND \"page_subscriber\".\"webhook_url\" IS NULL)" + } + } + }, + "page_subscriber_to_page_component": { + "name": "page_subscriber_to_page_component", + "columns": { + "page_subscriber_id": { + "name": "page_subscriber_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk": { + "name": "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_subscriber", + "columnsFrom": [ + "page_subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_subscriber_to_page_component_page_component_id_page_component_id_fk": { + "name": "page_subscriber_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk": { + "columns": [ + "page_subscriber_id", + "page_component_id" + ], + "name": "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notification_workspace_id_idx": { + "name": "notification_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_workspace_id_workspace_id_fk": { + "name": "notification_workspace_id_workspace_id_fk", + "tableFrom": "notification", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_trigger": { + "name": "notification_trigger", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_id_monitor_id_crontimestampe": { + "name": "notification_id_monitor_id_crontimestampe", + "columns": [ + "notification_id", + "monitor_id", + "cron_timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_trigger_monitor_id_monitor_id_fk": { + "name": "notification_trigger_monitor_id_monitor_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_trigger_notification_id_notification_id_fk": { + "name": "notification_trigger_notification_id_notification_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications_to_monitors": { + "name": "notifications_to_monitors", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notifications_to_monitors_notification_id_idx": { + "name": "notifications_to_monitors_notification_id_idx", + "columns": [ + "notification_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_to_monitors_monitor_id_monitor_id_fk": { + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_to_monitors_notification_id_notification_id_fk": { + "name": "notifications_to_monitors_notification_id_notification_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notifications_to_monitors_monitor_id_notification_id_pk": { + "columns": [ + "monitor_id", + "notification_id" + ], + "name": "notifications_to_monitors_monitor_id_notification_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_status": { + "name": "monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_status_idx": { + "name": "monitor_status_idx", + "columns": [ + "monitor_id", + "region" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_status_monitor_id_monitor_id_fk": { + "name": "monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_status_monitor_id_region_pk": { + "columns": [ + "monitor_id", + "region" + ], + "name": "monitor_status_monitor_id_region_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invitation_workspace_id_idx": { + "name": "invitation_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incident": { + "name": "incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'triage'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "incident_screenshot_url": { + "name": "incident_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recovery_screenshot_url": { + "name": "recovery_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_resolved": { + "name": "auto_resolved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "incident_workspace_id_idx": { + "name": "incident_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "incident_monitor_id_started_at_unique": { + "name": "incident_monitor_id_started_at_unique", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "incident_monitor_id_monitor_id_fk": { + "name": "incident_monitor_id_monitor_id_fk", + "tableFrom": "incident", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set default", + "onUpdate": "no action" + }, + "incident_workspace_id_workspace_id_fk": { + "name": "incident_workspace_id_workspace_id_fk", + "tableFrom": "incident", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_acknowledged_by_user_id_fk": { + "name": "incident_acknowledged_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "acknowledged_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_resolved_by_user_id_fk": { + "name": "incident_resolved_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag": { + "name": "monitor_tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_workspace_id_idx": { + "name": "monitor_tag_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_workspace_id_workspace_id_fk": { + "name": "monitor_tag_workspace_id_workspace_id_fk", + "tableFrom": "monitor_tag", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag_to_monitor": { + "name": "monitor_tag_to_monitor", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_tag_id": { + "name": "monitor_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_to_monitor_monitor_tag_id_idx": { + "name": "monitor_tag_to_monitor_monitor_tag_id_idx", + "columns": [ + "monitor_tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor_tag", + "columnsFrom": [ + "monitor_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { + "columns": [ + "monitor_id", + "monitor_tag_id" + ], + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "application": { + "name": "application", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "application_dsn_unique": { + "name": "application_dsn_unique", + "columns": [ + "dsn" + ], + "isUnique": true + }, + "application_workspace_id_idx": { + "name": "application_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "application_workspace_id_workspace_id_fk": { + "name": "application_workspace_id_workspace_id_fk", + "tableFrom": "application", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance": { + "name": "maintenance", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_page_id_idx": { + "name": "maintenance_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "maintenance_workspace_id_idx": { + "name": "maintenance_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_workspace_id_workspace_id_fk": { + "name": "maintenance_workspace_id_workspace_id_fk", + "tableFrom": "maintenance", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "maintenance_page_id_page_id_fk": { + "name": "maintenance_page_id_page_id_fk", + "tableFrom": "maintenance", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "check": { + "name": "check", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "count_requests": { + "name": "count_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "check_workspace_id_idx": { + "name": "check_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "check_workspace_id_workspace_id_fk": { + "name": "check_workspace_id_workspace_id_fk", + "tableFrom": "check", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_run": { + "name": "monitor_run", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runned_at": { + "name": "runned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_run_workspace_id_idx": { + "name": "monitor_run_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_run_monitor_id_idx": { + "name": "monitor_run_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_run_workspace_id_workspace_id_fk": { + "name": "monitor_run_workspace_id_workspace_id_fk", + "tableFrom": "monitor_run", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "monitor_run_monitor_id_monitor_id_fk": { + "name": "monitor_run_monitor_id_monitor_id_fk", + "tableFrom": "monitor_run", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_monitor_status": { + "name": "private_location_monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_monitor_status_pl_id_idx": { + "name": "private_location_monitor_status_pl_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_monitor_status_monitor_id_monitor_id_fk": { + "name": "private_location_monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_monitor_status_private_location_id_private_location_id_fk": { + "name": "private_location_monitor_status_private_location_id_private_location_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "private_location_monitor_status_monitor_id_private_location_id_pk": { + "columns": [ + "monitor_id", + "private_location_id" + ], + "name": "private_location_monitor_status_monitor_id_private_location_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location": { + "name": "private_location", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'error'" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_workspace_id_idx": { + "name": "private_location_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_workspace_id_workspace_id_fk": { + "name": "private_location_workspace_id_workspace_id_fk", + "tableFrom": "private_location", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_to_monitor": { + "name": "private_location_to_monitor", + "columns": { + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "private_location_to_monitor_private_location_id_idx": { + "name": "private_location_to_monitor_private_location_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + }, + "private_location_to_monitor_monitor_id_idx": { + "name": "private_location_to_monitor_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_to_monitor_private_location_id_private_location_id_fk": { + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_to_monitor_monitor_id_monitor_id_fk": { + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_group": { + "name": "monitor_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_group_workspace_id_idx": { + "name": "monitor_group_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_group_page_id_idx": { + "name": "monitor_group_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_group_workspace_id_workspace_id_fk": { + "name": "monitor_group_workspace_id_workspace_id_fk", + "tableFrom": "monitor_group", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_group_page_id_page_id_fk": { + "name": "monitor_group_page_id_page_id_fk", + "tableFrom": "monitor_group", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer": { + "name": "viewer", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "viewer_email_unique": { + "name": "viewer_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_accounts": { + "name": "viewer_accounts", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_accounts_user_id_viewer_id_fk": { + "name": "viewer_accounts_user_id_viewer_id_fk", + "tableFrom": "viewer_accounts", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "viewer_accounts_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "viewer_accounts_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_session": { + "name": "viewer_session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_session_user_id_viewer_id_fk": { + "name": "viewer_session_user_id_viewer_id_fk", + "tableFrom": "viewer_session", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hashed_token": { + "name": "hashed_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"write\"]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_key_prefix_unique": { + "name": "api_key_prefix_unique", + "columns": [ + "prefix" + ], + "isUnique": true + }, + "api_key_hashed_token_unique": { + "name": "api_key_hashed_token_unique", + "columns": [ + "hashed_token" + ], + "isUnique": true + }, + "api_key_prefix_idx": { + "name": "api_key_prefix_idx", + "columns": [ + "prefix" + ], + "isUnique": false + }, + "api_key_workspace_id_idx": { + "name": "api_key_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_id_user_id_fk": { + "name": "api_key_created_by_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_to_page_component": { + "name": "maintenance_to_page_component", + "columns": { + "maintenance_id": { + "name": "maintenance_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_to_page_component_page_component_id_idx": { + "name": "maintenance_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_to_page_component_maintenance_id_maintenance_id_fk": { + "name": "maintenance_to_page_component_maintenance_id_maintenance_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "maintenance", + "columnsFrom": [ + "maintenance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_to_page_component_page_component_id_page_component_id_fk": { + "name": "maintenance_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "maintenance_to_page_component_maintenance_id_page_component_id_pk": { + "columns": [ + "maintenance_id", + "page_component_id" + ], + "name": "maintenance_to_page_component_maintenance_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component": { + "name": "page_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monitor'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_order": { + "name": "group_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_workspace_id_idx": { + "name": "page_component_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "page_component_page_id_monitor_id_unique": { + "name": "page_component_page_id_monitor_id_unique", + "columns": [ + "page_id", + "monitor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "page_component_workspace_id_workspace_id_fk": { + "name": "page_component_workspace_id_workspace_id_fk", + "tableFrom": "page_component", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_page_id_page_id_fk": { + "name": "page_component_page_id_page_id_fk", + "tableFrom": "page_component", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_monitor_id_monitor_id_fk": { + "name": "page_component_monitor_id_monitor_id_fk", + "tableFrom": "page_component", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_group_id_page_component_groups_id_fk": { + "name": "page_component_group_id_page_component_groups_id_fk", + "tableFrom": "page_component", + "tableTo": "page_component_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_component_type_check": { + "name": "page_component_type_check", + "value": "\"page_component\".\"type\" = 'monitor' AND \"page_component\".\"monitor_id\" IS NOT NULL OR \"page_component\".\"type\" = 'static' AND \"page_component\".\"monitor_id\" IS NULL" + } + } + }, + "status_report_update_to_page_component": { + "name": "status_report_update_to_page_component", + "columns": { + "status_report_update_id": { + "name": "status_report_update_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_to_page_component_page_component_id_idx": { + "name": "status_report_update_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk": { + "name": "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "status_report_update", + "columnsFrom": [ + "status_report_update_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_update_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_update_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_update_to_page_component_status_report_update_id_page_component_id_pk": { + "columns": [ + "status_report_update_id", + "page_component_id" + ], + "name": "status_report_update_to_page_component_status_report_update_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_to_page_component": { + "name": "status_report_to_page_component", + "columns": { + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_to_page_component_page_component_id_idx": { + "name": "status_report_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_to_page_component_status_report_id_status_report_id_fk": { + "name": "status_report_to_page_component_status_report_id_status_report_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_to_page_component_status_report_id_page_component_id_pk": { + "columns": [ + "status_report_id", + "page_component_id" + ], + "name": "status_report_to_page_component_status_report_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component_groups": { + "name": "page_component_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_open": { + "name": "default_open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_groups_page_id_idx": { + "name": "page_component_groups_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "page_component_groups_workspace_id_idx": { + "name": "page_component_groups_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_component_groups_workspace_id_workspace_id_fk": { + "name": "page_component_groups_workspace_id_workspace_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_groups_page_id_page_id_fk": { + "name": "page_component_groups_page_id_page_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocker": { + "name": "blocker", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "feedback_workspace_id_idx": { + "name": "feedback_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "feedback_workspace_id_workspace_id_fk": { + "name": "feedback_workspace_id_workspace_id_fk", + "tableFrom": "feedback", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_user_id_user_id_fk": { + "name": "feedback_user_id_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_fields": { + "name": "changed_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + "workspace_id", + "entity_type", + "entity_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service": { + "name": "external_service", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_page_url": { + "name": "status_page_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_config": { + "name": "api_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_slug_unique": { + "name": "external_service_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "external_service_deleted_at_idx": { + "name": "external_service_deleted_at_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_component": { + "name": "external_service_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_component_id": { + "name": "upstream_component_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "indicator": { + "name": "indicator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_component_unique_idx": { + "name": "external_service_component_unique_idx", + "columns": [ + "external_service_id", + "upstream_component_id" + ], + "isUnique": true + }, + "external_service_component_slug_unique_idx": { + "name": "external_service_component_slug_unique_idx", + "columns": [ + "external_service_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_service_component_external_service_id_external_service_id_fk": { + "name": "external_service_component_external_service_id_external_service_id_fk", + "tableFrom": "external_service_component", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_incident": { + "name": "external_service_incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_incident_id": { + "name": "provider_incident_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortlink": { + "name": "shortlink", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affected_component_ids": { + "name": "affected_component_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload_purged_at": { + "name": "raw_payload_purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_incident_unique_idx": { + "name": "external_service_incident_unique_idx", + "columns": [ + "external_service_id", + "provider_incident_id" + ], + "isUnique": true + }, + "external_service_incident_started_at_idx": { + "name": "external_service_incident_started_at_idx", + "columns": [ + "external_service_id", + "started_at" + ], + "isUnique": false + }, + "external_service_incident_resolved_at_idx": { + "name": "external_service_incident_resolved_at_idx", + "columns": [ + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_incident_external_service_id_external_service_id_fk": { + "name": "external_service_incident_external_service_id_external_service_id_fk", + "tableFrom": "external_service_incident", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_report": { + "name": "external_service_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_service_component_id": { + "name": "external_service_component_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_hash": { + "name": "reporter_hash", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text(2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_report_service_idx": { + "name": "external_service_report_service_idx", + "columns": [ + "external_service_id", + "created_at" + ], + "isUnique": false + }, + "external_service_report_component_idx": { + "name": "external_service_report_component_idx", + "columns": [ + "external_service_component_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_report_external_service_id_external_service_id_fk": { + "name": "external_service_report_external_service_id_external_service_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_service_report_external_service_component_id_external_service_component_id_fk": { + "name": "external_service_report_external_service_component_id_external_service_component_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service_component", + "columnsFrom": [ + "external_service_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_session": { + "name": "chat_session", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_session_workspace_user_updated_idx": { + "name": "chat_session_workspace_user_updated_idx", + "columns": [ + "workspace_id", + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_session_workspace_id_workspace_id_fk": { + "name": "chat_session_workspace_id_workspace_id_fk", + "tableFrom": "chat_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_session_user_id_user_id_fk": { + "name": "chat_session_user_id_user_id_fk", + "tableFrom": "chat_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "frozen_monitor_uptime": { + "name": "frozen_monitor_uptime", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "days": { + "name": "days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "frozen_monitor_uptime_workspace_id_idx": { + "name": "frozen_monitor_uptime_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "frozen_monitor_uptime_monitor_id_month_unique": { + "name": "frozen_monitor_uptime_monitor_id_month_unique", + "columns": [ + "monitor_id", + "month" + ], + "isUnique": true + } + }, + "foreignKeys": { + "frozen_monitor_uptime_workspace_id_workspace_id_fk": { + "name": "frozen_monitor_uptime_workspace_id_workspace_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frozen_monitor_uptime_monitor_id_monitor_id_fk": { + "name": "frozen_monitor_uptime_monitor_id_monitor_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "page_lower_slug_idx": { + "columns": { + "LOWER(\"slug\")": { + "isExpression": true + } + } + }, + "page_lower_custom_domain_idx": { + "columns": { + "LOWER(\"custom_domain\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_email_page_active": { + "columns": { + "LOWER(\"email\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_webhook_page_active": { + "columns": { + "LOWER(\"webhook_url\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index a7653aa83..ec8427483 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -568,6 +568,13 @@ "when": 1784059243113, "tag": "0080_soft_norman_osborn", "breakpoints": true + }, + { + "idx": 81, + "version": "6", + "when": 1784808228044, + "tag": "0081_charming_unus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/private_locations/private_locations.ts b/packages/db/src/schema/private_locations/private_locations.ts index 996269ca4..0fc6e0554 100644 --- a/packages/db/src/schema/private_locations/private_locations.ts +++ b/packages/db/src/schema/private_locations/private_locations.ts @@ -5,12 +5,20 @@ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { monitor } from "../monitors/monitor"; import { workspace } from "../workspaces"; +export const privateLocationStatus = ["active", "error"] as const; + export const privateLocation = sqliteTable( "private_location", { id: integer("id").primaryKey(), name: text("name").notNull(), token: text("token").notNull(), + status: text("status", { enum: privateLocationStatus }) + .default("error") + .notNull(), + metadata: text("metadata", { mode: "json" }).$type< + Record + >(), lastSeenAt: integer("last_seen_at", { mode: "timestamp" }), workspaceId: integer("workspace_id").references(() => workspace.id), createdAt: integer("created_at", { mode: "timestamp" }).default( diff --git a/packages/db/src/schema/private_locations/validation.ts b/packages/db/src/schema/private_locations/validation.ts index 3c8f542d3..30296fbef 100644 --- a/packages/db/src/schema/private_locations/validation.ts +++ b/packages/db/src/schema/private_locations/validation.ts @@ -1,12 +1,22 @@ import { createInsertSchema, createSelectSchema } from "drizzle-zod"; -import type { z } from "zod"; +import { z } from "zod"; import { privateLocationMonitorStatus } from "./private_location_monitor_status"; -import { privateLocation } from "./private_locations"; +import { privateLocation, privateLocationStatus } from "./private_locations"; -export const insertPrivateLocationSchema = createInsertSchema(privateLocation); +export const privateLocationStatusSchema = z.enum(privateLocationStatus); -export const selectPrivateLocationSchema = createSelectSchema(privateLocation); +const privateLocationMetadataSchema = z.record(z.string(), z.string()); + +export const insertPrivateLocationSchema = createInsertSchema(privateLocation, { + status: privateLocationStatusSchema.prefault("error"), + metadata: privateLocationMetadataSchema.nullish(), +}); + +export const selectPrivateLocationSchema = createSelectSchema(privateLocation, { + status: privateLocationStatusSchema, + metadata: privateLocationMetadataSchema.nullable(), +}); export type InsertPrivateLocation = z.infer; export type PrivateLocation = z.infer; diff --git a/packages/emails/emails/private-location-alert.tsx b/packages/emails/emails/private-location-alert.tsx new file mode 100644 index 000000000..37e4eee7a --- /dev/null +++ b/packages/emails/emails/private-location-alert.tsx @@ -0,0 +1,76 @@ +/** @jsxRuntime automatic @jsxImportSource react */ + +import { Body, Button, Head, Heading, Html, Preview, Text } from "react-email"; +import { z } from "zod"; + +import { Layout } from "./_components/layout"; +import { colors, styles } from "./_components/styles"; + +const PrivateLocationAlertSchema = z.object({ + locationName: z.string(), + status: z.enum(["error", "recovered"]), + lastSeenAt: z.string(), +}); + +export type PrivateLocationAlertProps = z.infer< + typeof PrivateLocationAlertSchema +>; + +const PrivateLocationAlertEmail = (props: PrivateLocationAlertProps) => { + const isError = props.status === "error"; + const preview = isError + ? `Your private location "${props.locationName}" is unhealthy` + : `Your private location "${props.locationName}" is healthy again`; + + return ( + + + {preview} + + + + {preview} + + {isError ? ( + + The agent for {props.locationName} has not + reported a result in over 15 minutes, so checks scheduled on this + location are not running. It was last seen at {props.lastSeenAt}. + + ) : ( + + The agent for {props.locationName} is reporting + again and checks on this location have resumed. It was last seen + at {props.lastSeenAt}. + + )} + + {isError + ? "Check that the agent is running and can reach OpenStatus, then verify its token and network access." + : "No action is needed."} + + + + + If you have any questions, please reply to this email. + + + + ); +}; + +PrivateLocationAlertEmail.PreviewProps = { + locationName: "eu-west-agent", + status: "error", + lastSeenAt: "2026-07-23T10:00:00Z", +} satisfies PrivateLocationAlertProps; + +export default PrivateLocationAlertEmail; diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index 721671f86..d14664815 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -8,6 +8,8 @@ import FollowUpEmail from "../emails/followup"; import type { MonitorAlertProps } from "../emails/monitor-alert"; import PageSubscriptionEmail from "../emails/page-subscription"; import type { PageSubscriptionProps } from "../emails/page-subscription"; +import PrivateLocationAlertEmail from "../emails/private-location-alert"; +import type { PrivateLocationAlertProps } from "../emails/private-location-alert"; import SlackFeedbackEmail from "../emails/slack-feedback"; import StatusPageMagicLinkEmail from "../emails/status-page-magic-link"; import type { StatusPageMagicLinkProps } from "../emails/status-page-magic-link"; @@ -445,4 +447,59 @@ export class EmailClient { `Sent maintenance notification email to ${req.subscribers.length} subscribers`, ); } + + public async sendPrivateLocationAlert( + req: Omit & { + to: string[]; + lastSeenAt: Date; + }, + ) { + if (req.to.length === 0) return; + + const subject = + req.status === "error" + ? `Your private location "${req.locationName}" is unhealthy` + : `Your private location "${req.locationName}" is healthy again`; + + if (process.env.NODE_ENV === "development") { + console.log( + `Sending private location ${req.status} email to ${req.to.join(", ")}`, + ); + return; + } + + try { + const html = await render( + , + ); + const result = await this.client.batch.send( + req.to.map((to) => ({ + from: "OpenStatus ", + subject, + to, + html, + })), + ); + + if (result.error) { + if (result.error?.name === "rate_limit_exceeded") { + throw result.error; + } + console.error( + `Error sending private location alert to ${req.to}: ${result.error}`, + ); + return; + } + + console.log(`Sent private location ${req.status} email to ${req.to}`); + } catch (err) { + console.error( + `Error sending private location alert to ${req.to}: ${err}`, + ); + } + } } diff --git a/packages/emails/src/index.ts b/packages/emails/src/index.ts index d8c9f0ddd..b77c282c0 100644 --- a/packages/emails/src/index.ts +++ b/packages/emails/src/index.ts @@ -7,6 +7,7 @@ export { default as WelcomeEmail } from "../emails/welcome"; export { default as TeamInvitationEmail } from "../emails/team-invitation"; export { default as MonitorPausedEmail } from "../emails/monitor-paused"; export { default as MonitorDeactivationEmail } from "../emails/monitor-deactivation"; +export { default as PrivateLocationAlertEmail } from "../emails/private-location-alert"; export { default as StatusPageMagicLinkEmail } from "../emails/status-page-magic-link"; export { monitorDeactivationEmail } from "../hotfix/monitor-deactivation"; diff --git a/packages/proto/api/openstatus/private_location/v1/private_location.proto b/packages/proto/api/openstatus/private_location/v1/private_location.proto index cb0c63e30..4e27cf0a8 100644 --- a/packages/proto/api/openstatus/private_location/v1/private_location.proto +++ b/packages/proto/api/openstatus/private_location/v1/private_location.proto @@ -4,6 +4,19 @@ package openstatus.private_location.v1; option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/private_location/v1;privatelocationv1"; +// PrivateLocationStatus is the computed health of a private location, derived +// from the agent heartbeat. Read-only — it cannot be set through the API. +enum PrivateLocationStatus { + // PRIVATE_LOCATION_STATUS_UNSPECIFIED indicates an unknown status. + PRIVATE_LOCATION_STATUS_UNSPECIFIED = 0; + + // PRIVATE_LOCATION_STATUS_ACTIVE means the agent is reporting normally. + PRIVATE_LOCATION_STATUS_ACTIVE = 1; + + // PRIVATE_LOCATION_STATUS_ERROR means the agent has stopped reporting. + PRIVATE_LOCATION_STATUS_ERROR = 2; +} + // PrivateLocation represents a self-hosted checker agent with full details. message PrivateLocation { // Unique identifier for the private location. @@ -27,6 +40,12 @@ message PrivateLocation { // Timestamp when the private location was last updated (RFC 3339 format). string updated_at = 7; + + // User-defined key/value labels attached to this private location. + map metadata = 8; + + // Computed health of the agent. Read-only. + PrivateLocationStatus status = 9; } // PrivateLocationSummary represents metadata for a private location (used in @@ -49,4 +68,10 @@ message PrivateLocationSummary { // Timestamp when the private location was last updated (RFC 3339 format). string updated_at = 6; + + // Computed health of the agent. Read-only. + PrivateLocationStatus status = 7; + + // User-defined key/value labels attached to this private location. + map metadata = 8; } diff --git a/packages/proto/api/openstatus/private_location/v1/service.proto b/packages/proto/api/openstatus/private_location/v1/service.proto index 6f80c5783..25c5fc997 100644 --- a/packages/proto/api/openstatus/private_location/v1/service.proto +++ b/packages/proto/api/openstatus/private_location/v1/service.proto @@ -51,6 +51,13 @@ message CreatePrivateLocationRequest { // IDs of monitors this private location should run (optional). repeated string monitor_ids = 2; + + // User-defined key/value labels (optional, up to 20 entries). + map metadata = 3 [(buf.validate.field).map = { + max_pairs: 20 + keys: {string: {min_len: 1, max_len: 64}} + values: {string: {max_len: 256}} + }]; } // CreatePrivateLocationResponse is the response after creating a private location. @@ -110,6 +117,17 @@ message UpdatePrivateLocationRequest { // clears all). When false or unset, monitor_ids is ignored and existing // associations are preserved. optional bool update_monitor_ids = 4; + + // New key/value labels. Only applied when update_metadata is true. + map metadata = 5 [(buf.validate.field).map = { + max_pairs: 20 + keys: {string: {min_len: 1, max_len: 64}} + values: {string: {max_len: 256}} + }]; + + // When true, metadata replaces the current labels (an empty map clears all). + // When false or unset, metadata is ignored and existing labels are preserved. + optional bool update_metadata = 6; } // UpdatePrivateLocationResponse is the response after updating a private location. diff --git a/packages/proto/gen/openapi.yaml b/packages/proto/gen/openapi.yaml index 324d1369a..2099c29f6 100644 --- a/packages/proto/gen/openapi.yaml +++ b/packages/proto/gen/openapi.yaml @@ -2342,9 +2342,29 @@ components: type: string title: monitor_ids description: IDs of monitors this private location should run (optional). + metadata: + type: object + title: metadata + maxProperties: 20 + additionalProperties: + type: string + title: value + maxLength: 256 + description: User-defined key/value labels (optional, up to 20 entries). title: CreatePrivateLocationRequest additionalProperties: false description: CreatePrivateLocationRequest is the request to create a new private location. + openstatus.private_location.v1.CreatePrivateLocationRequest.MetadataEntry: + type: object + properties: + key: + type: string + title: key + value: + type: string + title: value + title: MetadataEntry + additionalProperties: false openstatus.private_location.v1.CreatePrivateLocationResponse: type: object properties: @@ -2472,9 +2492,41 @@ components: type: string title: updated_at description: Timestamp when the private location was last updated (RFC 3339 format). + metadata: + type: object + title: metadata + additionalProperties: + type: string + title: value + description: User-defined key/value labels attached to this private location. + status: + title: status + description: Computed health of the agent. Read-only. + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocationStatus' title: PrivateLocation additionalProperties: false description: PrivateLocation represents a self-hosted checker agent with full details. + openstatus.private_location.v1.PrivateLocation.MetadataEntry: + type: object + properties: + key: + type: string + title: key + value: + type: string + title: value + title: MetadataEntry + additionalProperties: false + openstatus.private_location.v1.PrivateLocationStatus: + type: string + title: PrivateLocationStatus + enum: + - PRIVATE_LOCATION_STATUS_UNSPECIFIED + - PRIVATE_LOCATION_STATUS_ACTIVE + - PRIVATE_LOCATION_STATUS_ERROR + description: |- + PrivateLocationStatus is the computed health of a private location, derived + from the agent heartbeat. Read-only — it cannot be set through the API. openstatus.private_location.v1.PrivateLocationSummary: type: object properties: @@ -2503,11 +2555,33 @@ components: type: string title: updated_at description: Timestamp when the private location was last updated (RFC 3339 format). + status: + title: status + description: Computed health of the agent. Read-only. + $ref: '#/components/schemas/openstatus.private_location.v1.PrivateLocationStatus' + metadata: + type: object + title: metadata + additionalProperties: + type: string + title: value + description: User-defined key/value labels attached to this private location. title: PrivateLocationSummary additionalProperties: false description: |- PrivateLocationSummary represents metadata for a private location (used in list responses). The agent token is intentionally omitted. + openstatus.private_location.v1.PrivateLocationSummary.MetadataEntry: + type: object + properties: + key: + type: string + title: key + value: + type: string + title: value + title: MetadataEntry + additionalProperties: false openstatus.private_location.v1.UpdatePrivateLocationRequest: type: object properties: @@ -2539,9 +2613,37 @@ components: When true, monitor_ids replaces the current associations (an empty list clears all). When false or unset, monitor_ids is ignored and existing associations are preserved. + metadata: + type: object + title: metadata + maxProperties: 20 + additionalProperties: + type: string + title: value + maxLength: 256 + description: New key/value labels. Only applied when update_metadata is true. + updateMetadata: + type: + - boolean + - "null" + title: update_metadata + description: |- + When true, metadata replaces the current labels (an empty map clears all). + When false or unset, metadata is ignored and existing labels are preserved. title: UpdatePrivateLocationRequest additionalProperties: false description: UpdatePrivateLocationRequest is the request to update a private location. + openstatus.private_location.v1.UpdatePrivateLocationRequest.MetadataEntry: + type: object + properties: + key: + type: string + title: key + value: + type: string + title: value + title: MetadataEntry + additionalProperties: false openstatus.private_location.v1.UpdatePrivateLocationResponse: type: object properties: diff --git a/packages/proto/gen/ts/openstatus/private_location/v1/private_location_pb.ts b/packages/proto/gen/ts/openstatus/private_location/v1/private_location_pb.ts index 1618cef8f..0c81780c1 100644 --- a/packages/proto/gen/ts/openstatus/private_location/v1/private_location_pb.ts +++ b/packages/proto/gen/ts/openstatus/private_location/v1/private_location_pb.ts @@ -2,15 +2,15 @@ // @generated from file openstatus/private_location/v1/private_location.proto (package openstatus.private_location.v1, syntax proto3) /* eslint-disable */ -import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; -import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; import type { Message } from "@bufbuild/protobuf"; /** * Describes the file openstatus/private_location/v1/private_location.proto. */ export const file_openstatus_private_location_v1_private_location: GenFile = /*@__PURE__*/ - fileDesc("CjVvcGVuc3RhdHVzL3ByaXZhdGVfbG9jYXRpb24vdjEvcHJpdmF0ZV9sb2NhdGlvbi5wcm90bxIeb3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxIo0BCg9Qcml2YXRlTG9jYXRpb24SCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRINCgV0b2tlbhgDIAEoCRITCgttb25pdG9yX2lkcxgEIAMoCRIUCgxsYXN0X3NlZW5fYXQYBSABKAkSEgoKY3JlYXRlZF9hdBgGIAEoCRISCgp1cGRhdGVkX2F0GAcgASgJIocBChZQcml2YXRlTG9jYXRpb25TdW1tYXJ5EgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkSFQoNbW9uaXRvcl9jb3VudBgDIAEoBRIUCgxsYXN0X3NlZW5fYXQYBCABKAkSEgoKY3JlYXRlZF9hdBgFIAEoCRISCgp1cGRhdGVkX2F0GAYgASgJQmRaYmdpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9wcml2YXRlX2xvY2F0aW9uL3YxO3ByaXZhdGVsb2NhdGlvbnYxYgZwcm90bzM"); + fileDesc("CjVvcGVuc3RhdHVzL3ByaXZhdGVfbG9jYXRpb24vdjEvcHJpdmF0ZV9sb2NhdGlvbi5wcm90bxIeb3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxItYCCg9Qcml2YXRlTG9jYXRpb24SCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRINCgV0b2tlbhgDIAEoCRITCgttb25pdG9yX2lkcxgEIAMoCRIUCgxsYXN0X3NlZW5fYXQYBSABKAkSEgoKY3JlYXRlZF9hdBgGIAEoCRISCgp1cGRhdGVkX2F0GAcgASgJEk8KCG1ldGFkYXRhGAggAygLMj0ub3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxLlByaXZhdGVMb2NhdGlvbi5NZXRhZGF0YUVudHJ5EkUKBnN0YXR1cxgJIAEoDjI1Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5Qcml2YXRlTG9jYXRpb25TdGF0dXMaLwoNTWV0YWRhdGFFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBItcCChZQcml2YXRlTG9jYXRpb25TdW1tYXJ5EgoKAmlkGAEgASgJEgwKBG5hbWUYAiABKAkSFQoNbW9uaXRvcl9jb3VudBgDIAEoBRIUCgxsYXN0X3NlZW5fYXQYBCABKAkSEgoKY3JlYXRlZF9hdBgFIAEoCRISCgp1cGRhdGVkX2F0GAYgASgJEkUKBnN0YXR1cxgHIAEoDjI1Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5Qcml2YXRlTG9jYXRpb25TdGF0dXMSVgoIbWV0YWRhdGEYCCADKAsyRC5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuUHJpdmF0ZUxvY2F0aW9uU3VtbWFyeS5NZXRhZGF0YUVudHJ5Gi8KDU1ldGFkYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASqHAQoVUHJpdmF0ZUxvY2F0aW9uU3RhdHVzEicKI1BSSVZBVEVfTE9DQVRJT05fU1RBVFVTX1VOU1BFQ0lGSUVEEAASIgoeUFJJVkFURV9MT0NBVElPTl9TVEFUVVNfQUNUSVZFEAESIQodUFJJVkFURV9MT0NBVElPTl9TVEFUVVNfRVJST1IQAkJkWmJnaXRodWIuY29tL29wZW5zdGF0dXNocS9vcGVuc3RhdHVzL3BhY2thZ2VzL3Byb3RvL29wZW5zdGF0dXMvcHJpdmF0ZV9sb2NhdGlvbi92MTtwcml2YXRlbG9jYXRpb252MWIGcHJvdG8z"); /** * PrivateLocation represents a self-hosted checker agent with full details. @@ -67,6 +67,20 @@ export type PrivateLocation = Message<"openstatus.private_location.v1.PrivateLoc * @generated from field: string updated_at = 7; */ updatedAt: string; + + /** + * User-defined key/value labels attached to this private location. + * + * @generated from field: map metadata = 8; + */ + metadata: { [key: string]: string }; + + /** + * Computed health of the agent. Read-only. + * + * @generated from field: openstatus.private_location.v1.PrivateLocationStatus status = 9; + */ + status: PrivateLocationStatus; }; /** @@ -124,6 +138,20 @@ export type PrivateLocationSummary = Message<"openstatus.private_location.v1.Pri * @generated from field: string updated_at = 6; */ updatedAt: string; + + /** + * Computed health of the agent. Read-only. + * + * @generated from field: openstatus.private_location.v1.PrivateLocationStatus status = 7; + */ + status: PrivateLocationStatus; + + /** + * User-defined key/value labels attached to this private location. + * + * @generated from field: map metadata = 8; + */ + metadata: { [key: string]: string }; }; /** @@ -133,3 +161,38 @@ export type PrivateLocationSummary = Message<"openstatus.private_location.v1.Pri export const PrivateLocationSummarySchema: GenMessage = /*@__PURE__*/ messageDesc(file_openstatus_private_location_v1_private_location, 1); +/** + * PrivateLocationStatus is the computed health of a private location, derived + * from the agent heartbeat. Read-only — it cannot be set through the API. + * + * @generated from enum openstatus.private_location.v1.PrivateLocationStatus + */ +export enum PrivateLocationStatus { + /** + * PRIVATE_LOCATION_STATUS_UNSPECIFIED indicates an unknown status. + * + * @generated from enum value: PRIVATE_LOCATION_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * PRIVATE_LOCATION_STATUS_ACTIVE means the agent is reporting normally. + * + * @generated from enum value: PRIVATE_LOCATION_STATUS_ACTIVE = 1; + */ + ACTIVE = 1, + + /** + * PRIVATE_LOCATION_STATUS_ERROR means the agent has stopped reporting. + * + * @generated from enum value: PRIVATE_LOCATION_STATUS_ERROR = 2; + */ + ERROR = 2, +} + +/** + * Describes the enum openstatus.private_location.v1.PrivateLocationStatus. + */ +export const PrivateLocationStatusSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_openstatus_private_location_v1_private_location, 0); + diff --git a/packages/proto/gen/ts/openstatus/private_location/v1/service_pb.ts b/packages/proto/gen/ts/openstatus/private_location/v1/service_pb.ts index a8d4c7395..32d98ad17 100644 --- a/packages/proto/gen/ts/openstatus/private_location/v1/service_pb.ts +++ b/packages/proto/gen/ts/openstatus/private_location/v1/service_pb.ts @@ -14,7 +14,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file openstatus/private_location/v1/service.proto. */ export const file_openstatus_private_location_v1_service: GenFile = /*@__PURE__*/ - fileDesc("CixvcGVuc3RhdHVzL3ByaXZhdGVfbG9jYXRpb24vdjEvc2VydmljZS5wcm90bxIeb3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxImEKHENyZWF0ZVByaXZhdGVMb2NhdGlvblJlcXVlc3QSLAoEbmFtZRgBIAEoCUIeukcROg8SDWV1LXdlc3QtYWdlbnS6SAdyBRABGIACEhMKC21vbml0b3JfaWRzGAIgAygJImoKHUNyZWF0ZVByaXZhdGVMb2NhdGlvblJlc3BvbnNlEkkKEHByaXZhdGVfbG9jYXRpb24YASABKAsyLy5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuUHJpdmF0ZUxvY2F0aW9uIjAKGUdldFByaXZhdGVMb2NhdGlvblJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEiZwoaR2V0UHJpdmF0ZUxvY2F0aW9uUmVzcG9uc2USSQoQcHJpdmF0ZV9sb2NhdGlvbhgBIAEoCzIvLm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5Qcml2YXRlTG9jYXRpb24ibwobTGlzdFByaXZhdGVMb2NhdGlvbnNSZXF1ZXN0Eh0KBWxpbWl0GAEgASgFQgm6SAYaBBhkKAFIAIgBARIcCgZvZmZzZXQYAiABKAVCB7pIBBoCKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCKFAQocTGlzdFByaXZhdGVMb2NhdGlvbnNSZXNwb25zZRJRChFwcml2YXRlX2xvY2F0aW9ucxgBIAMoCzI2Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5Qcml2YXRlTG9jYXRpb25TdW1tYXJ5EhIKCnRvdGFsX3NpemUYAiABKAUiqAEKHFVwZGF0ZVByaXZhdGVMb2NhdGlvblJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESHQoEbmFtZRgCIAEoCUIKukgHcgUQARiAAkgAiAEBEhMKC21vbml0b3JfaWRzGAMgAygJEh8KEnVwZGF0ZV9tb25pdG9yX2lkcxgEIAEoCEgBiAEBQgcKBV9uYW1lQhUKE191cGRhdGVfbW9uaXRvcl9pZHMiagodVXBkYXRlUHJpdmF0ZUxvY2F0aW9uUmVzcG9uc2USSQoQcHJpdmF0ZV9sb2NhdGlvbhgBIAEoCzIvLm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5Qcml2YXRlTG9jYXRpb24iMwocRGVsZXRlUHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIwCh1EZWxldGVQcml2YXRlTG9jYXRpb25SZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIMvIHChZQcml2YXRlTG9jYXRpb25TZXJ2aWNlEv0CChVDcmVhdGVQcml2YXRlTG9jYXRpb24SPC5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuQ3JlYXRlUHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBo9Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5DcmVhdGVQcml2YXRlTG9jYXRpb25SZXNwb25zZSLmAbpH4gEa3wFDcmVhdGVzIGEgcHJpdmF0ZSBsb2NhdGlvbi4gVGhlIGFnZW50IHRva2VuIGlzIGdlbmVyYXRlZCBieSB0aGUgc2VydmVyIGFuZCByZXR1cm5lZCBpbiB0aGUgcmVzcG9uc2UgLSBpdCBjYW5ub3QgYmUgc3VwcGxpZWQgYnkgdGhlIGNhbGxlci4gVXNlIHRoZSB0b2tlbiB0byBjb25maWd1cmUgdGhlIGFnZW50IHNvIGl0IGNhbiBwdWxsIGl0cyBtb25pdG9ycyBhbmQgcmVwb3J0IHJlc3VsdHMuEpABChJHZXRQcml2YXRlTG9jYXRpb24SOS5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuR2V0UHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBo6Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5HZXRQcml2YXRlTG9jYXRpb25SZXNwb25zZSIDkAIBEpYBChRMaXN0UHJpdmF0ZUxvY2F0aW9ucxI7Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5MaXN0UHJpdmF0ZUxvY2F0aW9uc1JlcXVlc3QaPC5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuTGlzdFByaXZhdGVMb2NhdGlvbnNSZXNwb25zZSIDkAIBEpQBChVVcGRhdGVQcml2YXRlTG9jYXRpb24SPC5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuVXBkYXRlUHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBo9Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5VcGRhdGVQcml2YXRlTG9jYXRpb25SZXNwb25zZRKUAQoVRGVsZXRlUHJpdmF0ZUxvY2F0aW9uEjwub3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxLkRlbGV0ZVByaXZhdGVMb2NhdGlvblJlcXVlc3QaPS5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuRGVsZXRlUHJpdmF0ZUxvY2F0aW9uUmVzcG9uc2VCZFpiZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL3ByaXZhdGVfbG9jYXRpb24vdjE7cHJpdmF0ZWxvY2F0aW9udjFiBnByb3RvMw", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_private_location_v1_private_location]); + fileDesc("CixvcGVuc3RhdHVzL3ByaXZhdGVfbG9jYXRpb24vdjEvc2VydmljZS5wcm90bxIeb3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxIokCChxDcmVhdGVQcml2YXRlTG9jYXRpb25SZXF1ZXN0EiwKBG5hbWUYASABKAlCHrpHEToPEg1ldS13ZXN0LWFnZW50ukgHcgUQARiAAhITCgttb25pdG9yX2lkcxgCIAMoCRJ1CghtZXRhZGF0YRgDIAMoCzJKLm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5DcmVhdGVQcml2YXRlTG9jYXRpb25SZXF1ZXN0Lk1ldGFkYXRhRW50cnlCF7pIFJoBERAUIgZyBBABGEAqBXIDGIACGi8KDU1ldGFkYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJqCh1DcmVhdGVQcml2YXRlTG9jYXRpb25SZXNwb25zZRJJChBwcml2YXRlX2xvY2F0aW9uGAEgASgLMi8ub3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxLlByaXZhdGVMb2NhdGlvbiIwChlHZXRQcml2YXRlTG9jYXRpb25SZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABImcKGkdldFByaXZhdGVMb2NhdGlvblJlc3BvbnNlEkkKEHByaXZhdGVfbG9jYXRpb24YASABKAsyLy5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuUHJpdmF0ZUxvY2F0aW9uIm8KG0xpc3RQcml2YXRlTG9jYXRpb25zUmVxdWVzdBIdCgVsaW1pdBgBIAEoBUIJukgGGgQYZCgBSACIAQESHAoGb2Zmc2V0GAIgASgFQge6SAQaAigASAGIAQFCCAoGX2xpbWl0QgkKB19vZmZzZXQihQEKHExpc3RQcml2YXRlTG9jYXRpb25zUmVzcG9uc2USUQoRcHJpdmF0ZV9sb2NhdGlvbnMYASADKAsyNi5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuUHJpdmF0ZUxvY2F0aW9uU3VtbWFyeRISCgp0b3RhbF9zaXplGAIgASgFIoIDChxVcGRhdGVQcml2YXRlTG9jYXRpb25SZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEh0KBG5hbWUYAiABKAlCCrpIB3IFEAEYgAJIAIgBARITCgttb25pdG9yX2lkcxgDIAMoCRIfChJ1cGRhdGVfbW9uaXRvcl9pZHMYBCABKAhIAYgBARJ1CghtZXRhZGF0YRgFIAMoCzJKLm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5VcGRhdGVQcml2YXRlTG9jYXRpb25SZXF1ZXN0Lk1ldGFkYXRhRW50cnlCF7pIFJoBERAUIgZyBBABGEAqBXIDGIACEhwKD3VwZGF0ZV9tZXRhZGF0YRgGIAEoCEgCiAEBGi8KDU1ldGFkYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIHCgVfbmFtZUIVChNfdXBkYXRlX21vbml0b3JfaWRzQhIKEF91cGRhdGVfbWV0YWRhdGEiagodVXBkYXRlUHJpdmF0ZUxvY2F0aW9uUmVzcG9uc2USSQoQcHJpdmF0ZV9sb2NhdGlvbhgBIAEoCzIvLm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5Qcml2YXRlTG9jYXRpb24iMwocRGVsZXRlUHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIwCh1EZWxldGVQcml2YXRlTG9jYXRpb25SZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIMvIHChZQcml2YXRlTG9jYXRpb25TZXJ2aWNlEv0CChVDcmVhdGVQcml2YXRlTG9jYXRpb24SPC5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuQ3JlYXRlUHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBo9Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5DcmVhdGVQcml2YXRlTG9jYXRpb25SZXNwb25zZSLmAbpH4gEa3wFDcmVhdGVzIGEgcHJpdmF0ZSBsb2NhdGlvbi4gVGhlIGFnZW50IHRva2VuIGlzIGdlbmVyYXRlZCBieSB0aGUgc2VydmVyIGFuZCByZXR1cm5lZCBpbiB0aGUgcmVzcG9uc2UgLSBpdCBjYW5ub3QgYmUgc3VwcGxpZWQgYnkgdGhlIGNhbGxlci4gVXNlIHRoZSB0b2tlbiB0byBjb25maWd1cmUgdGhlIGFnZW50IHNvIGl0IGNhbiBwdWxsIGl0cyBtb25pdG9ycyBhbmQgcmVwb3J0IHJlc3VsdHMuEpABChJHZXRQcml2YXRlTG9jYXRpb24SOS5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuR2V0UHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBo6Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5HZXRQcml2YXRlTG9jYXRpb25SZXNwb25zZSIDkAIBEpYBChRMaXN0UHJpdmF0ZUxvY2F0aW9ucxI7Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5MaXN0UHJpdmF0ZUxvY2F0aW9uc1JlcXVlc3QaPC5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuTGlzdFByaXZhdGVMb2NhdGlvbnNSZXNwb25zZSIDkAIBEpQBChVVcGRhdGVQcml2YXRlTG9jYXRpb24SPC5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuVXBkYXRlUHJpdmF0ZUxvY2F0aW9uUmVxdWVzdBo9Lm9wZW5zdGF0dXMucHJpdmF0ZV9sb2NhdGlvbi52MS5VcGRhdGVQcml2YXRlTG9jYXRpb25SZXNwb25zZRKUAQoVRGVsZXRlUHJpdmF0ZUxvY2F0aW9uEjwub3BlbnN0YXR1cy5wcml2YXRlX2xvY2F0aW9uLnYxLkRlbGV0ZVByaXZhdGVMb2NhdGlvblJlcXVlc3QaPS5vcGVuc3RhdHVzLnByaXZhdGVfbG9jYXRpb24udjEuRGVsZXRlUHJpdmF0ZUxvY2F0aW9uUmVzcG9uc2VCZFpiZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL3ByaXZhdGVfbG9jYXRpb24vdjE7cHJpdmF0ZWxvY2F0aW9udjFiBnByb3RvMw", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_private_location_v1_private_location]); /** * CreatePrivateLocationRequest is the request to create a new private location. @@ -35,6 +35,13 @@ export type CreatePrivateLocationRequest = Message<"openstatus.private_location. * @generated from field: repeated string monitor_ids = 2; */ monitorIds: string[]; + + /** + * User-defined key/value labels (optional, up to 20 entries). + * + * @generated from field: map metadata = 3; + */ + metadata: { [key: string]: string }; }; /** @@ -198,6 +205,21 @@ export type UpdatePrivateLocationRequest = Message<"openstatus.private_location. * @generated from field: optional bool update_monitor_ids = 4; */ updateMonitorIds?: boolean | undefined; + + /** + * New key/value labels. Only applied when update_metadata is true. + * + * @generated from field: map metadata = 5; + */ + metadata: { [key: string]: string }; + + /** + * When true, metadata replaces the current labels (an empty map clears all). + * When false or unset, metadata is ignored and existing labels are preserved. + * + * @generated from field: optional bool update_metadata = 6; + */ + updateMetadata?: boolean | undefined; }; /** diff --git a/packages/services/src/agent-tools/private-location.ts b/packages/services/src/agent-tools/private-location.ts index 003cda944..c1d74103d 100644 --- a/packages/services/src/agent-tools/private-location.ts +++ b/packages/services/src/agent-tools/private-location.ts @@ -31,6 +31,8 @@ const ListPrivateLocationsOutput = z.object({ z.object({ id: z.number().int(), name: z.string(), + status: z.enum(["active", "error"]), + metadata: z.record(z.string(), z.string()).nullable(), monitorIds: z.array(z.number().int()), lastSeenAt: z.string().nullable(), createdAt: z.string().nullable(), @@ -51,7 +53,7 @@ export const listPrivateLocationsTool: AgentTool< > = { name: "list_private_locations", description: - "List private locations (self-hosted checker agents) in this workspace, with the monitors each one runs and when it last reported in. Read-only — agent tokens are NOT exposed. Use `lastSeenAt` to tell whether an agent is still alive before blaming a monitor for missing checks.", + 'List private locations (self-hosted checker agents) in this workspace, with the monitors each one runs and when it last reported in. Read-only — agent tokens are NOT exposed. `status: "error"` means the agent is not currently healthy (it may not have reported yet or may have a stale heartbeat); use `lastSeenAt` to tell whether an agent is still alive before blaming a monitor for missing checks. `metadata` holds user-defined key/value labels.', scope: "read", destructive: false, inputSchema: ListPrivateLocationsInputShape, @@ -69,6 +71,8 @@ export const listPrivateLocationsTool: AgentTool< items: result.items.map((location) => ({ id: location.id, name: location.name, + status: location.status, + metadata: location.metadata ?? null, monitorIds: location.monitors.map((m) => m.id), lastSeenAt: location.lastSeenAt?.toISOString() ?? null, createdAt: location.createdAt?.toISOString() ?? null, diff --git a/packages/services/src/private-location/__tests__/private-location.test.ts b/packages/services/src/private-location/__tests__/private-location.test.ts index 93e3cafd6..08c0fd585 100644 --- a/packages/services/src/private-location/__tests__/private-location.test.ts +++ b/packages/services/src/private-location/__tests__/private-location.test.ts @@ -25,6 +25,7 @@ import { createPrivateLocation } from "../create"; import { deletePrivateLocation } from "../delete"; import { getPrivateLocation } from "../get"; import { listPrivateLocations } from "../list"; +import { setPrivateLocationStatus } from "../set-status"; import { updatePrivateLocation } from "../update"; const TEST_PREFIX = "svc-private-location-test"; @@ -217,6 +218,84 @@ describe("createPrivateLocation", () => { }); }); + test("persists metadata and audits it", async () => { + await withTestTransaction(async (tx) => { + const row = await createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-metadata`, + monitors: [], + metadata: { datacenter: "eu-west", rack: "A3" }, + }, + }); + + expect(row.metadata).toEqual({ datacenter: "eu-west", rack: "A3" }); + + const audit = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "private_location", + entityId: String(row.id), + db: tx, + }); + const created = audit.find((r) => r.action === "private_location.create"); + expect( + (created?.after as { metadata?: Record } | undefined) + ?.metadata, + ).toEqual({ datacenter: "eu-west", rack: "A3" }); + }); + }); + + test("stores null metadata when none is supplied", async () => { + await withTestTransaction(async (tx) => { + const row = await createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { name: `${TEST_PREFIX}-no-metadata`, monitors: [] }, + }); + + expect(row.metadata).toBeNull(); + }); + }); + + test("rejects metadata beyond the allowed limits", async () => { + await withTestTransaction(async (tx) => { + const tooMany = Object.fromEntries( + Array.from({ length: 21 }, (_, i) => [`k${i}`, "v"]), + ); + await expect( + createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-too-many`, + monitors: [], + metadata: tooMany, + }, + }), + ).rejects.toBeTruthy(); + + await expect( + createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-long-value`, + monitors: [], + metadata: { k: "x".repeat(257) }, + }, + }), + ).rejects.toBeTruthy(); + }); + }); + + test("defaults status to error until the agent reports in", async () => { + await withTestTransaction(async (tx) => { + const row = await createPrivateLocation({ + ctx: { ...teamCtx, db: tx }, + input: { name: `${TEST_PREFIX}-status-default`, monitors: [] }, + }); + + expect(row.status).toBe("error"); + }); + }); + test("rejects read-only actor", async () => { await withTestTransaction(async (tx) => { const ctx = { @@ -370,6 +449,75 @@ describe("updatePrivateLocation", () => { }); }); + test("replaces metadata and audits the change", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await createPrivateLocation({ + ctx, + input: { + name: `${TEST_PREFIX}-meta-seed`, + monitors: [], + metadata: { a: "1" }, + }, + }); + + const updated = await updatePrivateLocation({ + ctx, + input: { id: created.id, metadata: { b: "2", c: "3" } }, + }); + expect(updated.metadata).toEqual({ b: "2", c: "3" }); + + const audit = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "private_location", + entityId: String(created.id), + db: tx, + }); + const row = audit.find((r) => r.action === "private_location.update"); + expect(row?.changedFields).toContain("metadata"); + }); + }); + + test("clears metadata when given an empty object", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await createPrivateLocation({ + ctx, + input: { + name: `${TEST_PREFIX}-meta-clear`, + monitors: [], + metadata: { a: "1" }, + }, + }); + + const updated = await updatePrivateLocation({ + ctx, + input: { id: created.id, metadata: {} }, + }); + expect(updated.metadata).toBeNull(); + }); + }); + + test("preserves metadata when the field is omitted", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await createPrivateLocation({ + ctx, + input: { + name: `${TEST_PREFIX}-meta-preserve`, + monitors: [], + metadata: { a: "1" }, + }, + }); + + const updated = await updatePrivateLocation({ + ctx, + input: { id: created.id, name: `${TEST_PREFIX}-meta-renamed` }, + }); + expect(updated.metadata).toEqual({ a: "1" }); + }); + }); + test("rejects read-only actor", async () => { await withTestTransaction(async (tx) => { const ctx = { @@ -387,6 +535,99 @@ describe("updatePrivateLocation", () => { }); }); +describe("setPrivateLocationStatus", () => { + test("flips status and audits the change", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-set-status`, monitors: [] }, + }); + expect(created.status).toBe("error"); + + const updated = await setPrivateLocationStatus({ + ctx, + input: { id: created.id, status: "active" }, + }); + expect(updated.status).toBe("active"); + + const audit = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "private_location", + entityId: String(created.id), + db: tx, + }); + const row = audit.find((r) => r.action === "private_location.update"); + expect(row?.changedFields).toContain("status"); + }); + }); + + test("is a no-op when the status is unchanged", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const created = await createPrivateLocation({ + ctx, + input: { name: `${TEST_PREFIX}-set-status-noop`, monitors: [] }, + }); + + await setPrivateLocationStatus({ + ctx, + input: { id: created.id, status: "error" }, + }); + + const rows = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "private_location", + entityId: String(created.id), + db: tx, + }); + expect(rows.some((r) => r.action === "private_location.update")).toBe( + false, + ); + }); + }); + + test("throws NotFoundError for another workspace's location", async () => { + await withTestTransaction(async (tx) => { + const other = await tx + .insert(privateLocation) + .values({ + name: `${TEST_PREFIX}-set-status-foreign`, + token: "foreign-token-status", + workspaceId: otherWorkspaceId, + }) + .returning() + .get(); + + await expect( + setPrivateLocationStatus({ + ctx: { ...teamCtx, db: tx }, + input: { id: other.id, status: "error" }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); + + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + const ctx = { + ...makeApiKeyCtx(teamCtx.workspace, { + keyId: "k", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + await expect( + setPrivateLocationStatus({ + ctx, + input: { id: 1, status: "error" }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); + describe("getPrivateLocation", () => { test("returns the location with its monitors", async () => { await withTestTransaction(async (tx) => { diff --git a/packages/services/src/private-location/create.ts b/packages/services/src/private-location/create.ts index 22d2e98d2..a8e5339e8 100644 --- a/packages/services/src/private-location/create.ts +++ b/packages/services/src/private-location/create.ts @@ -48,6 +48,10 @@ export async function createPrivateLocation(args: { .values({ name: input.name, token, + metadata: + input.metadata && Object.keys(input.metadata).length > 0 + ? input.metadata + : null, workspaceId: ctx.workspace.id, }) .returning() diff --git a/packages/services/src/private-location/index.ts b/packages/services/src/private-location/index.ts index 83fb2d5ec..fd393e6b9 100644 --- a/packages/services/src/private-location/index.ts +++ b/packages/services/src/private-location/index.ts @@ -2,11 +2,14 @@ export { createPrivateLocation } from "./create"; export { deletePrivateLocation } from "./delete"; export { getPrivateLocation } from "./get"; export { listPrivateLocations } from "./list"; +export { setPrivateLocationStatus } from "./set-status"; export { updatePrivateLocation } from "./update"; export { CreatePrivateLocationInput, DeletePrivateLocationInput, GetPrivateLocationInput, ListPrivateLocationsInput, + PrivateLocationMetadata, + SetPrivateLocationStatusInput, UpdatePrivateLocationInput, } from "./schemas"; diff --git a/packages/services/src/private-location/schemas.ts b/packages/services/src/private-location/schemas.ts index 1db4ae5c6..6cb07a625 100644 --- a/packages/services/src/private-location/schemas.ts +++ b/packages/services/src/private-location/schemas.ts @@ -12,10 +12,16 @@ export type ListPrivateLocationsInput = z.infer< const monitorIds = z.array(z.number().int().positive()); +export const PrivateLocationMetadata = z + .record(z.string().min(1).max(64), z.string().max(256)) + .refine((m) => Object.keys(m).length <= 20, "max 20 metadata entries"); +export type PrivateLocationMetadata = z.infer; + export const CreatePrivateLocationInput = z.object({ name: z.string().min(1), token: z.string().min(1).optional(), monitors: monitorIds, + metadata: PrivateLocationMetadata.optional(), }); export type CreatePrivateLocationInput = z.infer< typeof CreatePrivateLocationInput @@ -25,11 +31,20 @@ export const UpdatePrivateLocationInput = z.object({ id: z.number().int(), name: z.string().min(1).optional(), monitors: monitorIds.optional(), + metadata: PrivateLocationMetadata.optional(), }); export type UpdatePrivateLocationInput = z.infer< typeof UpdatePrivateLocationInput >; +export const SetPrivateLocationStatusInput = z.object({ + id: z.number().int(), + status: z.enum(["active", "error"]), +}); +export type SetPrivateLocationStatusInput = z.infer< + typeof SetPrivateLocationStatusInput +>; + export const GetPrivateLocationInput = z.object({ id: z.number().int() }); export type GetPrivateLocationInput = z.infer; diff --git a/packages/services/src/private-location/set-status.ts b/packages/services/src/private-location/set-status.ts new file mode 100644 index 000000000..0c707094c --- /dev/null +++ b/packages/services/src/private-location/set-status.ts @@ -0,0 +1,70 @@ +import { and, eq } from "@openstatus/db"; +import { + privateLocation, + selectPrivateLocationSchema, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { NotFoundError } from "../errors"; +import type { PrivateLocation } from "../types"; +import { SetPrivateLocationStatusInput } from "./schemas"; + +/** + * Set a private location's health status. Cron-only writer — the value is + * derived from the agent heartbeat, never from user input. Returns early + * without a write or audit row when the status is unchanged. + */ +export async function setPrivateLocationStatus(args: { + ctx: ServiceContext; + input: SetPrivateLocationStatusInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = SetPrivateLocationStatusInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await tx.query.privateLocation.findFirst({ + where: and( + eq(privateLocation.id, input.id), + eq(privateLocation.workspaceId, ctx.workspace.id), + ), + }); + + if (!existing) { + throw new NotFoundError("private_location", input.id); + } + + const beforeParsed = selectPrivateLocationSchema.parse(existing); + if (beforeParsed.status === input.status) { + return beforeParsed; + } + + const row = await tx + .update(privateLocation) + .set({ status: input.status, updatedAt: new Date() }) + .where( + and( + eq(privateLocation.id, input.id), + eq(privateLocation.workspaceId, ctx.workspace.id), + ), + ) + .returning() + .get(); + + if (!row) throw new NotFoundError("private_location", input.id); + + const afterParsed = selectPrivateLocationSchema.parse(row); + + await emitAudit(tx, ctx, { + action: "private_location.update", + entityType: "private_location", + entityId: afterParsed.id, + before: beforeParsed, + after: afterParsed, + }); + + return afterParsed; + }); +} diff --git a/packages/services/src/private-location/update.ts b/packages/services/src/private-location/update.ts index adc335611..53047b344 100644 --- a/packages/services/src/private-location/update.ts +++ b/packages/services/src/private-location/update.ts @@ -32,6 +32,11 @@ export async function updatePrivateLocation(args: { ? Array.from(new Set(input.monitors)) : undefined; + const metadataUpdate = + input.metadata !== undefined + ? { metadata: Object.keys(input.metadata).length ? input.metadata : null } + : undefined; + return withTransaction(ctx, async (tx) => { const existing = await tx.query.privateLocation.findFirst({ where: and( @@ -57,11 +62,11 @@ export async function updatePrivateLocation(args: { const row = await tx .update(privateLocation) - .set( - input.name !== undefined - ? { name: input.name, updatedAt: new Date() } - : { updatedAt: new Date() }, - ) + .set({ + ...(input.name !== undefined ? { name: input.name } : {}), + ...metadataUpdate, + updatedAt: new Date(), + }) .where( and( eq(privateLocation.id, input.id), -- 2.51.2 From 51c18d2f564c01a117f80c6825085a0b4543f916 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:36:47 +0200 Subject: [PATCH 009/109] feat: dashboard cmdk (#2416) * feat: dashboard cmdk * fix: workspace cookie key duplication --- .../src/app/(dashboard)/invite/client.tsx | 4 +- apps/dashboard/src/app/(dashboard)/layout.tsx | 10 +- .../overview/create-event-button-group.tsx | 19 +- .../app/(dashboard)/settings/account/page.tsx | 6 +- .../src/app/(dashboard)/settings/tabs.tsx | 47 +- apps/dashboard/src/app/api/chat/route.ts | 3 +- .../components/command-menu/command-menu.tsx | 406 ++++++++++++++++++ .../src/components/command-menu/config.ts | 154 +++++++ .../src/components/command-menu/provider.tsx | 34 ++ .../src/components/forms/form-card.tsx | 4 +- .../forms/maintenance/sheet-create.tsx | 14 +- .../forms/settings/form-api-key.tsx | 2 +- .../forms/settings/form-members.tsx | 2 +- .../components/forms/settings/form-slug.tsx | 2 +- .../forms/settings/form-workspace.tsx | 2 +- .../forms/status-report/sheet-create.tsx | 16 +- .../forms/support-contact/dialog.tsx | 16 +- .../src/components/nav/app-sidebar.tsx | 48 +-- .../dashboard/src/components/nav/nav-help.tsx | 70 +-- .../src/components/nav/nav-overview.tsx | 3 + .../src/components/nav/nav-search.tsx | 36 ++ .../src/components/nav/workspace-switcher.tsx | 8 +- apps/dashboard/src/config/help.ts | 54 +++ apps/dashboard/src/config/nav.ts | 83 ++++ apps/dashboard/src/config/settings.ts | 56 +++ .../dashboard/src/hooks/use-scroll-to-hash.ts | 43 ++ apps/dashboard/src/lib/edge-context.ts | 3 +- apps/dashboard/src/lib/workspace-cookie.ts | 8 + apps/dashboard/src/proxy.ts | 7 +- 29 files changed, 977 insertions(+), 183 deletions(-) create mode 100644 apps/dashboard/src/components/command-menu/command-menu.tsx create mode 100644 apps/dashboard/src/components/command-menu/config.ts create mode 100644 apps/dashboard/src/components/command-menu/provider.tsx create mode 100644 apps/dashboard/src/components/nav/nav-search.tsx create mode 100644 apps/dashboard/src/config/help.ts create mode 100644 apps/dashboard/src/config/nav.ts create mode 100644 apps/dashboard/src/config/settings.ts create mode 100644 apps/dashboard/src/hooks/use-scroll-to-hash.ts create mode 100644 apps/dashboard/src/lib/workspace-cookie.ts diff --git a/apps/dashboard/src/app/(dashboard)/invite/client.tsx b/apps/dashboard/src/app/(dashboard)/invite/client.tsx index 1b7c5f267..f8597840a 100644 --- a/apps/dashboard/src/app/(dashboard)/invite/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/invite/client.tsx @@ -15,6 +15,7 @@ import { SectionTitle, } from "@/components/content/section"; import { useTRPC } from "@/lib/trpc/client"; +import { switchWorkspace } from "@/lib/workspace-cookie"; import { searchParamsParsers } from "./search-params"; @@ -30,8 +31,7 @@ export function Client() { trpc.invitation.accept.mutationOptions({ onSuccess: (workspace) => { if (!workspace) return; - document.cookie = `workspace-slug=${workspace.slug}; path=/;`; - window.location.href = "/overview"; + switchWorkspace(workspace.slug); }, }), ); diff --git a/apps/dashboard/src/app/(dashboard)/layout.tsx b/apps/dashboard/src/app/(dashboard)/layout.tsx index b251d576f..499cac3c4 100644 --- a/apps/dashboard/src/app/(dashboard)/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/layout.tsx @@ -4,6 +4,8 @@ import { } from "@openstatus/ui/components/ui/sidebar"; import { cookies } from "next/headers"; +import { CommandMenu } from "@/components/command-menu/command-menu"; +import { CommandMenuProvider } from "@/components/command-menu/provider"; import { DismissedNotesProvider } from "@/components/common/note-dismissible"; import { AppSidebar } from "@/components/nav/app-sidebar"; import { @@ -31,8 +33,11 @@ export default async function Layout({ defaultOpen={defaultOpen} cookieName={LEFT_SIDEBAR_COOKIE} > - - {children} + + + + {children} + @@ -45,6 +50,7 @@ async function HydrateSidebar({ children }: { children: React.ReactNode }) { queryClient.prefetchQuery(trpc.page.list.queryOptions()), queryClient.prefetchQuery(trpc.monitor.list.queryOptions()), queryClient.prefetchQuery(trpc.workspace.get.queryOptions()), + queryClient.prefetchQuery(trpc.workspace.list.queryOptions()), queryClient.prefetchQuery(trpc.user.get.queryOptions()), ]); diff --git a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx index b6993c532..ea75b8410 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx @@ -9,15 +9,13 @@ import { DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { ChevronDown, Plus } from "lucide-react"; -import { useRef } from "react"; +import { useState } from "react"; import { FormSheetMaintenanceCreate } from "@/components/forms/maintenance/sheet-create"; import { FormSheetStatusReportCreate } from "@/components/forms/status-report/sheet-create"; export function CreateEventButtonGroup() { - // the maintenance sheet lives outside the dropdown — a trigger inside - // DropdownMenuContent unmounts (and closes the sheet) when the menu closes - const maintenanceButtonRef = useRef(null); + const [maintenanceOpen, setMaintenanceOpen] = useState(false); return (
@@ -40,20 +38,17 @@ export function CreateEventButtonGroup() { - maintenanceButtonRef.current?.click()} - > + setMaintenanceOpen(true)}> Create Maintenance - - - +
); } diff --git a/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx index aabf78079..f9466526d 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx @@ -51,7 +51,7 @@ export default function Page() { Account - + Personal Information @@ -77,7 +77,7 @@ export default function Page() { - + Appearance @@ -88,7 +88,7 @@ export default function Page() { - + Delete Account diff --git a/apps/dashboard/src/app/(dashboard)/settings/tabs.tsx b/apps/dashboard/src/app/(dashboard)/settings/tabs.tsx index 80f92c3be..93fc88a69 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/tabs.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/tabs.tsx @@ -1,50 +1,17 @@ "use client"; -import { Blocks, Cog, CreditCard, Globe, ScanEye, User } from "lucide-react"; - import { NavTabs } from "@/components/nav/nav-tabs"; +import { SETTINGS_TABS } from "@/config/settings"; export function Tabs() { return ( ({ + value, + label, + icon, + href, + }))} /> ); } diff --git a/apps/dashboard/src/app/api/chat/route.ts b/apps/dashboard/src/app/api/chat/route.ts index 546e6b96c..bd135dbfa 100644 --- a/apps/dashboard/src/app/api/chat/route.ts +++ b/apps/dashboard/src/app/api/chat/route.ts @@ -25,6 +25,7 @@ import { z } from "zod"; import { toAiSdkTools } from "@/lib/agent-tools/adapter"; import { getChatServiceContext } from "@/lib/agent-tools/context"; import { chatRateLimit } from "@/lib/rate-limit/chat"; +import { WORKSPACE_SLUG_COOKIE } from "@/lib/workspace-cookie"; // Drop tool parts that have no result the SDK can turn into a tool_result // block. `ignoreIncompleteToolCalls` doesn't cover `approval-requested`, so @@ -51,7 +52,7 @@ const requestSchema = z.object({ }); export async function POST(req: NextRequest) { - const workspaceSlug = (await cookies()).get("workspace-slug")?.value; + const workspaceSlug = (await cookies()).get(WORKSPACE_SLUG_COOKIE)?.value; const ctx = await getChatServiceContext({ workspaceSlug }); if (!ctx || ctx.actor.type !== "user") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/apps/dashboard/src/components/command-menu/command-menu.tsx b/apps/dashboard/src/components/command-menu/command-menu.tsx new file mode 100644 index 000000000..0e8a7f6f9 --- /dev/null +++ b/apps/dashboard/src/components/command-menu/command-menu.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, + CommandLoading, +} from "@openstatus/ui/components/ui/command"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@openstatus/ui/components/ui/dialog"; +import { useQuery } from "@tanstack/react-query"; +import { Command as CommandPrimitive } from "cmdk"; +import { Moon, Search, Sun, X } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useRouter } from "next/navigation"; +import * as React from "react"; + +import { FormSheetMaintenanceCreate } from "@/components/forms/maintenance/sheet-create"; +import { FormSheetStatusReportCreate } from "@/components/forms/status-report/sheet-create"; +import { FormDialogSupportContact } from "@/components/forms/support-contact/dialog"; +import { scrollToHash, useScrollToHash } from "@/hooks/use-scroll-to-hash"; +import { useTRPC } from "@/lib/trpc/client"; +import { switchWorkspace } from "@/lib/workspace-cookie"; + +import { + type CommandLink, + type CommandPage, + type CommandSheetAction, + CREATE_ACTIONS, + CREATE_LINKS, + HELP_LINK_ITEMS, + HELP_SUPPORT_ACTION, + MONITOR_ACTIONS, + NAVIGATION, + SETTINGS, + STATUS_PAGE_ACTIONS, +} from "./config"; +import { useCommandMenu } from "./provider"; + +type ActiveSheet = CommandSheetAction["sheet"] | null; + +const IDLE_CAP = 5; + +function LinkItem({ + item, + onSelect, +}: { + item: CommandLink; + onSelect: (item: CommandLink) => void; +}) { + return ( + onSelect(item)} + > + + {item.label} + + ); +} + +function ActionItem({ + action, + onSelect, +}: { + action: CommandSheetAction; + onSelect: () => void; +}) { + return ( + + + {action.label} + + ); +} + +export function CommandMenu() { + const { open, setOpen } = useCommandMenu(); + const [search, setSearch] = React.useState(""); + const [pages, setPages] = React.useState([]); + const [activeSheet, setActiveSheet] = React.useState(null); + const [mounted, setMounted] = React.useState(false); + const inputRef = React.useRef(null); + + const router = useRouter(); + const { resolvedTheme, setTheme } = useTheme(); + const trpc = useTRPC(); + + useScrollToHash(); + + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); + const { data: statusPages } = useQuery(trpc.page.list.queryOptions()); + const { data: workspaces } = useQuery(trpc.workspace.list.queryOptions()); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + + const page = pages.length > 0 ? pages[pages.length - 1] : null; + + React.useEffect(() => setMounted(true), []); + + React.useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((prev) => !prev); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [setOpen]); + + // Reset scope + query every time the palette opens. + React.useEffect(() => { + if (open) { + setSearch(""); + setPages([]); + } + }, [open]); + + const pop = React.useCallback(() => { + setPages((prev) => prev.slice(0, -1)); + setSearch(""); + }, []); + + const pushPage = (next: CommandPage) => { + setPages((prev) => [...prev, next]); + setSearch(""); + }; + + const navigate = (href: string) => { + router.push(href); + setOpen(false); + const hashIndex = href.indexOf("#"); + if (hashIndex !== -1) scrollToHash(href.slice(hashIndex + 1)); + }; + + const selectLink = (item: CommandLink) => { + if (item.external) { + window.open(item.href, "_blank", "noreferrer"); + setOpen(false); + return; + } + navigate(item.href); + }; + + const openSheet = (sheet: Exclude) => { + setOpen(false); + // Defer so the palette Dialog releases focus + scroll lock before the + // sheet/dialog claims them — otherwise Radix layers fight during overlap. + setTimeout(() => setActiveSheet(sheet), 0); + }; + + const idleCap = (items: T[]) => + search ? items : items.slice(0, IDLE_CAP); + + const otherWorkspaces = + workspaces?.filter((w) => w.slug !== workspace?.slug) ?? []; + + return ( + <> + + { + e.preventDefault(); + inputRef.current?.focus(); + }} + className="top-[15%] translate-y-0 overflow-hidden p-0 lg:max-w-xl" + > + Command Menu + { + if (e.key === "Backspace" && !search && pages.length > 0) { + e.preventDefault(); + pop(); + } + }} + > +
+ + {page ? ( + + {page.type === "monitor" ? page.name : page.title} + + + ) : null} + +
+ + No results found. + + {page ? ( + + {(page.type === "monitor" + ? MONITOR_ACTIONS(page.id) + : STATUS_PAGE_ACTIONS(page.id) + ).map((item) => ( + navigate(item.href)} + > + + {item.label} + + ))} + + ) : ( + <> + {monitors === undefined ? ( + Loading… + ) : null} + + {monitors && monitors.length > 0 ? ( + + {idleCap(monitors).map((m) => ( + + pushPage({ + type: "monitor", + id: m.id, + name: m.name, + }) + } + > +
+ {m.name} + + {m.url} + +
+
+ ))} +
+ ) : null} + + {statusPages && statusPages.length > 0 ? ( + + {idleCap(statusPages).map((p) => ( + + pushPage({ + type: "status-page", + id: p.id, + title: p.title, + }) + } + > +
+ {p.title} + + {p.slug} + +
+
+ ))} +
+ ) : null} + + + {NAVIGATION.map((item) => ( + + ))} + + + + {CREATE_LINKS.map((item) => ( + + ))} + {CREATE_ACTIONS.map((action) => ( + openSheet(action.sheet)} + /> + ))} + + + + {SETTINGS.map((item) => ( + + ))} + + + {otherWorkspaces.length > 0 ? ( + + {otherWorkspaces.map((w) => ( + switchWorkspace(w.slug)} + > + + {w.name || "Untitled Workspace"} + + + {w.slug} + + + ))} + + ) : null} + + + openSheet("support")} + /> + {HELP_LINK_ITEMS.map((item) => ( + + ))} + + + {mounted ? ( + + + setTheme(resolvedTheme === "dark" ? "light" : "dark") + } + > + {resolvedTheme === "dark" ? : } + + Switch to{" "} + {resolvedTheme === "dark" ? "light" : "dark"} theme + + + + ) : null} + + )} +
+
+
+
+ + setActiveSheet(o ? "status-report" : null)} + /> + setActiveSheet(o ? "maintenance" : null)} + /> + setActiveSheet(o ? "support" : null)} + /> + + ); +} diff --git a/apps/dashboard/src/components/command-menu/config.ts b/apps/dashboard/src/components/command-menu/config.ts new file mode 100644 index 000000000..bd45b7e77 --- /dev/null +++ b/apps/dashboard/src/components/command-menu/config.ts @@ -0,0 +1,154 @@ +import { + AtSign, + KeyRound, + Palette, + Pencil, + Plus, + Trash2, + Users, +} from "lucide-react"; +import type * as React from "react"; + +import { MONITOR_TABS } from "@/app/(dashboard)/monitors/[id]/constants"; +import { STATUS_PAGE_TABS } from "@/app/(dashboard)/status-pages/[id]/constants"; +import { HELP_LINKS, HELP_SUPPORT } from "@/config/help"; +import { NAV_MENU_ITEMS } from "@/config/nav"; +import { SETTINGS_TABS } from "@/config/settings"; + +// Lucide icons and the custom brand icons both accept svg props. +export type CommandIcon = React.ComponentType>; + +export type CommandLink = { + label: string; + href: string; + icon: CommandIcon; + keywords?: string[]; + external?: boolean; +}; + +export type CommandSheetAction = { + label: string; + icon: CommandIcon; + keywords?: string[]; + sheet: "status-report" | "maintenance" | "support"; +}; + +export type CommandPage = + | { type: "monitor"; id: number; name: string } + | { type: "status-page"; id: number; title: string }; + +export const NAVIGATION: CommandLink[] = NAV_MENU_ITEMS.map((item) => ({ + label: item.label, + href: item.href, + icon: item.icon, + keywords: item.keywords, +})); + +export const CREATE_LINKS: CommandLink[] = [ + { + label: "Create Monitor", + href: "/monitors/create", + icon: Plus, + keywords: ["new monitor"], + }, + { + label: "Create Status Page", + href: "/status-pages/create", + icon: Plus, + keywords: ["new page"], + }, +]; + +export const CREATE_ACTIONS: CommandSheetAction[] = [ + { + label: "Create Status Report", + icon: Plus, + keywords: ["incident", "announce", "publish"], + sheet: "status-report", + }, + { + label: "Create Maintenance", + icon: Plus, + keywords: ["downtime", "window", "schedule"], + sheet: "maintenance", + }, +]; + +const SETTINGS_TAB_LINKS: CommandLink[] = SETTINGS_TABS.map((tab) => ({ + label: tab.label, + href: tab.href, + icon: tab.icon, + keywords: tab.keywords, +})); + +// Deep-links into settings cards via anchor ids. Icons are only used here, so +// they live with the command menu rather than a shared config. +const SETTINGS_ANCHORS: CommandLink[] = [ + { + label: "API Keys", + href: "/settings/general#api-keys", + icon: KeyRound, + keywords: ["token", "key"], + }, + { + label: "Team Members", + href: "/settings/general#team-members", + icon: Users, + keywords: ["invite", "member"], + }, + { + label: "Workspace Slug", + href: "/settings/general#workspace-slug", + icon: AtSign, + }, + { + label: "Workspace Name", + href: "/settings/general#workspace-name", + icon: Pencil, + keywords: ["rename"], + }, + { + label: "Appearance", + href: "/settings/account#appearance", + icon: Palette, + keywords: ["theme", "dark mode"], + }, + { + label: "Delete Account", + href: "/settings/account#delete-account", + icon: Trash2, + }, +]; + +export const SETTINGS: CommandLink[] = [ + ...SETTINGS_TAB_LINKS, + ...SETTINGS_ANCHORS, +]; + +export const HELP_SUPPORT_ACTION: CommandSheetAction = { + label: HELP_SUPPORT.label, + icon: HELP_SUPPORT.icon, + keywords: HELP_SUPPORT.keywords, + sheet: "support", +}; + +export const HELP_LINK_ITEMS: CommandLink[] = HELP_LINKS.map((link) => ({ + ...link, + external: true, +})); + +export function MONITOR_ACTIONS(id: number): CommandLink[] { + return MONITOR_TABS.map((tab) => ({ + label: tab.label, + href: `/monitors/${id}/${tab.value}`, + icon: tab.icon, + })); +} + +export function STATUS_PAGE_ACTIONS(id: number): CommandLink[] { + return STATUS_PAGE_TABS.map((tab) => ({ + label: tab.label, + href: `/status-pages/${id}/${tab.value}`, + icon: tab.icon, + })); +} diff --git a/apps/dashboard/src/components/command-menu/provider.tsx b/apps/dashboard/src/components/command-menu/provider.tsx new file mode 100644 index 000000000..46ec5147c --- /dev/null +++ b/apps/dashboard/src/components/command-menu/provider.tsx @@ -0,0 +1,34 @@ +"use client"; + +import * as React from "react"; + +type CommandMenuContextValue = { + open: boolean; + setOpen: React.Dispatch>; +}; + +const CommandMenuContext = React.createContext( + null, +); + +export function CommandMenuProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [open, setOpen] = React.useState(false); + const value = React.useMemo(() => ({ open, setOpen }), [open]); + return ( + + {children} + + ); +} + +export function useCommandMenu() { + const ctx = React.useContext(CommandMenuContext); + if (!ctx) { + throw new Error("useCommandMenu must be used within a CommandMenuProvider"); + } + return ctx; +} diff --git a/apps/dashboard/src/components/forms/form-card.tsx b/apps/dashboard/src/components/forms/form-card.tsx index b20c9b525..937131345 100644 --- a/apps/dashboard/src/components/forms/form-card.tsx +++ b/apps/dashboard/src/components/forms/form-card.tsx @@ -10,9 +10,9 @@ import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; -// py-0 +// py-0; scroll-mt keeps `#anchor` deep-links clear of the sticky header + tabs. const formCardVariants = cva( - "group relative w-full gap-4 overflow-hidden py-0 shadow-none", + "group relative w-full scroll-mt-28 gap-4 overflow-hidden py-0 shadow-none", { variants: { variant: { diff --git a/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx b/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx index d6d12c700..b5135bfe4 100644 --- a/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx +++ b/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx @@ -37,10 +37,16 @@ import { useTRPC } from "@/lib/trpc/client"; */ export function FormSheetMaintenanceCreate({ children, + open: controlledOpen, + onOpenChange, }: { - children: React.ReactNode; + children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; const [selectedPageId, setSelectedPageId] = useState(null); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -75,7 +81,9 @@ export function FormSheetMaintenanceCreate({ return ( - {children} + {children ? ( + {children} + ) : null} Maintenance diff --git a/apps/dashboard/src/components/forms/settings/form-api-key.tsx b/apps/dashboard/src/components/forms/settings/form-api-key.tsx index 83486abdd..979e18e6c 100644 --- a/apps/dashboard/src/components/forms/settings/form-api-key.tsx +++ b/apps/dashboard/src/components/forms/settings/form-api-key.tsx @@ -164,7 +164,7 @@ export function FormApiKey() { } return ( - + API Keys diff --git a/apps/dashboard/src/components/forms/settings/form-members.tsx b/apps/dashboard/src/components/forms/settings/form-members.tsx index fc2bf439c..f33041d9d 100644 --- a/apps/dashboard/src/components/forms/settings/form-members.tsx +++ b/apps/dashboard/src/components/forms/settings/form-members.tsx @@ -83,7 +83,7 @@ export function FormMembers({ return (
- + {locked ? : null} Team diff --git a/apps/dashboard/src/components/forms/settings/form-slug.tsx b/apps/dashboard/src/components/forms/settings/form-slug.tsx index 74282de8b..b49fb4966 100644 --- a/apps/dashboard/src/components/forms/settings/form-slug.tsx +++ b/apps/dashboard/src/components/forms/settings/form-slug.tsx @@ -27,7 +27,7 @@ export function FormSlug({ defaultValues }: { defaultValues?: FormValues }) { console.log({ defaultValues, schema }); return ( - + Slug diff --git a/apps/dashboard/src/components/forms/settings/form-workspace.tsx b/apps/dashboard/src/components/forms/settings/form-workspace.tsx index 985039c4c..c4272eec8 100644 --- a/apps/dashboard/src/components/forms/settings/form-workspace.tsx +++ b/apps/dashboard/src/components/forms/settings/form-workspace.tsx @@ -68,7 +68,7 @@ export function FormWorkspace({ return ( - + Workspace diff --git a/apps/dashboard/src/components/forms/status-report/sheet-create.tsx b/apps/dashboard/src/components/forms/status-report/sheet-create.tsx index 7f1bdc94e..86be18f41 100644 --- a/apps/dashboard/src/components/forms/status-report/sheet-create.tsx +++ b/apps/dashboard/src/components/forms/status-report/sheet-create.tsx @@ -11,7 +11,7 @@ import { } from "@openstatus/ui/components/ui/select"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { EmptyStateContainer, @@ -37,10 +37,16 @@ import { useTRPC } from "@/lib/trpc/client"; */ export function FormSheetStatusReportCreate({ children, + open: controlledOpen, + onOpenChange, }: { - children: React.ReactNode; + children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; const [selectedPageId, setSelectedPageId] = useState(null); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -88,7 +94,9 @@ export function FormSheetStatusReportCreate({ return ( - {children} + {children ? ( + {children} + ) : null} Status Report diff --git a/apps/dashboard/src/components/forms/support-contact/dialog.tsx b/apps/dashboard/src/components/forms/support-contact/dialog.tsx index bab020f44..f3cc2f30a 100644 --- a/apps/dashboard/src/components/forms/support-contact/dialog.tsx +++ b/apps/dashboard/src/components/forms/support-contact/dialog.tsx @@ -18,11 +18,17 @@ import { ContactForm, type FormValues } from "./form"; export function FormDialogSupportContact({ children, defaultValues, + open: controlledOpen, + onOpenChange, ...props }: React.ComponentProps & { defaultValues?: FormValues; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; const isMobile = useIsMobile(); const trpc = useTRPC(); const { data: user } = useQuery(trpc.user.get.queryOptions()); @@ -30,9 +36,11 @@ export function FormDialogSupportContact({ return ( - - {children} - + {children ? ( + + {children} + + ) : null} Support diff --git a/apps/dashboard/src/components/nav/app-sidebar.tsx b/apps/dashboard/src/components/nav/app-sidebar.tsx index 570f8a8e9..d003704a3 100644 --- a/apps/dashboard/src/components/nav/app-sidebar.tsx +++ b/apps/dashboard/src/components/nav/app-sidebar.tsx @@ -15,15 +15,6 @@ import { TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; -import { - Activity, - Bell, - Bot, - Cog, - LayoutGrid, - MessageSquare, - PanelTop, -} from "lucide-react"; import * as React from "react"; import { Kbd } from "@/components/common/kbd"; @@ -32,6 +23,7 @@ import { NavOverview } from "@/components/nav/nav-overview"; import { NavStatusPages } from "@/components/nav/nav-status-pages"; import { NavUser } from "@/components/nav/nav-user"; import { WorkspaceSwitcher } from "@/components/nav/workspace-switcher"; +import { NAV } from "@/config/nav"; import { NavBanner } from "./nav-banner"; import { NavHelp } from "./nav-help"; @@ -46,41 +38,21 @@ const data = { avatar: "/avatars/shadcn.jpg", }, overview: [ - { - name: "Overview", - url: "/overview", - icon: LayoutGrid, - }, + { name: "Overview", url: NAV.overview.href, icon: NAV.overview.icon }, { name: "Status Pages", - url: "/status-pages", - icon: PanelTop, - }, - { - name: "Monitors", - url: "/monitors", - icon: Activity, + url: NAV.statusPages.href, + icon: NAV.statusPages.icon, }, + { name: "Monitors", url: NAV.monitors.href, icon: NAV.monitors.icon }, { name: "Notifications", - url: "/notifications", - icon: Bell, - }, - { - name: "Settings", - url: "/settings/general", - icon: Cog, - }, - { - name: "Assistant", - url: "/chat", - icon: MessageSquare, - }, - { - name: "Slack agent", - url: "/agents", - icon: Bot, + url: NAV.notifications.href, + icon: NAV.notifications.icon, }, + { name: "Settings", url: NAV.settings.href, icon: NAV.settings.icon }, + { name: "Assistant", url: NAV.chat.href, icon: NAV.chat.icon }, + { name: "Slack agent", url: NAV.agents.href, icon: NAV.agents.icon }, ], }; diff --git a/apps/dashboard/src/components/nav/nav-help.tsx b/apps/dashboard/src/components/nav/nav-help.tsx index a8469dd26..ad3b29ad6 100644 --- a/apps/dashboard/src/components/nav/nav-help.tsx +++ b/apps/dashboard/src/components/nav/nav-help.tsx @@ -1,7 +1,5 @@ "use client"; -import { DiscordIcon } from "@openstatus/icons"; -import { GitHubIcon } from "@openstatus/icons"; import { DropdownMenu, DropdownMenuContent, @@ -17,16 +15,11 @@ import { SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; -import { - Book, - Braces, - CalendarClock, - HelpCircle, - LifeBuoy, -} from "lucide-react"; +import { HelpCircle } from "lucide-react"; import Link from "next/link"; import { FormDialogSupportContact } from "@/components/forms/support-contact/dialog"; +import { HELP_LINKS, HELP_SUPPORT } from "@/config/help"; export function NavHelp() { const { isMobile } = useSidebar(); @@ -56,57 +49,18 @@ export function NavHelp() { e.preventDefault()}> - - Support + + {HELP_SUPPORT.label} - - - Docs - - - - - API Reference - - - - - Book a Call - - - - - - Community - - - - - - GitHub - - + {HELP_LINKS.map((item) => ( + + + + {item.label} + + + ))} diff --git a/apps/dashboard/src/components/nav/nav-overview.tsx b/apps/dashboard/src/components/nav/nav-overview.tsx index fe131bacf..9cdfead03 100644 --- a/apps/dashboard/src/components/nav/nav-overview.tsx +++ b/apps/dashboard/src/components/nav/nav-overview.tsx @@ -12,6 +12,8 @@ import type { LucideIcon } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { NavSearch } from "@/components/nav/nav-search"; + function topSegment(url: string) { return url.split("/")[1] ?? ""; } @@ -32,6 +34,7 @@ export function NavOverview({ Workspace + {items.map((item) => ( + { + setOpenMobile(false); + setOpen(true); + }} + tooltip="Search" + variant="outline" + className="group/search" + > + + Search +
+ ⌘ + K +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/nav/workspace-switcher.tsx b/apps/dashboard/src/components/nav/workspace-switcher.tsx index 3b423b83c..45205dafd 100644 --- a/apps/dashboard/src/components/nav/workspace-switcher.tsx +++ b/apps/dashboard/src/components/nav/workspace-switcher.tsx @@ -20,6 +20,7 @@ import { ChevronsUpDown, Plus } from "lucide-react"; import { Link } from "@/components/common/link"; import { useTRPC } from "@/lib/trpc/client"; +import { switchWorkspace } from "@/lib/workspace-cookie"; interface WorkspaceSwitcherProps { className?: string; @@ -34,11 +35,6 @@ export function WorkspaceSwitcher({ className, side }: WorkspaceSwitcherProps) { if (!workspace) return null; - function handleClick(slug: string) { - document.cookie = `workspace-slug=${slug}; path=/;`; - window.location.href = "/overview"; - } - return ( @@ -88,7 +84,7 @@ export function WorkspaceSwitcher({ className, side }: WorkspaceSwitcherProps) { { - handleClick(workspace.slug); + switchWorkspace(workspace.slug); setOpenMobile(false); }} className="gap-2 p-2" diff --git a/apps/dashboard/src/config/help.ts b/apps/dashboard/src/config/help.ts new file mode 100644 index 000000000..34102e4af --- /dev/null +++ b/apps/dashboard/src/config/help.ts @@ -0,0 +1,54 @@ +import { DiscordIcon, GitHubIcon } from "@openstatus/icons"; +import { Book, Braces, CalendarClock, LifeBuoy } from "lucide-react"; +import type * as React from "react"; + +// Lucide icons and the custom brand icons both accept svg props. +export type HelpIcon = React.ComponentType>; + +// Single source of truth for the Get Help menu (sidebar nav-help) and the +// command-menu "Get Help" group so their icons stay in sync. +export const HELP_SUPPORT = { + label: "Support", + icon: LifeBuoy as HelpIcon, + keywords: ["feedback", "help", "contact"], +}; + +export type HelpLink = { + label: string; + href: string; + icon: HelpIcon; + keywords?: string[]; +}; + +export const HELP_LINKS = [ + { + label: "Docs", + href: "https://www.openstatus.dev/docs", + icon: Book, + keywords: ["documentation"], + }, + { + label: "API Reference", + href: "https://api.openstatus.dev/openapi", + icon: Braces, + keywords: ["openapi", "swagger"], + }, + { + label: "Book a Call", + href: "https://openstatus.dev/cal", + icon: CalendarClock, + keywords: ["demo", "meeting"], + }, + { + label: "Community", + href: "https://openstatus.dev/discord", + icon: DiscordIcon, + keywords: ["discord", "chat"], + }, + { + label: "GitHub", + href: "https://openstatus.dev/github", + icon: GitHubIcon, + keywords: ["source", "repo", "issues"], + }, +] satisfies HelpLink[]; diff --git a/apps/dashboard/src/config/nav.ts b/apps/dashboard/src/config/nav.ts new file mode 100644 index 000000000..57af077a6 --- /dev/null +++ b/apps/dashboard/src/config/nav.ts @@ -0,0 +1,83 @@ +import type { LucideIcon } from "lucide-react"; +import { + Activity, + Bell, + Bot, + Cog, + LayoutGrid, + MessageSquare, + PanelTop, + Terminal, +} from "lucide-react"; + +export type NavItem = { + label: string; + href: string; + icon: LucideIcon; + keywords?: string[]; +}; + +// Single source of truth for primary destinations. Consumed by the sidebar +// (icon + href) and the command menu (icon + href + label + keywords), so an +// icon swap here propagates to both surfaces. +export const NAV = { + overview: { + label: "Overview", + href: "/overview", + icon: LayoutGrid, + keywords: ["home", "dashboard"], + }, + monitors: { + label: "Monitors", + href: "/monitors", + icon: Activity, + keywords: ["checks", "uptime"], + }, + statusPages: { + label: "Status Pages", + href: "/status-pages", + icon: PanelTop, + keywords: ["pages"], + }, + notifications: { + label: "Notifications", + href: "/notifications", + icon: Bell, + keywords: ["notifiers", "alerts", "channels"], + }, + chat: { + label: "Chat", + href: "/chat", + icon: MessageSquare, + keywords: ["assistant", "ai"], + }, + agents: { + label: "Agents", + href: "/agents", + icon: Bot, + keywords: ["slack"], + }, + cli: { + label: "CLI", + href: "/cli", + icon: Terminal, + keywords: ["terminal"], + }, + settings: { + label: "Settings", + href: "/settings/general", + icon: Cog, + keywords: ["workspace"], + }, +} satisfies Record; + +// Command-menu "Navigation" group order. +export const NAV_MENU_ITEMS: NavItem[] = [ + NAV.overview, + NAV.monitors, + NAV.statusPages, + NAV.notifications, + NAV.chat, + NAV.agents, + NAV.cli, +]; diff --git a/apps/dashboard/src/config/settings.ts b/apps/dashboard/src/config/settings.ts new file mode 100644 index 000000000..4309dc01a --- /dev/null +++ b/apps/dashboard/src/config/settings.ts @@ -0,0 +1,56 @@ +import type { LucideIcon } from "lucide-react"; +import { Blocks, Cog, CreditCard, Globe, ScanEye, User } from "lucide-react"; + +export type SettingsTab = { + value: string; + label: string; + icon: LucideIcon; + href: string; + keywords?: string[]; +}; + +// Single source of truth for the settings tab bar and the command-menu +// "Settings" group so their icons stay in sync. +export const SETTINGS_TABS = [ + { + value: "general", + label: "General", + icon: Cog, + href: "/settings/general", + keywords: ["workspace"], + }, + { + value: "account", + label: "Account", + icon: User, + href: "/settings/account", + keywords: ["profile", "user"], + }, + { + value: "billing", + label: "Billing", + icon: CreditCard, + href: "/settings/billing", + keywords: ["plan", "subscription", "upgrade"], + }, + { + value: "integrations", + label: "Integrations", + icon: Blocks, + href: "/settings/integrations", + }, + { + value: "private-locations", + label: "Private Locations", + icon: Globe, + href: "/settings/private-locations", + keywords: ["probes", "regions"], + }, + { + value: "audit-logs", + label: "Audit Logs", + icon: ScanEye, + href: "/settings/audit-logs", + keywords: ["activity", "history"], + }, +] satisfies SettingsTab[]; diff --git a/apps/dashboard/src/hooks/use-scroll-to-hash.ts b/apps/dashboard/src/hooks/use-scroll-to-hash.ts new file mode 100644 index 000000000..9b3a422f0 --- /dev/null +++ b/apps/dashboard/src/hooks/use-scroll-to-hash.ts @@ -0,0 +1,43 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; + +// Settings pages render `null` until tRPC data resolves, so native hash scroll +// fires before the anchor exists. Retry via rAF until the element mounts. +function scrollWhenReady(id: string) { + const start = performance.now(); + const tick = () => { + const el = document.getElementById(id); + if (el) { + el.scrollIntoView(); + return; + } + if (performance.now() - start < 2000) { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); +} + +// Imperative helper for anchor menu items — handles same-page and repeat +// selection where the URL hash doesn't change and `hashchange` never fires. +export function scrollToHash(id: string) { + scrollWhenReady(id); +} + +export function useScrollToHash() { + const pathname = usePathname(); + + useEffect(() => { + const id = window.location.hash.slice(1); + if (id) scrollWhenReady(id); + + const onHashChange = () => { + const next = window.location.hash.slice(1); + if (next) scrollWhenReady(next); + }; + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, [pathname]); +} diff --git a/apps/dashboard/src/lib/edge-context.ts b/apps/dashboard/src/lib/edge-context.ts index 60e5a5e35..f72194c20 100644 --- a/apps/dashboard/src/lib/edge-context.ts +++ b/apps/dashboard/src/lib/edge-context.ts @@ -2,6 +2,7 @@ import { asc, db, eq, schema } from "@openstatus/db"; import type { ServiceContext } from "@openstatus/services"; import { auth } from "@/lib/auth"; +import { WORKSPACE_SLUG_COOKIE } from "@/lib/workspace-cookie"; /** * Build a `ServiceContext` from a Next.js Edge Route Handler request. @@ -44,7 +45,7 @@ export async function getServiceContextFromRequest( .get("cookie") ?.split(";") .map((c) => c.trim()) - .find((c) => c.startsWith("workspace-slug=")); + .find((c) => c.startsWith(`${WORKSPACE_SLUG_COOKIE}=`)); return raw ? raw.slice(raw.indexOf("=") + 1) : undefined; })(); diff --git a/apps/dashboard/src/lib/workspace-cookie.ts b/apps/dashboard/src/lib/workspace-cookie.ts new file mode 100644 index 000000000..89ab3ed2c --- /dev/null +++ b/apps/dashboard/src/lib/workspace-cookie.ts @@ -0,0 +1,8 @@ +export const WORKSPACE_SLUG_COOKIE = "workspace-slug"; + +// Client-side workspace switch: set the cookie the proxy reads, then hard-reload +// so every server component re-resolves against the new workspace. +export function switchWorkspace(slug: string) { + document.cookie = `${WORKSPACE_SLUG_COOKIE}=${slug}; path=/;`; + window.location.href = "/overview"; +} diff --git a/apps/dashboard/src/proxy.ts b/apps/dashboard/src/proxy.ts index 2e5254bc8..1a00a9df0 100644 --- a/apps/dashboard/src/proxy.ts +++ b/apps/dashboard/src/proxy.ts @@ -4,6 +4,7 @@ import { getCurrency } from "@openstatus/db/src/schema/plan/utils"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; +import { WORKSPACE_SLUG_COOKIE } from "@/lib/workspace-cookie"; export default auth(async (req) => { const url = req.nextUrl.clone(); @@ -74,7 +75,7 @@ export default auth(async (req) => { } } - const hasWorkspaceSlug = req.cookies.has("workspace-slug"); + const hasWorkspaceSlug = req.cookies.has(WORKSPACE_SLUG_COOKIE); if (req.auth?.user?.id && !hasWorkspaceSlug) { const [query] = await db @@ -89,11 +90,11 @@ export default auth(async (req) => { console.error(">> Should not happen, no workspace found for user"); } - response.cookies.set("workspace-slug", query.workspace.slug); + response.cookies.set(WORKSPACE_SLUG_COOKIE, query.workspace.slug); } if (!req.auth && hasWorkspaceSlug) { - response.cookies.delete("workspace-slug"); + response.cookies.delete(WORKSPACE_SLUG_COOKIE); } // auth-redirect is single-use and consumed by the /onboarding Server -- 2.51.2 From a8f25585bec8fc3669ae58eaf1f2a80fca973f23 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:21:32 +0200 Subject: [PATCH 010/109] chore: changelog dashboard cmdk (#2418) --- .../public/assets/changelog/dashboard-cmdk.png | Bin 0 -> 370325 bytes .../content/pages/changelog/dashboard-cmdk.mdx | 12 ++++++++++++ 2 files changed, 12 insertions(+) create mode 100644 apps/web/public/assets/changelog/dashboard-cmdk.png create mode 100644 apps/web/src/content/pages/changelog/dashboard-cmdk.mdx diff --git a/apps/web/public/assets/changelog/dashboard-cmdk.png b/apps/web/public/assets/changelog/dashboard-cmdk.png new file mode 100644 index 0000000000000000000000000000000000000000..282bb63981d81205732205ee3495163f14aa6ad4 GIT binary patch literal 370325 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

*vTAtT@!gs>*R!@oRdc`tzcDX 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

`yi8Z&ekRP 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≪~*Q(3Q->?3DZ{5rMyLP4i*YEx+3;(|M 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 =}To?Q5{?#hJb2hPa zcK^2*ncZKvaJB24=$)s`nqx2d?iW#+FztC*6{oO*LxL@!C68~F2~T^L+EsiPF|c^EL?HOz-VK}6EdR}~?_YNQXwj5EKbFd@ ze%&*F@8jqDHrSRm3$;IY&HwV#=E<*!yt#7vx4-@T_%w6+tMkFX`Xp7ita*NF3!nOy z_?@P&Gz@>m&Dj=w@@M-$@4|P#|Ceuk?#Zqmvc7Zk*>m4BU)vSNnc0LSe4pZfpY`y! z1Mg1Ft$qFa=R(iQ;b9J@)U_Pno^45s=LYYWSbbY@RGzS)oLMGwvzpyM0on`^=Z-E$F)B>I{ ze!KbjS@dZKr(n(}i+iU==le`O7WBqs7o&09wc7B$e4~$-&h;L=`@6|o%EP#@HrJzp zq46m9l-V~uR^_r*%7*reG?ZN_yZ3&|frt-RHl#dV)a3kn!M!<)&wg{8muLQL`@CP9 zlRkIqmoGN(GTiMT`$KAR^m~@QeXR25=N-1wTvAq?FERPvHI8pD_eit8yY_O~o^P6N z+N*{2KkQ}o!l_@xXLwxG+~+gDn>B6!jWwGZ&bbvlPdaV*{LZU$%!kiEe=*16 z%ZTC7(;VYMXe*VMrR&r6Jk?iq*t=87QA7|>Hw+m0- z@!XUBtF`Rhldosjo-f_9B|B)%#jRHBrk~AD`@46mn#iik9S*;2LgZ&&oE*V-|L|^b zGHW;>#oe(c{&DmAR}em=4H)6o`0zy7*C%RWzJ-^JlNrCweyn_DZpqDCXYE;UrmS2h zZG3Z8X8P&0sI#-1F018$_YSOJJ@v*aG%x?!>*tonJm2S}f4Vepx`UjJXl2U5n$M?- z)bIYiCH;HN?0iMBS--ArzOS_{{)eAo#3sQU@vbdCGnW=Gtv_h+!L;^&>cQimS9sse zlY17tC{`}b4Wj;Mz z-1)4Uz>VB=Uh{SK=E83>gdtUBf-dW?&+>*zy0M|qom$W?#4{Cv{yx88%>#CUbN*!i zyYan*`R@~N({`aN+uyfmUN8S}`?Naixt_caJ$Juk>Wga%DmWzUW!<*9FM(U?;69yA z4!dn`6zuJH`JR2BXGYYOjknD?H1eBE=D)L0J#+i}QLpXF{fjp}x!uQ-Y?hS+jk%D z#;bEzCB)76v*YRKpJuBK;;-jc#PL{P47sfMc4g6(cdud#GPk~5xcIL8qs@O$9X{L_ z@%?Sy#T{zP_n-UT6SH@B`_5@BmrARbH@p7ov;X%!sq){|yF$5h@6L^H+qtKA#(90` zo8jwvUPjHe>)v?hkB!;%aK6OlPKVzgeX(WMxr?G}pT4=eYJKv=DS~g(*{4Ew#6;va b{+HK{N}6mZw|6lE0|SGntDnm{r-UW|P%^q1 literal 0 HcmV?d00001 diff --git a/apps/web/src/content/pages/changelog/dashboard-cmdk.mdx b/apps/web/src/content/pages/changelog/dashboard-cmdk.mdx new file mode 100644 index 000000000..7e0ab5d95 --- /dev/null +++ b/apps/web/src/content/pages/changelog/dashboard-cmdk.mdx @@ -0,0 +1,12 @@ +--- +title: "Command Menu for the Dashboard" +description: "Jump anywhere in the dashboard with ⌘K — navigate, search, and create without leaving the keyboard." +image: "/assets/changelog/dashboard-cmdk.png" +publishedAt: "2026-07-23" +category: "company" +author: "openstatus" +--- + +Press `⌘K` (or `Ctrl K`) anywhere in the dashboard to open the command menu. + +Search across your monitors and status pages, jump straight to any page, deep-link into settings, or kick off a new monitor, status report, or maintenance window — all without touching the mouse. -- 2.51.2 From ffbf4e816fbd3073721f9b7a2f5d1505f0db8b6e Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:26:28 +0200 Subject: [PATCH 011/109] chore: multi icons family package (#2361) * chore: phase 1 * fix: duplicate icons * fix: unused * fix: unused icons * chore: nucleo icons * fix: semantic layer * fix: masked * fix: license * fix: app sidebar left * fix: icon set * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- apps/dashboard/next-env.d.ts | 2 +- apps/dashboard/next.config.ts | 16 ++ .../src/app/(dashboard)/agents/breadcrumb.tsx | 4 +- .../app/(dashboard)/agents/nav-actions.tsx | 4 +- .../src/app/(dashboard)/agents/page.tsx | 2 +- .../src/app/(dashboard)/chat/breadcrumb.tsx | 6 +- .../src/app/(dashboard)/chat/sidebar.tsx | 4 +- .../src/app/(dashboard)/cli/breadcrumb.tsx | 2 +- .../src/app/(dashboard)/cli/nav-actions.tsx | 4 +- .../src/app/(dashboard)/cli/page.tsx | 28 +-- .../monitors/(list)/breadcrumb.tsx | 4 +- .../(dashboard)/monitors/(list)/client.tsx | 4 +- .../(dashboard)/monitors/[id]/breadcrumb.tsx | 4 +- .../(dashboard)/monitors/[id]/constants.ts | 12 +- .../monitors/[id]/incidents/page.tsx | 2 +- .../(dashboard)/monitors/[id]/logs/client.tsx | 2 +- .../(dashboard)/monitors/[id]/nav-actions.tsx | 4 +- .../app/(dashboard)/monitors/[id]/sidebar.tsx | 2 +- .../monitors/create/breadcrumb.tsx | 4 +- .../src/app/(dashboard)/not-found.tsx | 10 +- .../(dashboard)/notifications/breadcrumb.tsx | 4 +- .../app/(dashboard)/overview/breadcrumb.tsx | 4 +- .../overview/create-event-button-group.tsx | 6 +- .../src/app/(dashboard)/overview/page.tsx | 6 +- .../settings/account/breadcrumb.tsx | 6 +- .../settings/audit-logs/breadcrumb.tsx | 6 +- .../settings/audit-logs/client.tsx | 2 +- .../settings/billing/breadcrumb.tsx | 6 +- .../settings/general/breadcrumb.tsx | 6 +- .../settings/integrations/breadcrumb.tsx | 6 +- .../settings/integrations/slack-card.tsx | 2 +- .../settings/private-locations/breadcrumb.tsx | 4 +- .../settings/private-locations/client.tsx | 2 +- .../status-pages/(list)/breadcrumb.tsx | 4 +- .../status-pages/(list)/client.tsx | 4 +- .../status-pages/[id]/breadcrumb.tsx | 4 +- .../status-pages/[id]/constants.ts | 27 ++- .../status-pages/[id]/history/client.tsx | 2 +- .../status-pages/[id]/maintenances/page.tsx | 4 +- .../status-pages/[id]/nav-actions.tsx | 2 +- .../(dashboard)/status-pages/[id]/sidebar.tsx | 2 +- .../[id]/status-reports/[reportId]/page.tsx | 4 +- .../status-pages/[id]/status-reports/page.tsx | 6 +- .../status-pages/[id]/subscribers/page.tsx | 4 +- .../status-pages/create/breadcrumb.tsx | 4 +- apps/dashboard/src/app/login/page.tsx | 4 +- .../src/app/onboarding/_steps/step-1.tsx | 4 +- .../src/app/onboarding/_steps/step-2.tsx | 4 +- .../src/app/onboarding/_steps/step-3.tsx | 32 +-- apps/dashboard/src/app/onboarding/client.tsx | 8 +- .../src/components/chat/chat-prompt-input.tsx | 8 +- .../src/components/chat/chat-tool-part.tsx | 8 +- .../components/command-menu/command-menu.tsx | 6 +- apps/dashboard/src/components/common/code.tsx | 2 +- .../src/components/common/copy-row.tsx | 2 +- .../components/common/icon-cloud-provider.tsx | 4 +- apps/dashboard/src/components/common/note.tsx | 4 +- .../src/components/content/billing-addons.tsx | 6 +- .../components/content/hint-collapsible.tsx | 2 +- .../controls-search/button-reset.tsx | 4 +- .../controls-search/command-region.tsx | 2 +- .../controls-search/command-tags.tsx | 2 +- .../controls-search/dropdown-interval.tsx | 2 +- .../controls-search/dropdown-percentile.tsx | 2 +- .../controls-search/dropdown-period.tsx | 2 +- .../controls-search/dropdown-status.tsx | 2 +- .../controls-search/dropdown-trigger.tsx | 2 +- .../audit-logs-workspace/columns.tsx | 10 +- .../data-table-toolbar.tsx | 8 +- .../data-table/billing/data-table.tsx | 2 +- .../monitors/data-table-action-bar.tsx | 6 +- .../monitors/data-table-toolbar.tsx | 4 +- .../data-table/response-logs/columns.tsx | 4 +- .../response-logs/data-table-basics.tsx | 6 +- .../response-logs/data-table-sheet.tsx | 2 +- .../response-logs/data-table-toolbar.tsx | 10 +- .../status-report-updates/data-table.tsx | 4 +- .../data-table/status-reports/columns.tsx | 2 +- .../subscribers/data-table-row-actions.tsx | 4 +- .../subscribers/data-table-toolbar.tsx | 6 +- .../components/data-table/table-cell-link.tsx | 2 +- .../src/components/dialogs/export-code.tsx | 2 +- .../src/components/dialogs/upgrade.tsx | 4 +- .../components/domains/domain-status-icon.tsx | 8 +- .../domains/domain-troubleshooting.tsx | 2 +- .../components/dropdowns/quick-actions.tsx | 14 +- .../forms/components/form-components.tsx | 52 ++--- .../forms/components/form-import.tsx | 6 +- .../forms/components/telegram-qrcode.tsx | 4 +- .../components/forms/form-alert-dialog.tsx | 2 +- .../src/components/forms/maintenance/form.tsx | 6 +- .../forms/monitor-tag/form-monitor-tag.tsx | 6 +- .../components/forms/monitor/form-general.tsx | 18 +- .../components/forms/monitor/form-otel.tsx | 6 +- .../forms/monitor/form-scheduling-regions.tsx | 4 +- .../components/forms/monitor/form-tags.tsx | 4 +- .../forms/monitor/form-visibility.tsx | 2 +- .../forms/notifications/form-webhook.tsx | 6 +- .../forms/onboarding/create-monitor.tsx | 4 +- .../forms/onboarding/create-page.tsx | 12 +- .../components/forms/onboarding/question.tsx | 28 +-- .../forms/private-location/form.tsx | 6 +- .../forms/settings/form-api-key.tsx | 2 +- .../forms/settings/form-members.tsx | 2 +- .../components/forms/settings/form-slug.tsx | 2 +- .../forms/status-page/form-appearance.tsx | 8 +- .../forms/status-page/form-configuration.tsx | 2 +- .../forms/status-page/form-custom-domain.tsx | 2 +- .../forms/status-page/form-custom-theme.tsx | 6 +- .../forms/status-page/form-locale.tsx | 2 +- .../forms/status-page/form-monitors.tsx | 22 +- .../forms/status-page/form-page-access.tsx | 14 +- .../forms/status-page/theme-picker.tsx | 4 +- .../components/forms/status-page/update.tsx | 2 +- .../form-status-report.tsx | 4 +- .../forms/status-report-update/form.tsx | 4 +- .../components/forms/status-report/form.tsx | 4 +- .../src/components/forms/step-card.tsx | 2 +- .../src/components/forms/subscriber/form.tsx | 6 +- .../src/components/layout/auth-layout.tsx | 2 +- .../components/layout/onboarding-layout.tsx | 2 +- .../src/components/metric/metric-card.tsx | 2 +- .../src/components/nav/app-sidebar.tsx | 15 +- .../components/nav/nav-banner-checklist.tsx | 8 +- .../src/components/nav/nav-banner-upgrade.tsx | 6 +- .../src/components/nav/nav-breadcrumb.tsx | 6 +- .../src/components/nav/nav-feedback.tsx | 4 +- .../dashboard/src/components/nav/nav-help.tsx | 4 +- .../src/components/nav/nav-logout.tsx | 4 +- .../dashboard/src/components/nav/nav-main.tsx | 4 +- .../src/components/nav/nav-monitors.tsx | 6 +- .../src/components/nav/nav-overview.tsx | 4 +- .../src/components/nav/nav-search.tsx | 2 +- .../src/components/nav/nav-status-pages.tsx | 6 +- .../dashboard/src/components/nav/nav-tabs.tsx | 4 +- .../dashboard/src/components/nav/nav-user.tsx | 42 ++-- .../src/components/nav/sidebar-metadata.tsx | 10 +- .../src/components/nav/sidebar-right.tsx | 4 +- .../src/components/nav/workspace-switcher.tsx | 6 +- .../components/onboarding/checks-table.tsx | 6 +- .../components/onboarding/feature-badges.tsx | 40 ++-- .../dashboard/src/components/theme-toggle.tsx | 8 +- apps/dashboard/src/config/help.ts | 14 +- apps/dashboard/src/config/nav.ts | 34 +-- apps/dashboard/src/config/settings.ts | 23 +- apps/dashboard/src/data/audit-logs.client.ts | 18 +- apps/dashboard/src/data/icons.ts | 8 +- apps/dashboard/src/data/incidents.client.ts | 6 +- .../dashboard/src/data/maintenances.client.ts | 6 +- apps/dashboard/src/data/monitors.client.ts | 14 +- .../src/data/notifications.client.ts | 34 +-- .../src/data/overview-events.client.ts | 26 +-- .../src/data/page-components.client.ts | 4 +- .../src/data/region-metrics.client.ts | 2 +- .../dashboard/src/data/status-pages.client.ts | 6 +- .../src/data/status-report-updates.client.ts | 6 +- .../src/data/status-reports.client.ts | 10 +- apps/status-page/next.config.ts | 14 ++ apps/status-page/package.json | 1 + .../login/_components/section-magic-link.tsx | 2 +- .../[locale]/(auth)/restricted/page.tsx | 4 +- .../[locale]/(public)/monitors/[id]/page.tsx | 4 +- .../src/components/button/button-back.tsx | 4 +- .../components/button/button-copy-link.tsx | 2 +- .../src/components/content/metric-card.tsx | 2 +- .../content/timestamp-hover-card.tsx | 2 +- .../status-page/src/components/nav/footer.tsx | 2 +- .../status-page/src/components/nav/header.tsx | 4 +- .../status-page/floating-button.tsx | 4 +- .../components/status-page/floating-theme.tsx | 6 +- .../components/status-page/status-updates.tsx | 2 +- .../themes/theme-palette-picker.tsx | 4 +- .../src/components/themes/theme-select.tsx | 8 +- .../src/components/themes/theme-sidebar.tsx | 18 +- apps/status-page/src/data/icons.ts | 10 +- apps/status-page/src/data/incidents.client.ts | 6 +- .../src/data/maintenances.client.ts | 6 +- apps/status-page/src/data/monitors.client.ts | 8 +- .../src/data/region-metrics.client.ts | 4 +- .../src/data/status-pages.client.ts | 6 +- .../src/data/status-report-updates.client.ts | 6 +- .../src/data/status-reports.client.ts | 8 +- apps/web/next.config.ts | 14 ++ .../status/external-service-components.tsx | 6 +- .../app/(landing)/status/service-search.tsx | 2 +- .../src/components/icon-cloud-provider.tsx | 4 +- apps/web/src/components/icons.tsx | 202 ++++-------------- apps/web/src/content/cmdk.tsx | 16 +- apps/web/src/content/docs-feedback.tsx | 4 +- apps/web/src/content/docs-sidebar.tsx | 2 +- apps/web/src/content/theme-toggle.tsx | 8 +- packages/icons/README.md | 195 ++--------------- packages/icons/package.json | 28 ++- .../icons/src/{ => brand}/betterstack.tsx | 0 packages/icons/src/{ => brand}/bluesky.tsx | 0 packages/icons/src/{ => brand}/discord.tsx | 0 packages/icons/src/{ => brand}/fly.tsx | 0 packages/icons/src/{ => brand}/github.tsx | 0 packages/icons/src/{ => brand}/google.tsx | 0 packages/icons/src/{ => brand}/grafana.tsx | 0 packages/icons/src/{ => brand}/index.tsx | 0 packages/icons/src/{ => brand}/instatus.tsx | 0 packages/icons/src/{ => brand}/koyeb.tsx | 0 packages/icons/src/{ => brand}/linkedin.tsx | 0 packages/icons/src/{ => brand}/markdown.tsx | 0 .../src/{ => brand}/modelcontextprotocol.tsx | 0 packages/icons/src/{ => brand}/ms-teams.tsx | 0 packages/icons/src/{ => brand}/opsgenie.tsx | 0 packages/icons/src/{ => brand}/pagerduty.tsx | 0 packages/icons/src/{ => brand}/railway.tsx | 0 packages/icons/src/{ => brand}/slack.tsx | 0 packages/icons/src/{ => brand}/statuspage.tsx | 0 packages/icons/src/{ => brand}/telegram.tsx | 0 packages/icons/src/{ => brand}/whatsapp.tsx | 0 packages/icons/src/{ => brand}/x.tsx | 0 packages/icons/src/{ => brand}/youtube.tsx | 0 packages/icons/src/lucide/index.ts | 122 +++++++++++ packages/icons/src/nucleo/NOTICE | 4 + packages/icons/src/nucleo/account.tsx | 39 ++++ packages/icons/src/nucleo/acknowledge.tsx | 28 +++ packages/icons/src/nucleo/add.tsx | 43 ++++ packages/icons/src/nucleo/agent.tsx | 77 +++++++ packages/icons/src/nucleo/ai.tsx | 34 +++ packages/icons/src/nucleo/alert.tsx | 43 ++++ packages/icons/src/nucleo/api-key.tsx | 34 +++ packages/icons/src/nucleo/api-trigger.tsx | 71 ++++++ packages/icons/src/nucleo/api.tsx | 37 ++++ packages/icons/src/nucleo/arrow-down.tsx | 40 ++++ packages/icons/src/nucleo/arrow-up-right.tsx | 40 ++++ packages/icons/src/nucleo/audio-lines.tsx | 89 ++++++++ packages/icons/src/nucleo/audit-log.tsx | 69 ++++++ packages/icons/src/nucleo/back.tsx | 40 ++++ packages/icons/src/nucleo/billing.tsx | 70 ++++++ packages/icons/src/nucleo/blog.tsx | 54 +++++ packages/icons/src/nucleo/book-open.tsx | 37 ++++ packages/icons/src/nucleo/calendar.tsx | 68 ++++++ packages/icons/src/nucleo/changelog.tsx | 71 ++++++ packages/icons/src/nucleo/chat.tsx | 28 +++ packages/icons/src/nucleo/check.tsx | 28 +++ packages/icons/src/nucleo/chevron-down.tsx | 28 +++ packages/icons/src/nucleo/chevron-right.tsx | 28 +++ packages/icons/src/nucleo/chevron-up.tsx | 28 +++ packages/icons/src/nucleo/clock.tsx | 39 ++++ packages/icons/src/nucleo/close.tsx | 43 ++++ packages/icons/src/nucleo/code.tsx | 37 ++++ packages/icons/src/nucleo/collapse.tsx | 37 ++++ packages/icons/src/nucleo/command.tsx | 66 ++++++ packages/icons/src/nucleo/compare.tsx | 60 ++++++ packages/icons/src/nucleo/components.tsx | 74 +++++++ packages/icons/src/nucleo/copy.tsx | 43 ++++ packages/icons/src/nucleo/dark.tsx | 28 +++ packages/icons/src/nucleo/database.tsx | 47 ++++ packages/icons/src/nucleo/delete.tsx | 64 ++++++ packages/icons/src/nucleo/docs.tsx | 53 +++++ packages/icons/src/nucleo/download.tsx | 70 ++++++ packages/icons/src/nucleo/drag-handle.tsx | 82 +++++++ packages/icons/src/nucleo/duplicate.tsx | 55 +++++ packages/icons/src/nucleo/edit.tsx | 45 ++++ packages/icons/src/nucleo/email.tsx | 43 ++++ packages/icons/src/nucleo/enter.tsx | 36 ++++ packages/icons/src/nucleo/error.tsx | 54 +++++ packages/icons/src/nucleo/expand.tsx | 37 ++++ packages/icons/src/nucleo/external-link.tsx | 49 +++++ packages/icons/src/nucleo/failed.tsx | 42 ++++ packages/icons/src/nucleo/file-json.tsx | 76 +++++++ packages/icons/src/nucleo/file-text.tsx | 72 +++++++ packages/icons/src/nucleo/filter.tsx | 28 +++ packages/icons/src/nucleo/fingerprint.tsx | 62 ++++++ packages/icons/src/nucleo/globe.tsx | 54 +++++ packages/icons/src/nucleo/help.tsx | 45 ++++ packages/icons/src/nucleo/hide.tsx | 54 +++++ packages/icons/src/nucleo/image.tsx | 50 +++++ packages/icons/src/nucleo/impact.tsx | 55 +++++ packages/icons/src/nucleo/inbox.tsx | 37 ++++ packages/icons/src/nucleo/incident.tsx | 90 ++++++++ packages/icons/src/nucleo/index.ts | 120 +++++++++++ packages/icons/src/nucleo/info.tsx | 43 ++++ packages/icons/src/nucleo/integrations.tsx | 65 ++++++ packages/icons/src/nucleo/invite.tsx | 54 +++++ packages/icons/src/nucleo/launch.tsx | 59 +++++ packages/icons/src/nucleo/light.tsx | 126 +++++++++++ packages/icons/src/nucleo/link.tsx | 37 ++++ packages/icons/src/nucleo/linked.tsx | 48 +++++ packages/icons/src/nucleo/list-filter.tsx | 54 +++++ packages/icons/src/nucleo/loading.tsx | 115 ++++++++++ packages/icons/src/nucleo/lock.tsx | 54 +++++ packages/icons/src/nucleo/login.tsx | 49 +++++ packages/icons/src/nucleo/logout.tsx | 49 +++++ packages/icons/src/nucleo/logs.tsx | 89 ++++++++ packages/icons/src/nucleo/maintenance.tsx | 37 ++++ packages/icons/src/nucleo/menu.tsx | 54 +++++ packages/icons/src/nucleo/mic.tsx | 53 +++++ packages/icons/src/nucleo/monitor.tsx | 28 +++ packages/icons/src/nucleo/more.tsx | 51 +++++ packages/icons/src/nucleo/network.tsx | 81 +++++++ packages/icons/src/nucleo/next.tsx | 40 ++++ packages/icons/src/nucleo/notification.tsx | 37 ++++ packages/icons/src/nucleo/overview.tsx | 74 +++++++ packages/icons/src/nucleo/pending.tsx | 88 ++++++++ packages/icons/src/nucleo/play.tsx | 28 +++ packages/icons/src/nucleo/plug.tsx | 63 ++++++ .../icons/src/nucleo/private-location.tsx | 74 +++++++ packages/icons/src/nucleo/refresh.tsx | 37 ++++ packages/icons/src/nucleo/region.tsx | 43 ++++ packages/icons/src/nucleo/remove.tsx | 31 +++ packages/icons/src/nucleo/report.tsx | 54 +++++ packages/icons/src/nucleo/reset.tsx | 37 ++++ packages/icons/src/nucleo/resolved.tsx | 45 ++++ packages/icons/src/nucleo/restricted.tsx | 43 ++++ packages/icons/src/nucleo/schedule.tsx | 58 +++++ packages/icons/src/nucleo/search.tsx | 37 ++++ packages/icons/src/nucleo/send.tsx | 49 +++++ packages/icons/src/nucleo/server.tsx | 78 +++++++ packages/icons/src/nucleo/settings.tsx | 37 ++++ packages/icons/src/nucleo/shield-user.tsx | 48 +++++ packages/icons/src/nucleo/shield.tsx | 28 +++ packages/icons/src/nucleo/show.tsx | 37 ++++ packages/icons/src/nucleo/sidebar-left.tsx | 45 ++++ packages/icons/src/nucleo/sidebar.tsx | 46 ++++ packages/icons/src/nucleo/speed.tsx | 28 +++ packages/icons/src/nucleo/status-page.tsx | 46 ++++ packages/icons/src/nucleo/stop.tsx | 33 +++ packages/icons/src/nucleo/success.tsx | 39 ++++ packages/icons/src/nucleo/support.tsx | 124 +++++++++++ packages/icons/src/nucleo/system.tsx | 40 ++++ .../icons/src/nucleo/table-properties.tsx | 45 ++++ packages/icons/src/nucleo/table.tsx | 55 +++++ packages/icons/src/nucleo/tag.tsx | 36 ++++ packages/icons/src/nucleo/team.tsx | 54 +++++ packages/icons/src/nucleo/terminal.tsx | 40 ++++ packages/icons/src/nucleo/theme.tsx | 57 +++++ packages/icons/src/nucleo/trend.tsx | 37 ++++ packages/icons/src/nucleo/unlinked.tsx | 61 ++++++ packages/icons/src/nucleo/unlock.tsx | 54 +++++ packages/icons/src/nucleo/warning.tsx | 43 ++++ packages/icons/src/parity.ts | 16 ++ packages/icons/src/types.ts | 6 + pnpm-lock.yaml | 6 + 338 files changed, 6986 insertions(+), 971 deletions(-) rename packages/icons/src/{ => brand}/betterstack.tsx (100%) rename packages/icons/src/{ => brand}/bluesky.tsx (100%) rename packages/icons/src/{ => brand}/discord.tsx (100%) rename packages/icons/src/{ => brand}/fly.tsx (100%) rename packages/icons/src/{ => brand}/github.tsx (100%) rename packages/icons/src/{ => brand}/google.tsx (100%) rename packages/icons/src/{ => brand}/grafana.tsx (100%) rename packages/icons/src/{ => brand}/index.tsx (100%) rename packages/icons/src/{ => brand}/instatus.tsx (100%) rename packages/icons/src/{ => brand}/koyeb.tsx (100%) rename packages/icons/src/{ => brand}/linkedin.tsx (100%) rename packages/icons/src/{ => brand}/markdown.tsx (100%) rename packages/icons/src/{ => brand}/modelcontextprotocol.tsx (100%) rename packages/icons/src/{ => brand}/ms-teams.tsx (100%) rename packages/icons/src/{ => brand}/opsgenie.tsx (100%) rename packages/icons/src/{ => brand}/pagerduty.tsx (100%) rename packages/icons/src/{ => brand}/railway.tsx (100%) rename packages/icons/src/{ => brand}/slack.tsx (100%) rename packages/icons/src/{ => brand}/statuspage.tsx (100%) rename packages/icons/src/{ => brand}/telegram.tsx (100%) rename packages/icons/src/{ => brand}/whatsapp.tsx (100%) rename packages/icons/src/{ => brand}/x.tsx (100%) rename packages/icons/src/{ => brand}/youtube.tsx (100%) create mode 100644 packages/icons/src/lucide/index.ts create mode 100644 packages/icons/src/nucleo/NOTICE create mode 100644 packages/icons/src/nucleo/account.tsx create mode 100644 packages/icons/src/nucleo/acknowledge.tsx create mode 100644 packages/icons/src/nucleo/add.tsx create mode 100644 packages/icons/src/nucleo/agent.tsx create mode 100644 packages/icons/src/nucleo/ai.tsx create mode 100644 packages/icons/src/nucleo/alert.tsx create mode 100644 packages/icons/src/nucleo/api-key.tsx create mode 100644 packages/icons/src/nucleo/api-trigger.tsx create mode 100644 packages/icons/src/nucleo/api.tsx create mode 100644 packages/icons/src/nucleo/arrow-down.tsx create mode 100644 packages/icons/src/nucleo/arrow-up-right.tsx create mode 100644 packages/icons/src/nucleo/audio-lines.tsx create mode 100644 packages/icons/src/nucleo/audit-log.tsx create mode 100644 packages/icons/src/nucleo/back.tsx create mode 100644 packages/icons/src/nucleo/billing.tsx create mode 100644 packages/icons/src/nucleo/blog.tsx create mode 100644 packages/icons/src/nucleo/book-open.tsx create mode 100644 packages/icons/src/nucleo/calendar.tsx create mode 100644 packages/icons/src/nucleo/changelog.tsx create mode 100644 packages/icons/src/nucleo/chat.tsx create mode 100644 packages/icons/src/nucleo/check.tsx create mode 100644 packages/icons/src/nucleo/chevron-down.tsx create mode 100644 packages/icons/src/nucleo/chevron-right.tsx create mode 100644 packages/icons/src/nucleo/chevron-up.tsx create mode 100644 packages/icons/src/nucleo/clock.tsx create mode 100644 packages/icons/src/nucleo/close.tsx create mode 100644 packages/icons/src/nucleo/code.tsx create mode 100644 packages/icons/src/nucleo/collapse.tsx create mode 100644 packages/icons/src/nucleo/command.tsx create mode 100644 packages/icons/src/nucleo/compare.tsx create mode 100644 packages/icons/src/nucleo/components.tsx create mode 100644 packages/icons/src/nucleo/copy.tsx create mode 100644 packages/icons/src/nucleo/dark.tsx create mode 100644 packages/icons/src/nucleo/database.tsx create mode 100644 packages/icons/src/nucleo/delete.tsx create mode 100644 packages/icons/src/nucleo/docs.tsx create mode 100644 packages/icons/src/nucleo/download.tsx create mode 100644 packages/icons/src/nucleo/drag-handle.tsx create mode 100644 packages/icons/src/nucleo/duplicate.tsx create mode 100644 packages/icons/src/nucleo/edit.tsx create mode 100644 packages/icons/src/nucleo/email.tsx create mode 100644 packages/icons/src/nucleo/enter.tsx create mode 100644 packages/icons/src/nucleo/error.tsx create mode 100644 packages/icons/src/nucleo/expand.tsx create mode 100644 packages/icons/src/nucleo/external-link.tsx create mode 100644 packages/icons/src/nucleo/failed.tsx create mode 100644 packages/icons/src/nucleo/file-json.tsx create mode 100644 packages/icons/src/nucleo/file-text.tsx create mode 100644 packages/icons/src/nucleo/filter.tsx create mode 100644 packages/icons/src/nucleo/fingerprint.tsx create mode 100644 packages/icons/src/nucleo/globe.tsx create mode 100644 packages/icons/src/nucleo/help.tsx create mode 100644 packages/icons/src/nucleo/hide.tsx create mode 100644 packages/icons/src/nucleo/image.tsx create mode 100644 packages/icons/src/nucleo/impact.tsx create mode 100644 packages/icons/src/nucleo/inbox.tsx create mode 100644 packages/icons/src/nucleo/incident.tsx create mode 100644 packages/icons/src/nucleo/index.ts create mode 100644 packages/icons/src/nucleo/info.tsx create mode 100644 packages/icons/src/nucleo/integrations.tsx create mode 100644 packages/icons/src/nucleo/invite.tsx create mode 100644 packages/icons/src/nucleo/launch.tsx create mode 100644 packages/icons/src/nucleo/light.tsx create mode 100644 packages/icons/src/nucleo/link.tsx create mode 100644 packages/icons/src/nucleo/linked.tsx create mode 100644 packages/icons/src/nucleo/list-filter.tsx create mode 100644 packages/icons/src/nucleo/loading.tsx create mode 100644 packages/icons/src/nucleo/lock.tsx create mode 100644 packages/icons/src/nucleo/login.tsx create mode 100644 packages/icons/src/nucleo/logout.tsx create mode 100644 packages/icons/src/nucleo/logs.tsx create mode 100644 packages/icons/src/nucleo/maintenance.tsx create mode 100644 packages/icons/src/nucleo/menu.tsx create mode 100644 packages/icons/src/nucleo/mic.tsx create mode 100644 packages/icons/src/nucleo/monitor.tsx create mode 100644 packages/icons/src/nucleo/more.tsx create mode 100644 packages/icons/src/nucleo/network.tsx create mode 100644 packages/icons/src/nucleo/next.tsx create mode 100644 packages/icons/src/nucleo/notification.tsx create mode 100644 packages/icons/src/nucleo/overview.tsx create mode 100644 packages/icons/src/nucleo/pending.tsx create mode 100644 packages/icons/src/nucleo/play.tsx create mode 100644 packages/icons/src/nucleo/plug.tsx create mode 100644 packages/icons/src/nucleo/private-location.tsx create mode 100644 packages/icons/src/nucleo/refresh.tsx create mode 100644 packages/icons/src/nucleo/region.tsx create mode 100644 packages/icons/src/nucleo/remove.tsx create mode 100644 packages/icons/src/nucleo/report.tsx create mode 100644 packages/icons/src/nucleo/reset.tsx create mode 100644 packages/icons/src/nucleo/resolved.tsx create mode 100644 packages/icons/src/nucleo/restricted.tsx create mode 100644 packages/icons/src/nucleo/schedule.tsx create mode 100644 packages/icons/src/nucleo/search.tsx create mode 100644 packages/icons/src/nucleo/send.tsx create mode 100644 packages/icons/src/nucleo/server.tsx create mode 100644 packages/icons/src/nucleo/settings.tsx create mode 100644 packages/icons/src/nucleo/shield-user.tsx create mode 100644 packages/icons/src/nucleo/shield.tsx create mode 100644 packages/icons/src/nucleo/show.tsx create mode 100644 packages/icons/src/nucleo/sidebar-left.tsx create mode 100644 packages/icons/src/nucleo/sidebar.tsx create mode 100644 packages/icons/src/nucleo/speed.tsx create mode 100644 packages/icons/src/nucleo/status-page.tsx create mode 100644 packages/icons/src/nucleo/stop.tsx create mode 100644 packages/icons/src/nucleo/success.tsx create mode 100644 packages/icons/src/nucleo/support.tsx create mode 100644 packages/icons/src/nucleo/system.tsx create mode 100644 packages/icons/src/nucleo/table-properties.tsx create mode 100644 packages/icons/src/nucleo/table.tsx create mode 100644 packages/icons/src/nucleo/tag.tsx create mode 100644 packages/icons/src/nucleo/team.tsx create mode 100644 packages/icons/src/nucleo/terminal.tsx create mode 100644 packages/icons/src/nucleo/theme.tsx create mode 100644 packages/icons/src/nucleo/trend.tsx create mode 100644 packages/icons/src/nucleo/unlinked.tsx create mode 100644 packages/icons/src/nucleo/unlock.tsx create mode 100644 packages/icons/src/nucleo/warning.tsx create mode 100644 packages/icons/src/parity.ts create mode 100644 packages/icons/src/types.ts diff --git a/apps/dashboard/next-env.d.ts b/apps/dashboard/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/dashboard/next-env.d.ts +++ b/apps/dashboard/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts index 548a41c52..cef49b6ef 100644 --- a/apps/dashboard/next.config.ts +++ b/apps/dashboard/next.config.ts @@ -1,8 +1,24 @@ import { withSentryConfig } from "@sentry/nextjs"; import type { NextConfig } from "next"; +// ICON_SET=nucleo swaps the icon set at resolution time (see packages/icons) +const useNucleoIcons = process.env.ICON_SET === "nucleo"; + const nextConfig: NextConfig = { output: process.env.SELF_HOST === "true" ? "standalone" : undefined, + experimental: { + // barrel-optimize only when unaliased — the rewrite would bypass the nucleo alias + optimizePackageImports: useNucleoIcons ? [] : ["@openstatus/icons"], + }, + turbopack: useNucleoIcons + ? { resolveAlias: { "@openstatus/icons": "@openstatus/icons/nucleo" } } + : undefined, + webpack: (config) => { + if (useNucleoIcons) { + config.resolve.alias["@openstatus/icons$"] = "@openstatus/icons/nucleo"; + } + return config; + }, images: { remotePatterns: [ new URL("https://openstatus.dev/**"), diff --git a/apps/dashboard/src/app/(dashboard)/agents/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/agents/breadcrumb.tsx index f60bdeb77..314e38dbc 100644 --- a/apps/dashboard/src/app/(dashboard)/agents/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/agents/breadcrumb.tsx @@ -1,13 +1,13 @@ "use client"; -import { Bot } from "lucide-react"; +import { Agent } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; export function Breadcrumb() { return ( ); } diff --git a/apps/dashboard/src/app/(dashboard)/agents/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/agents/nav-actions.tsx index 4bb9b6669..6702d7a64 100644 --- a/apps/dashboard/src/app/(dashboard)/agents/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/agents/nav-actions.tsx @@ -1,3 +1,4 @@ +import { Docs } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Tooltip, @@ -5,7 +6,6 @@ import { TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; -import { Book } from "lucide-react"; import { NavFeedback } from "@/components/nav/nav-feedback"; @@ -22,7 +22,7 @@ export function NavActions() { target="_blank" rel="noreferrer" > - + diff --git a/apps/dashboard/src/app/(dashboard)/agents/page.tsx b/apps/dashboard/src/app/(dashboard)/agents/page.tsx index add05eca2..89520ea59 100644 --- a/apps/dashboard/src/app/(dashboard)/agents/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/agents/page.tsx @@ -1,8 +1,8 @@ "use client"; +import { Info } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useQuery } from "@tanstack/react-query"; -import { Info } from "lucide-react"; import { Code } from "@/components/common/code"; import { Link } from "@/components/common/link"; diff --git a/apps/dashboard/src/app/(dashboard)/chat/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/chat/breadcrumb.tsx index b4903dff2..f5d2173b6 100644 --- a/apps/dashboard/src/app/(dashboard)/chat/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/chat/breadcrumb.tsx @@ -1,7 +1,7 @@ "use client"; +import { Chat } from "@openstatus/icons"; import { skipToken, useQuery } from "@tanstack/react-query"; -import { MessageSquare } from "lucide-react"; import { useChatSessionContext } from "@/components/chat/chat-session-context"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -21,7 +21,7 @@ export function Breadcrumb() { if (sessionId === undefined) { return ( ); } @@ -33,7 +33,7 @@ export function Breadcrumb() { type: "link", label: "Assistant", href: "/chat", - icon: MessageSquare, + icon: Chat, }, { type: "page", label: session?.title ?? "New chat" }, ]} diff --git a/apps/dashboard/src/app/(dashboard)/chat/sidebar.tsx b/apps/dashboard/src/app/(dashboard)/chat/sidebar.tsx index b7d662b30..830a20412 100644 --- a/apps/dashboard/src/app/(dashboard)/chat/sidebar.tsx +++ b/apps/dashboard/src/app/(dashboard)/chat/sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { Add } from "@openstatus/icons"; import { skipToken, useMutation, @@ -7,7 +8,6 @@ import { useQueryClient, } from "@tanstack/react-query"; import { formatDistanceToNow } from "date-fns"; -import { Plus } from "lucide-react"; import { useRouter } from "next/navigation"; import { useChatSessionContext } from "@/components/chat/chat-session-context"; @@ -86,7 +86,7 @@ export function Sidebar() { onClick: () => router.push("/chat"), children: ( <> - + New chat ), diff --git a/apps/dashboard/src/app/(dashboard)/cli/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/cli/breadcrumb.tsx index aa7c0fe1d..10b4015ec 100644 --- a/apps/dashboard/src/app/(dashboard)/cli/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/cli/breadcrumb.tsx @@ -1,6 +1,6 @@ "use client"; -import { Terminal } from "lucide-react"; +import { Terminal } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; diff --git a/apps/dashboard/src/app/(dashboard)/cli/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/cli/nav-actions.tsx index 4bb9b6669..6702d7a64 100644 --- a/apps/dashboard/src/app/(dashboard)/cli/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/cli/nav-actions.tsx @@ -1,3 +1,4 @@ +import { Docs } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Tooltip, @@ -5,7 +6,6 @@ import { TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; -import { Book } from "lucide-react"; import { NavFeedback } from "@/components/nav/nav-feedback"; @@ -22,7 +22,7 @@ export function NavActions() { target="_blank" rel="noreferrer" > - + diff --git a/apps/dashboard/src/app/(dashboard)/cli/page.tsx b/apps/dashboard/src/app/(dashboard)/cli/page.tsx index 52bea3881..016554b22 100644 --- a/apps/dashboard/src/app/(dashboard)/cli/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/cli/page.tsx @@ -1,18 +1,18 @@ +import { + Download, + FileJson, + ApiKey as KeyIcon, + Login, + Report, + StatusPage, + Terminal, +} from "@openstatus/icons"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; -import { - FileDown, - FileJson, - Key, - LogIn, - Megaphone, - PanelTop, - Terminal, -} from "lucide-react"; import React from "react"; import { Code } from "@/components/common/code"; @@ -48,13 +48,13 @@ const installs = [ }, { title: "Login", - icon: LogIn, + icon: Login, description: "Save your API token for use in subsequent commands.", command: "openstatus login", }, { title: "Add API Key", - icon: Key, + icon: KeyIcon, description: ( <> Or set it as an environment variable. Create an API key in your @@ -69,19 +69,19 @@ const installs = [ }, { title: "List Status Pages", - icon: PanelTop, + icon: StatusPage, description: "List all status pages in your workspace.", command: "openstatus status-page list", }, { title: "List Status Reports", - icon: Megaphone, + icon: Report, description: "List all status reports in your workspace.", command: "openstatus status-report list", }, { title: "Import Monitors", - icon: FileDown, + icon: Download, description: "Import monitors from your workspace to a YAML file.", command: "openstatus monitors import", }, diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/monitors/(list)/breadcrumb.tsx index 744e30462..4c2e22584 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/(list)/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/breadcrumb.tsx @@ -1,13 +1,13 @@ "use client"; -import { Activity } from "lucide-react"; +import { Monitor } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; export function Breadcrumb() { return ( ); } diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx index 2513ab866..87086116e 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx @@ -1,8 +1,8 @@ "use client"; +import { ArrowDown, Success, ListFilter } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; import type { ColumnFiltersState, SortingState } from "@tanstack/react-table"; -import { ArrowDown, CheckCircle, ListFilter } from "lucide-react"; import { useQueryStates } from "nuqs"; import { useEffect, useState } from "react"; @@ -33,7 +33,7 @@ import { searchParamsParsers } from "./search-params"; const icons = { default: { - active: CheckCircle, + active: Success, inactive: ListFilter, }, p95: { diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx index 9cc24df22..12bfcaead 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx @@ -1,7 +1,7 @@ "use client"; +import { Monitor } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; -import { Activity } from "lucide-react"; import { useParams, usePathname } from "next/navigation"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -25,7 +25,7 @@ export function Breadcrumb() { return ( - + Test Monitor diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx index adfa85202..ff45460de 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx @@ -1,9 +1,9 @@ "use client"; import { deserialize } from "@openstatus/assertions"; +import { Logs } from "@openstatus/icons"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { useQuery } from "@tanstack/react-query"; -import { Logs } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { TableCellLink } from "@/components/data-table/table-cell-link"; diff --git a/apps/dashboard/src/app/(dashboard)/monitors/create/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/monitors/create/breadcrumb.tsx index 9cc21f586..d59d30731 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/create/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/create/breadcrumb.tsx @@ -1,6 +1,6 @@ "use client"; -import { Activity } from "lucide-react"; +import { Monitor } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -12,7 +12,7 @@ export function Breadcrumb() { type: "link", label: "Monitors", href: "/monitors", - icon: Activity, + icon: Monitor, }, { type: "page", label: "Create Monitor" }, ]} diff --git a/apps/dashboard/src/app/(dashboard)/not-found.tsx b/apps/dashboard/src/app/(dashboard)/not-found.tsx index e65f10d6c..447a4e03e 100644 --- a/apps/dashboard/src/app/(dashboard)/not-found.tsx +++ b/apps/dashboard/src/app/(dashboard)/not-found.tsx @@ -1,13 +1,13 @@ -import { Activity, Cog, LayoutGrid, PanelTop } from "lucide-react"; +import { Monitor, Settings, Overview, StatusPage } from "@openstatus/icons"; import Link from "next/link"; import { WorkspaceSwitcher } from "@/components/nav/workspace-switcher"; const quickLinks = [ - { name: "Overview", href: "/overview", icon: LayoutGrid }, - { name: "Monitors", href: "/monitors", icon: Activity }, - { name: "Status Pages", href: "/status-pages", icon: PanelTop }, - { name: "Settings", href: "/settings/general", icon: Cog }, + { name: "Overview", href: "/overview", icon: Overview }, + { name: "Monitors", href: "/monitors", icon: Monitor }, + { name: "Status Pages", href: "/status-pages", icon: StatusPage }, + { name: "Settings", href: "/settings/general", icon: Settings }, ]; export default function NotFound() { diff --git a/apps/dashboard/src/app/(dashboard)/notifications/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/notifications/breadcrumb.tsx index ff1644ca6..7cc19cdf9 100644 --- a/apps/dashboard/src/app/(dashboard)/notifications/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/notifications/breadcrumb.tsx @@ -1,13 +1,13 @@ "use client"; -import { Bell } from "lucide-react"; +import { Notification } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; export function Breadcrumb() { return ( ); } diff --git a/apps/dashboard/src/app/(dashboard)/overview/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/overview/breadcrumb.tsx index 020ddd598..4a27fea31 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/breadcrumb.tsx @@ -1,13 +1,13 @@ "use client"; -import { LayoutGrid } from "lucide-react"; +import { Overview } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; export function Breadcrumb() { return ( ); } diff --git a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx index ea75b8410..9194c419e 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx @@ -1,5 +1,6 @@ "use client"; +import { ChevronDown, Add } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { ButtonGroup } from "@openstatus/ui/components/ui/button-group"; import { @@ -8,7 +9,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; -import { ChevronDown, Plus } from "lucide-react"; import { useState } from "react"; import { FormSheetMaintenanceCreate } from "@/components/forms/maintenance/sheet-create"; @@ -22,7 +22,7 @@ export function CreateEventButtonGroup() { @@ -39,7 +39,7 @@ export function CreateEventButtonGroup() { setMaintenanceOpen(true)}> - + Create Maintenance diff --git a/apps/dashboard/src/app/(dashboard)/overview/page.tsx b/apps/dashboard/src/app/(dashboard)/overview/page.tsx index dd9ac4f0f..943a2e7ed 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/page.tsx @@ -1,7 +1,7 @@ "use client"; +import { Agent } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; -import { Bot } from "lucide-react"; import Link from "next/link"; import { NoteButton } from "@/components/common/note"; @@ -60,7 +60,7 @@ export default function Page() { return ( - + Use our Slack agent to manage your status pages and incidents. Learn more @@ -84,7 +84,7 @@ export default function Page() { {metric.title} - + {metric.value} diff --git a/apps/dashboard/src/app/(dashboard)/settings/account/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/settings/account/breadcrumb.tsx index 3c4d88bb3..6d0596b82 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/account/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/account/breadcrumb.tsx @@ -1,6 +1,6 @@ "use client"; -import { Cog, User } from "lucide-react"; +import { Settings, Account } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -11,10 +11,10 @@ export function Breadcrumb() { { type: "link", label: "Settings", - icon: Cog, + icon: Settings, href: "/settings/general", }, - { type: "page", label: "Account", icon: User }, + { type: "page", label: "Account", icon: Account }, ]} /> ); diff --git a/apps/dashboard/src/app/(dashboard)/settings/audit-logs/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/settings/audit-logs/breadcrumb.tsx index cf300c8c5..3a4d42d15 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/audit-logs/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/audit-logs/breadcrumb.tsx @@ -1,6 +1,6 @@ "use client"; -import { Cog, ScanEye } from "lucide-react"; +import { Settings, AuditLog } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -11,10 +11,10 @@ export function Breadcrumb() { { type: "link", label: "Settings", - icon: Cog, + icon: Settings, href: "/settings/general", }, - { type: "page", label: "Audit Logs", icon: ScanEye }, + { type: "page", label: "Audit Logs", icon: AuditLog }, ]} /> ); diff --git a/apps/dashboard/src/app/(dashboard)/settings/audit-logs/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/audit-logs/client.tsx index 338798bdd..577306549 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/audit-logs/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/audit-logs/client.tsx @@ -1,8 +1,8 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Lock } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; -import { Lock } from "lucide-react"; import { useState } from "react"; import { Link } from "@/components/common/link"; diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/breadcrumb.tsx index 74d92d24d..d3f8476f9 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/billing/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/breadcrumb.tsx @@ -1,6 +1,6 @@ "use client"; -import { Cog, CreditCard } from "lucide-react"; +import { Settings, Billing } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -11,10 +11,10 @@ export function Breadcrumb() { { type: "link", label: "Settings", - icon: Cog, + icon: Settings, href: "/settings/general", }, - { type: "page", label: "Billing", icon: CreditCard }, + { type: "page", label: "Billing", icon: Billing }, ]} /> ); diff --git a/apps/dashboard/src/app/(dashboard)/settings/general/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/settings/general/breadcrumb.tsx index a79f3eb9f..d28285ce3 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/general/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/general/breadcrumb.tsx @@ -1,6 +1,6 @@ "use client"; -import { Cog, SlidersHorizontal } from "lucide-react"; +import { Settings } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -11,10 +11,10 @@ export function Breadcrumb() { { type: "link", label: "Settings", - icon: Cog, + icon: Settings, href: "/settings/general", }, - { type: "page", label: "General", icon: SlidersHorizontal }, + { type: "page", label: "General", icon: Settings }, ]} /> ); diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/breadcrumb.tsx index 6c448ebbb..8f33ec7a4 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/breadcrumb.tsx @@ -1,6 +1,6 @@ "use client"; -import { Blocks, Cog } from "lucide-react"; +import { Integrations, Settings } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -11,10 +11,10 @@ export function Breadcrumb() { { type: "link", label: "Settings", - icon: Cog, + icon: Settings, href: "/settings/general", }, - { type: "page", label: "Integrations", icon: Blocks }, + { type: "page", label: "Integrations", icon: Integrations }, ]} /> ); diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx index 6206e52d3..5fe0096e6 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx @@ -1,9 +1,9 @@ "use client"; +import { Lock } from "@openstatus/icons"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Lock } from "lucide-react"; import { useRouter } from "next/navigation"; import { Link } from "@/components/common/link"; diff --git a/apps/dashboard/src/app/(dashboard)/settings/private-locations/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/settings/private-locations/breadcrumb.tsx index 104b96b16..5cdadbf8b 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/private-locations/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/private-locations/breadcrumb.tsx @@ -1,6 +1,6 @@ "use client"; -import { Cog, Globe } from "lucide-react"; +import { Settings, Globe } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -11,7 +11,7 @@ export function Breadcrumb() { { type: "link", label: "Settings", - icon: Cog, + icon: Settings, href: "/settings/general", }, { type: "page", label: "Private Locations", icon: Globe }, diff --git a/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx index 88624caa7..8b8f4dfa5 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx @@ -1,7 +1,7 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Lock } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; -import { Lock } from "lucide-react"; import { useState } from "react"; import { Link } from "@/components/common/link"; diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/breadcrumb.tsx index 141c700a0..06818b543 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/breadcrumb.tsx @@ -1,13 +1,13 @@ "use client"; -import { PanelTop } from "lucide-react"; +import { StatusPage } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; export function Breadcrumb() { return ( ); } diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx index d9c98e801..7723dbce2 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx @@ -1,7 +1,7 @@ "use client"; +import { Theme } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; -import { Palette } from "lucide-react"; import Link from "next/link"; import { NoteButton } from "@/components/common/note"; @@ -27,7 +27,7 @@ export function Client() { return ( - + Create your own custom themes for your status pages. diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx index dadb8783e..6ae97cfa4 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx @@ -1,7 +1,7 @@ "use client"; +import { StatusPage } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; -import { PanelTop } from "lucide-react"; import { useParams, usePathname } from "next/navigation"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -31,7 +31,7 @@ export function Breadcrumb() { type: "link", label: "Status Pages", href: "/status-pages", - icon: PanelTop, + icon: StatusPage, }, { type: "link", diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/constants.ts b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/constants.ts index a1a57bfed..0c8a2389a 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/constants.ts +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/constants.ts @@ -1,23 +1,22 @@ -import type { LucideIcon } from "lucide-react"; +import type { IconType } from "@openstatus/icons"; import { - CalendarDays, - Cog, - Hammer, - LayoutTemplate, - Megaphone, - Users, -} from "lucide-react"; + Settings, + Maintenance, + Components, + Report, + Team, +} from "@openstatus/icons"; export const STATUS_PAGE_TABS: { value: string; label: string; - icon: LucideIcon; + icon: IconType; }[] = [ - { value: "status-reports", label: "Status Reports", icon: Megaphone }, - { value: "maintenances", label: "Maintenances", icon: Hammer }, - { value: "subscribers", label: "Subscribers", icon: Users }, - { value: "components", label: "Components", icon: LayoutTemplate }, + { value: "status-reports", label: "Status Reports", icon: Report }, + { value: "maintenances", label: "Maintenances", icon: Maintenance }, + { value: "subscribers", label: "Subscribers", icon: Team }, + { value: "components", label: "Components", icon: Components }, // TODO: hidden in the tabs but still accessible via direct link - can be enabled in the future // { value: "history", label: "History", icon: CalendarDays }, - { value: "edit", label: "Settings", icon: Cog }, + { value: "edit", label: "Settings", icon: Settings }, ]; diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx index f7b0a679e..e5f488d95 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx @@ -1,9 +1,9 @@ "use client"; +import { Info, Lock } from "@openstatus/icons"; import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; -import { Info, Lock } from "lucide-react"; import { useParams } from "next/navigation"; import { useQueryStates } from "nuqs"; import { useMemo, useState } from "react"; diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx index 250b4514d..65930a078 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx @@ -1,8 +1,8 @@ "use client"; +import { Add } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { Plus } from "lucide-react"; import { useParams } from "next/navigation"; import { Link } from "@/components/common/link"; @@ -82,7 +82,7 @@ export default function Page() { }} > diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx index 372f16377..3979fdd38 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx @@ -1,5 +1,6 @@ "use client"; +import { Globe } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Tooltip, @@ -8,7 +9,6 @@ import { TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Globe } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { toast } from "sonner"; diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx index 153da5bd8..9dc6e2e24 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { ExternalLink } from "@openstatus/icons"; import { Tooltip, TooltipContent, @@ -8,7 +9,6 @@ import { } from "@openstatus/ui/components/ui/tooltip"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { useQuery } from "@tanstack/react-query"; -import { ExternalLink } from "lucide-react"; import { useParams } from "next/navigation"; import { Link } from "@/components/common/link"; diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx index fe4410e0f..d5ac7ada5 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx @@ -1,9 +1,9 @@ "use client"; import { currentImpactsFromUpdates } from "@openstatus/db/src/schema/page_components/constants"; +import { Add } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plus } from "lucide-react"; import { useParams } from "next/navigation"; import { @@ -127,7 +127,7 @@ export default function Page() { }} > diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx index 65c1e5aaf..4aea477eb 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx @@ -1,8 +1,8 @@ "use client"; +import { Impact, Add } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Gauge, Plus } from "lucide-react"; import NextLink from "next/link"; import { useParams } from "next/navigation"; @@ -62,7 +62,7 @@ export default function Page() { return ( - + Status reports now support per-component impacts. diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx index 7a57fdd32..67d3350a9 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx @@ -1,9 +1,9 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Lock, Add } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { Lock, Plus } from "lucide-react"; import { notFound, useParams } from "next/navigation"; import { useState } from "react"; @@ -145,7 +145,7 @@ export default function Page() { }} > )} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/create/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/create/breadcrumb.tsx index 5c75011b3..f814f2478 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/create/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/create/breadcrumb.tsx @@ -1,6 +1,6 @@ "use client"; -import { PanelTop } from "lucide-react"; +import { StatusPage } from "@openstatus/icons"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; @@ -12,7 +12,7 @@ export function Breadcrumb() { type: "link", label: "Status Pages", href: "/status-pages", - icon: PanelTop, + icon: StatusPage, }, { type: "page", label: "Create Status Page" }, ]} diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx index 96a30e007..e443923a7 100644 --- a/apps/dashboard/src/app/login/page.tsx +++ b/apps/dashboard/src/app/login/page.tsx @@ -1,5 +1,5 @@ -import { GitHubIcon } from "@openstatus/icons"; -import { GoogleIcon } from "@openstatus/icons"; +import { GitHubIcon } from "@openstatus/icons/brand"; +import { GoogleIcon } from "@openstatus/icons/brand"; import { Separator } from "@openstatus/ui/components/ui/separator"; import type { Metadata } from "next"; import Link from "next/link"; diff --git a/apps/dashboard/src/app/onboarding/_steps/step-1.tsx b/apps/dashboard/src/app/onboarding/_steps/step-1.tsx index 03314132c..1c6cd4ad7 100644 --- a/apps/dashboard/src/app/onboarding/_steps/step-1.tsx +++ b/apps/dashboard/src/app/onboarding/_steps/step-1.tsx @@ -1,8 +1,8 @@ "use client"; +import { Next } from "@openstatus/icons"; import { AVAILABLE_REGIONS } from "@openstatus/regions"; import { Button } from "@openstatus/ui/components/ui/button"; -import { ArrowRight } from "lucide-react"; import { useMemo } from "react"; import { CreateMonitorForm } from "@/components/forms/onboarding/create-monitor"; @@ -112,7 +112,7 @@ export function Step1({ {isLocked ? ( ) : ( <> diff --git a/apps/dashboard/src/app/onboarding/_steps/step-2.tsx b/apps/dashboard/src/app/onboarding/_steps/step-2.tsx index 3187fdd37..e35645056 100644 --- a/apps/dashboard/src/app/onboarding/_steps/step-2.tsx +++ b/apps/dashboard/src/app/onboarding/_steps/step-2.tsx @@ -1,8 +1,8 @@ "use client"; +import { Next } from "@openstatus/icons"; import type { ThemeKey } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; -import { ArrowRight } from "lucide-react"; import { useState } from "react"; import { @@ -97,7 +97,7 @@ export function Step2({ {isLocked ? ( ) : ( <> diff --git a/apps/dashboard/src/app/onboarding/_steps/step-3.tsx b/apps/dashboard/src/app/onboarding/_steps/step-3.tsx index 4fbe2b212..8b85996ef 100644 --- a/apps/dashboard/src/app/onboarding/_steps/step-3.tsx +++ b/apps/dashboard/src/app/onboarding/_steps/step-3.tsx @@ -1,16 +1,16 @@ "use client"; -import { Button } from "@openstatus/ui/components/ui/button"; import { - Activity, - ArrowRight, - Bell, + Monitor, + Next, + Notification, Calendar, - CreditCard, - LayoutGrid, - PanelTop, - UserPlus, -} from "lucide-react"; + Billing, + Overview, + StatusPage, + Invite, +} from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; import Link from "next/link"; import { useMemo } from "react"; @@ -55,20 +55,20 @@ export function Step3({ const priority: Link[] = []; if (monitorSkipped) { - priority.push({ name: "Monitors", href: "/monitors", icon: Activity }); + priority.push({ name: "Monitors", href: "/monitors", icon: Monitor }); } if (pageSkipped) { priority.push({ name: "Status Pages", href: "/status-pages", - icon: PanelTop, + icon: StatusPage, }); } const filler: Link[] = [ - { name: "Invite team", href: "/settings/general", icon: UserPlus }, - { name: "Notifiers", href: "/notifications", icon: Bell }, - { name: "Overview", href: "/overview", icon: LayoutGrid }, - { name: "Billing", href: "/settings/billing", icon: CreditCard }, + { name: "Invite team", href: "/settings/general", icon: Invite }, + { name: "Notifiers", href: "/notifications", icon: Notification }, + { name: "Overview", href: "/overview", icon: Overview }, + { name: "Billing", href: "/settings/billing", icon: Billing }, ]; return [...priority, ...filler].slice(0, 4); }, [monitorSkipped, pageSkipped]); @@ -99,7 +99,7 @@ export function Step3({ diff --git a/apps/dashboard/src/components/chat/chat-tool-part.tsx b/apps/dashboard/src/components/chat/chat-tool-part.tsx index c7efc34de..74ba4173b 100644 --- a/apps/dashboard/src/components/chat/chat-tool-part.tsx +++ b/apps/dashboard/src/components/chat/chat-tool-part.tsx @@ -1,3 +1,4 @@ +import { Api, ChevronDown, Table } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Collapsible, @@ -12,7 +13,6 @@ import { type UITools, getToolName, } from "ai"; -import { BracesIcon, ChevronDownIcon, TableIcon } from "lucide-react"; import { type KeyboardEvent, useEffect, useRef, useState } from "react"; import { ChangesTable } from "@/components/common/changes-table"; @@ -191,7 +191,7 @@ function ToolDisclosure({ tabIndex={open ? 0 : -1} className="h-[18px] px-1 [&_svg:not([class*='size-'])]:size-3" > - + - + ) : null} - - + ) : null} @@ -374,7 +374,7 @@ export function CommandMenu() { setTheme(resolvedTheme === "dark" ? "light" : "dark") } > - {resolvedTheme === "dark" ? : } + {resolvedTheme === "dark" ? : } Switch to{" "} {resolvedTheme === "dark" ? "light" : "dark"} theme diff --git a/apps/dashboard/src/components/common/code.tsx b/apps/dashboard/src/components/common/code.tsx index 4cea2f3ab..6e350d774 100644 --- a/apps/dashboard/src/components/common/code.tsx +++ b/apps/dashboard/src/components/common/code.tsx @@ -1,9 +1,9 @@ "use client"; +import { Check, Copy } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; -import { Check, Copy } from "lucide-react"; export function Code({ children, diff --git a/apps/dashboard/src/components/common/copy-row.tsx b/apps/dashboard/src/components/common/copy-row.tsx index 62f40f231..715f547d8 100644 --- a/apps/dashboard/src/components/common/copy-row.tsx +++ b/apps/dashboard/src/components/common/copy-row.tsx @@ -1,7 +1,7 @@ "use client"; +import { Check, Copy } from "@openstatus/icons"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; -import { Check, Copy } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/apps/dashboard/src/components/common/icon-cloud-provider.tsx b/apps/dashboard/src/components/common/icon-cloud-provider.tsx index a1357480e..37f72e82e 100644 --- a/apps/dashboard/src/components/common/icon-cloud-provider.tsx +++ b/apps/dashboard/src/components/common/icon-cloud-provider.tsx @@ -1,4 +1,5 @@ -import { Fly, Koyeb, Railway } from "@openstatus/icons"; +import { Globe } from "@openstatus/icons"; +import { Fly, Koyeb, Railway } from "@openstatus/icons/brand"; import { Tooltip, TooltipContent, @@ -6,7 +7,6 @@ import { TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; -import { Globe } from "lucide-react"; export function IconCloudProvider({ provider, diff --git a/apps/dashboard/src/components/common/note.tsx b/apps/dashboard/src/components/common/note.tsx index 09a9b0d41..243a03664 100644 --- a/apps/dashboard/src/components/common/note.tsx +++ b/apps/dashboard/src/components/common/note.tsx @@ -1,7 +1,7 @@ +import { Close } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; -import { X } from "lucide-react"; const noteVariants = cva( "flex items-center gap-2 rounded-lg border [&>svg]:shrink-0 [&>svg]:text-current", @@ -79,7 +79,7 @@ export function NoteButtonClose({ className={cn("text-muted-foreground -mr-1 shrink-0", className)} {...props} > - + ); } diff --git a/apps/dashboard/src/components/content/billing-addons.tsx b/apps/dashboard/src/components/content/billing-addons.tsx index 848994e21..246c2979c 100644 --- a/apps/dashboard/src/components/content/billing-addons.tsx +++ b/apps/dashboard/src/components/content/billing-addons.tsx @@ -2,6 +2,7 @@ import type { RouterOutputs } from "@openstatus/api"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Addons } from "@openstatus/db/src/schema/plan/schema"; import { getAddonPriceConfig } from "@openstatus/db/src/schema/plan/utils"; +import { Check, Remove, Add } from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, @@ -20,7 +21,6 @@ import { Label } from "@openstatus/ui/components/ui/label"; import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; -import { Check, MinusIcon, PlusIcon } from "lucide-react"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; @@ -261,7 +261,7 @@ function QuantityControl({ onClick={() => setValue(value - 1)} disabled={value <= 0} > - + setValue(value + 1)} > - + diff --git a/apps/dashboard/src/components/content/hint-collapsible.tsx b/apps/dashboard/src/components/content/hint-collapsible.tsx index f9cbd787c..91e8522bf 100644 --- a/apps/dashboard/src/components/content/hint-collapsible.tsx +++ b/apps/dashboard/src/components/content/hint-collapsible.tsx @@ -1,11 +1,11 @@ "use client"; +import { ChevronDown } from "@openstatus/icons"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@openstatus/ui/components/ui/collapsible"; -import { ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/apps/dashboard/src/components/controls-search/button-reset.tsx b/apps/dashboard/src/components/controls-search/button-reset.tsx index 7115970c7..83ed8f38d 100644 --- a/apps/dashboard/src/components/controls-search/button-reset.tsx +++ b/apps/dashboard/src/components/controls-search/button-reset.tsx @@ -1,7 +1,7 @@ "use client"; +import { Close } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; -import { X } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; export function ButtonReset({ only }: { only?: string[] }) { @@ -38,7 +38,7 @@ export function ButtonReset({ only }: { only?: string[] }) { return ( ); diff --git a/apps/dashboard/src/components/controls-search/command-region.tsx b/apps/dashboard/src/components/controls-search/command-region.tsx index 8a6fdebc2..db8f7dbea 100644 --- a/apps/dashboard/src/components/controls-search/command-region.tsx +++ b/apps/dashboard/src/components/controls-search/command-region.tsx @@ -1,5 +1,6 @@ "use client"; +import { Check, Globe, Lock } from "@openstatus/icons"; import { formatRegionCode, groupByContinent } from "@openstatus/regions"; import { Button } from "@openstatus/ui/components/ui/button"; import { @@ -18,7 +19,6 @@ import { } from "@openstatus/ui/components/ui/popover"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; -import { Check, Globe, Lock } from "lucide-react"; import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs"; import { IconCloudProvider } from "@/components/common/icon-cloud-provider"; diff --git a/apps/dashboard/src/components/controls-search/command-tags.tsx b/apps/dashboard/src/components/controls-search/command-tags.tsx index 6a89a333d..9c531f849 100644 --- a/apps/dashboard/src/components/controls-search/command-tags.tsx +++ b/apps/dashboard/src/components/controls-search/command-tags.tsx @@ -1,5 +1,6 @@ "use client"; +import { Check } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, @@ -16,7 +17,6 @@ import { } from "@openstatus/ui/components/ui/popover"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; -import { Check } from "lucide-react"; import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs"; import { useTRPC } from "@/lib/trpc/client"; diff --git a/apps/dashboard/src/components/controls-search/dropdown-interval.tsx b/apps/dashboard/src/components/controls-search/dropdown-interval.tsx index 58950c94e..f69600cef 100644 --- a/apps/dashboard/src/components/controls-search/dropdown-interval.tsx +++ b/apps/dashboard/src/components/controls-search/dropdown-interval.tsx @@ -1,5 +1,6 @@ "use client"; +import { Check } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, @@ -9,7 +10,6 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; -import { Check } from "lucide-react"; import { parseAsNumberLiteral, parseAsStringLiteral, diff --git a/apps/dashboard/src/components/controls-search/dropdown-percentile.tsx b/apps/dashboard/src/components/controls-search/dropdown-percentile.tsx index 38bac274b..137630d52 100644 --- a/apps/dashboard/src/components/controls-search/dropdown-percentile.tsx +++ b/apps/dashboard/src/components/controls-search/dropdown-percentile.tsx @@ -1,5 +1,6 @@ "use client"; +import { Check } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, @@ -10,7 +11,6 @@ import { DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { cn } from "@openstatus/ui/lib/utils"; -import { Check } from "lucide-react"; import { parseAsStringLiteral, useQueryState } from "nuqs"; import { PERCENTILES } from "@/data/metrics.client"; diff --git a/apps/dashboard/src/components/controls-search/dropdown-period.tsx b/apps/dashboard/src/components/controls-search/dropdown-period.tsx index 88e0904dd..a5ddc6dcb 100644 --- a/apps/dashboard/src/components/controls-search/dropdown-period.tsx +++ b/apps/dashboard/src/components/controls-search/dropdown-period.tsx @@ -1,5 +1,6 @@ "use client"; +import { Check, Lock } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, @@ -10,7 +11,6 @@ import { DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { useQuery } from "@tanstack/react-query"; -import { Check, Lock } from "lucide-react"; import { parseAsStringLiteral, useQueryState } from "nuqs"; import { useState } from "react"; diff --git a/apps/dashboard/src/components/controls-search/dropdown-status.tsx b/apps/dashboard/src/components/controls-search/dropdown-status.tsx index 9b9dc62c4..7de7cc454 100644 --- a/apps/dashboard/src/components/controls-search/dropdown-status.tsx +++ b/apps/dashboard/src/components/controls-search/dropdown-status.tsx @@ -1,5 +1,6 @@ "use client"; +import { Check } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, @@ -10,7 +11,6 @@ import { DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { cn } from "@openstatus/ui/lib/utils"; -import { Check } from "lucide-react"; import { parseAsStringLiteral, useQueryState } from "nuqs"; import { STATUS } from "@/data/metrics.client"; diff --git a/apps/dashboard/src/components/controls-search/dropdown-trigger.tsx b/apps/dashboard/src/components/controls-search/dropdown-trigger.tsx index 707e3abe6..db2515d89 100644 --- a/apps/dashboard/src/components/controls-search/dropdown-trigger.tsx +++ b/apps/dashboard/src/components/controls-search/dropdown-trigger.tsx @@ -1,5 +1,6 @@ "use client"; +import { Check } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, @@ -10,7 +11,6 @@ import { DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { cn } from "@openstatus/ui/lib/utils"; -import { Check } from "lucide-react"; import { parseAsStringLiteral, useQueryState } from "nuqs"; import { TRIGGER } from "@/data/metrics.client"; diff --git a/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx b/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx index 6bf77ee02..2893fe595 100644 --- a/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx +++ b/apps/dashboard/src/components/data-table/audit-logs-workspace/columns.tsx @@ -1,7 +1,12 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; -import { ModelContextProtocolIcon, SlackIcon } from "@openstatus/icons"; +import { + ApiKey as KeyIcon, + Email, + Server as ServerIcon, +} from "@openstatus/icons"; +import { ModelContextProtocolIcon, SlackIcon } from "@openstatus/icons/brand"; import { Avatar, AvatarFallback, @@ -9,7 +14,6 @@ import { } from "@openstatus/ui/components/ui/avatar"; import { Badge } from "@openstatus/ui/components/ui/badge"; import type { ColumnDef } from "@tanstack/react-table"; -import { KeyIcon, MailIcon, ServerIcon } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -95,7 +99,7 @@ export const columns: ColumnDef[] = [ return (
- +
Subscriber
diff --git a/apps/dashboard/src/components/data-table/audit-logs-workspace/data-table-toolbar.tsx b/apps/dashboard/src/components/data-table/audit-logs-workspace/data-table-toolbar.tsx index 15f72f438..27bc285ef 100644 --- a/apps/dashboard/src/components/data-table/audit-logs-workspace/data-table-toolbar.tsx +++ b/apps/dashboard/src/components/data-table/audit-logs-workspace/data-table-toolbar.tsx @@ -1,9 +1,9 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Database, Account, Close, Speed } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import type { Table } from "@tanstack/react-table"; -import { Database, User, X, Zap } from "lucide-react"; import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; @@ -55,7 +55,7 @@ export function AuditLogsDataTableToolbar({ column={table.getColumn("actorType")} title="Actor type" options={actorTypeOptions} - icon={User} + icon={Account} /> )} {table.getColumn("action") && actionOptions.length > 0 && ( @@ -63,7 +63,7 @@ export function AuditLogsDataTableToolbar({ column={table.getColumn("action")} title="Action" options={actionOptions} - icon={Zap} + icon={Speed} /> )} {table.getColumn("entityType") && entityTypeOptions.length > 0 && ( @@ -81,7 +81,7 @@ export function AuditLogsDataTableToolbar({ className="h-8 px-2 lg:px-3" > Reset - + )} diff --git a/apps/dashboard/src/components/data-table/billing/data-table.tsx b/apps/dashboard/src/components/data-table/billing/data-table.tsx index b79572490..9eb8e43b6 100644 --- a/apps/dashboard/src/components/data-table/billing/data-table.tsx +++ b/apps/dashboard/src/components/data-table/billing/data-table.tsx @@ -6,6 +6,7 @@ import { getAddonPriceConfig, getPriceConfig, } from "@openstatus/db/src/schema/plan/utils"; +import { Check } from "@openstatus/icons"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { @@ -20,7 +21,6 @@ import { import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { Check } from "lucide-react"; import { useRouter } from "next/navigation"; import { Fragment, useState, useTransition } from "react"; diff --git a/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx b/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx index e06223f98..93423d78d 100644 --- a/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx +++ b/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx @@ -1,6 +1,7 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Check, Success, Copy, Delete as DeleteIcon } from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, @@ -26,7 +27,6 @@ import { SelectTrigger } from "@radix-ui/react-select"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Table } from "@tanstack/react-table"; import { isTRPCClientError } from "@trpc/client"; -import { Check, CheckCircle2, Copy, Trash2 } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -142,7 +142,7 @@ export function MonitorDataTableActionBar({ > - + @@ -166,7 +166,7 @@ export function MonitorDataTableActionBar({ tooltip="Delete monitors" isPending={isPending || deleteMonitorsMutation.isPending} > - + Reset - + )} diff --git a/apps/dashboard/src/components/data-table/response-logs/columns.tsx b/apps/dashboard/src/components/data-table/response-logs/columns.tsx index 4e4e65154..74f22b204 100644 --- a/apps/dashboard/src/components/data-table/response-logs/columns.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/columns.tsx @@ -2,6 +2,7 @@ import type { RouterOutputs } from "@openstatus/api"; import type { PrivateLocation } from "@openstatus/db/src/schema"; +import { Clock, ApiTrigger } from "@openstatus/icons"; import { Tooltip, TooltipContent, @@ -9,7 +10,6 @@ import { TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import type { ColumnDef } from "@tanstack/react-table"; -import { Clock, Workflow } from "lucide-react"; import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp"; import { HoverCardTiming } from "@/components/common/hover-card-timing"; @@ -122,7 +122,7 @@ export function getColumns( cell: ({ row }) => { const value = row.getValue("trigger"); if (value === "cron" || value === "api") { - const Icon = value === "cron" ? Clock : Workflow; + const Icon = value === "cron" ? Clock : ApiTrigger; const label = value === "cron" ? "Scheduled" : "API"; return ( diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx index 2516c162f..debc22e62 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx @@ -2,6 +2,7 @@ import type { RouterOutputs } from "@openstatus/api"; import type { PrivateLocation } from "@openstatus/db/src/schema"; +import { Api, TableProperties } from "@openstatus/icons"; import { getRegionInfo } from "@openstatus/regions"; import { Table, @@ -17,7 +18,6 @@ import { TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { cn } from "@openstatus/ui/lib/utils"; -import { Braces, TableProperties } from "lucide-react"; import { IconCloudProvider } from "@/components/common/icon-cloud-provider"; import { BlockWrapper } from "@/components/content/block-wrapper"; @@ -203,7 +203,7 @@ export function DataTableBasicsHTTP({ - + @@ -597,7 +597,7 @@ export function DataTableBasicsDNS({ - + diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx index 76f6f24d5..337234f86 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx @@ -2,10 +2,10 @@ import type { RouterOutputs } from "@openstatus/api"; import type { PrivateLocation } from "@openstatus/db/src/schema"; +import { Check, Copy } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; -import { Check, Copy } from "lucide-react"; import { DataTableSheet, diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx index c1700b337..2e6459d87 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx @@ -1,9 +1,9 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Monitor, Region, Warning, Close } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import type { Table } from "@tanstack/react-table"; -import { Activity, MapPin, TriangleAlert, X } from "lucide-react"; import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; import { regions } from "@/data/regions"; @@ -31,7 +31,7 @@ export function ResponseLogsDataTableToolbar({ label: code.code.toString(), value: code.code.toString(), }))} - icon={Activity} + icon={Monitor} /> )} {table.getColumn("region") && ( @@ -42,7 +42,7 @@ export function ResponseLogsDataTableToolbar({ label: region.location, value: region.code, }))} - icon={MapPin} + icon={Region} /> )} {table.getColumn("error") && ( @@ -53,7 +53,7 @@ export function ResponseLogsDataTableToolbar({ { label: "Yes", value: "true" }, { label: "No", value: "false" }, ]} - icon={TriangleAlert} + icon={Warning} /> )} {isFiltered && ( @@ -63,7 +63,7 @@ export function ResponseLogsDataTableToolbar({ className="h-8 px-2 lg:px-3" > Reset - + )} diff --git a/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx b/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx index 884b08336..583b929bf 100644 --- a/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx +++ b/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx @@ -2,6 +2,7 @@ import type { RouterOutputs } from "@openstatus/api"; import { currentImpactsFromUpdates } from "@openstatus/db/src/schema/page_components/constants"; +import { Add } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Table, @@ -18,7 +19,6 @@ import { TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Plus } from "lucide-react"; import { useParams } from "next/navigation"; import { ProcessMessage } from "@/components/content/process-message"; @@ -116,7 +116,7 @@ export function DataTable({ > diff --git a/apps/dashboard/src/components/data-table/status-reports/columns.tsx b/apps/dashboard/src/components/data-table/status-reports/columns.tsx index 80d9d61e9..c23fe6b63 100644 --- a/apps/dashboard/src/components/data-table/status-reports/columns.tsx +++ b/apps/dashboard/src/components/data-table/status-reports/columns.tsx @@ -5,10 +5,10 @@ import { type PageComponentImpact, worstImpact, } from "@openstatus/db/src/schema/page_components/constants"; +import { ChevronDown, ChevronUp } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; import type { ColumnDef } from "@tanstack/react-table"; -import { ChevronDown, ChevronUp } from "lucide-react"; import Link from "next/link"; import { TableCellBadge } from "@/components/data-table/table-cell-badge"; diff --git a/apps/dashboard/src/components/data-table/subscribers/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/subscribers/data-table-row-actions.tsx index 34b93dacb..09245bfc9 100644 --- a/apps/dashboard/src/components/data-table/subscribers/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/subscribers/data-table-row-actions.tsx @@ -1,10 +1,10 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Edit, Send } from "@openstatus/icons"; import { useMutation, useQuery } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; import { isTRPCClientError } from "@trpc/client"; -import { Pencil, Send } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; @@ -79,7 +79,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { { id: "edit", label: "Edit", - icon: Pencil, + icon: Edit, variant: "default" as const, onClick: () => setEditOpen(true), }, diff --git a/apps/dashboard/src/components/data-table/subscribers/data-table-toolbar.tsx b/apps/dashboard/src/components/data-table/subscribers/data-table-toolbar.tsx index 1ffcddc7a..dc4d7185a 100644 --- a/apps/dashboard/src/components/data-table/subscribers/data-table-toolbar.tsx +++ b/apps/dashboard/src/components/data-table/subscribers/data-table-toolbar.tsx @@ -1,9 +1,9 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Success, Globe, Close } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import type { Table } from "@tanstack/react-table"; -import { CircleCheck, Globe, X } from "lucide-react"; import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; @@ -50,7 +50,7 @@ export function SubscribersDataTableToolbar({ column={table.getColumn("status")} title="Status" options={statusOptions} - icon={CircleCheck} + icon={Success} /> )} {table.getColumn("source") && sourceOptions.length > 0 && ( @@ -68,7 +68,7 @@ export function SubscribersDataTableToolbar({ className="h-8 px-2 lg:px-3" > Reset - + )} diff --git a/apps/dashboard/src/components/data-table/table-cell-link.tsx b/apps/dashboard/src/components/data-table/table-cell-link.tsx index ff650b3e3..47a49002a 100644 --- a/apps/dashboard/src/components/data-table/table-cell-link.tsx +++ b/apps/dashboard/src/components/data-table/table-cell-link.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight, ChevronRight } from "lucide-react"; +import { ArrowUpRight, ChevronRight } from "@openstatus/icons"; import { Link } from "@/components/common/link"; import { cn } from "@/lib/utils"; diff --git a/apps/dashboard/src/components/dialogs/export-code.tsx b/apps/dashboard/src/components/dialogs/export-code.tsx index 671a31f91..1c10cb0a4 100644 --- a/apps/dashboard/src/components/dialogs/export-code.tsx +++ b/apps/dashboard/src/components/dialogs/export-code.tsx @@ -1,3 +1,4 @@ +import { Check, Copy } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Dialog, @@ -14,7 +15,6 @@ import { } from "@openstatus/ui/components/ui/tabs"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import type { DialogProps } from "@radix-ui/react-dialog"; -import { Check, Copy } from "lucide-react"; import { Link } from "@/components/common/link"; diff --git a/apps/dashboard/src/components/dialogs/upgrade.tsx b/apps/dashboard/src/components/dialogs/upgrade.tsx index 9d9e497d8..3459661b7 100644 --- a/apps/dashboard/src/components/dialogs/upgrade.tsx +++ b/apps/dashboard/src/components/dialogs/upgrade.tsx @@ -2,6 +2,7 @@ import type { WorkspacePlan } from "@openstatus/db/src/schema"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Addons, Limits } from "@openstatus/db/src/schema/plan/schema"; import { getPlansForLimit } from "@openstatus/db/src/schema/plan/utils"; +import { Schedule } from "@openstatus/icons"; import { Dialog, DialogContent, @@ -12,7 +13,6 @@ import { import { Separator } from "@openstatus/ui/components/ui/separator"; import type { DialogProps } from "@radix-ui/react-dialog"; import { useQuery } from "@tanstack/react-query"; -import { CalendarClock } from "lucide-react"; import { Link } from "@/components/common/link"; import { Note, NoteButton } from "@/components/common/note"; @@ -83,7 +83,7 @@ export function UpgradeDialog( ) : null} {restrictTo.length === 0 ? ( - + Please contact us to upgrade your plan. @@ -21,11 +21,11 @@ export function DomainStatusIcon({ ) : status === "Pending Verification" ? (
- +
) : (
- +
); } diff --git a/apps/dashboard/src/components/domains/domain-troubleshooting.tsx b/apps/dashboard/src/components/domains/domain-troubleshooting.tsx index 6cb2c84fc..1a0e6b2e6 100644 --- a/apps/dashboard/src/components/domains/domain-troubleshooting.tsx +++ b/apps/dashboard/src/components/domains/domain-troubleshooting.tsx @@ -1,5 +1,6 @@ "use client"; +import { ExternalLink } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Card, @@ -13,7 +14,6 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@openstatus/ui/components/ui/collapsible"; -import { ExternalLink } from "lucide-react"; import { useState } from "react"; import { Link } from "@/components/common/link"; diff --git a/apps/dashboard/src/components/dropdowns/quick-actions.tsx b/apps/dashboard/src/components/dropdowns/quick-actions.tsx index 80b8eb220..38bec9345 100644 --- a/apps/dashboard/src/components/dropdowns/quick-actions.tsx +++ b/apps/dashboard/src/components/dropdowns/quick-actions.tsx @@ -1,5 +1,6 @@ "use client"; +import { Check, Copy, type IconType, More, Delete } from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, @@ -25,13 +26,6 @@ import { Input } from "@openstatus/ui/components/ui/input"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import type { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu"; import { isTRPCClientError } from "@trpc/client"; -import { - Check, - Copy, - type LucideIcon, - MoreHorizontal, - Trash2, -} from "lucide-react"; import type * as React from "react"; import { useState, useTransition } from "react"; import { toast } from "sonner"; @@ -42,7 +36,7 @@ interface QuickActionsProps extends React.ComponentProps { actions?: { id: string; label: string; - icon: LucideIcon; + icon: IconType; variant: "default" | "destructive"; onClick?: () => Promise | void; }[]; @@ -105,7 +99,7 @@ export function QuickActions({ className={className ?? "data-[state=open]:bg-accent h-7 w-7"} {...props} > - + )} @@ -136,7 +130,7 @@ export function QuickActions({ {actions?.length ? : null} - + Delete diff --git a/apps/dashboard/src/components/forms/components/form-components.tsx b/apps/dashboard/src/components/forms/components/form-components.tsx index 6644b1f85..1f65910cb 100644 --- a/apps/dashboard/src/components/forms/components/form-components.tsx +++ b/apps/dashboard/src/components/forms/components/form-components.tsx @@ -3,6 +3,17 @@ import type { UniqueIdentifier } from "@dnd-kit/core"; import { zodResolver } from "@hookform/resolvers/zod"; import type { RouterOutputs } from "@openstatus/api"; +import { + Check, + Show, + Hide, + DragHandle, + Linked, + Unlinked, + Plug, + Add, + Delete, +} from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, @@ -50,17 +61,6 @@ import { TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { isTRPCClientError } from "@trpc/client"; -import { - Check, - Eye, - EyeOff, - GripVertical, - Link2, - Link2Off, - Plug, - Plus, - Trash2, -} from "lucide-react"; import { useCallback, useEffect, useState, useTransition } from "react"; import { type UseFormReturn, useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -508,7 +508,7 @@ export function FormComponents({ @@ -542,12 +542,12 @@ export function FormComponents({ ]); }} > - + Add Static Component - + Add Monitor Component @@ -741,7 +741,7 @@ function ComponentRow({
-
@@ -360,7 +360,7 @@ export function FormMaintenance({ }} />
-
diff --git a/apps/dashboard/src/components/forms/monitor-tag/form-monitor-tag.tsx b/apps/dashboard/src/components/forms/monitor-tag/form-monitor-tag.tsx index 741c72d64..ea2207020 100644 --- a/apps/dashboard/src/components/forms/monitor-tag/form-monitor-tag.tsx +++ b/apps/dashboard/src/components/forms/monitor-tag/form-monitor-tag.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Add, Delete } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, @@ -13,7 +14,6 @@ import { import { Input } from "@openstatus/ui/components/ui/input"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; -import { Plus, Trash2 } from "lucide-react"; import React, { useTransition } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -109,7 +109,7 @@ export function FormMonitorTag({ size="sm" onClick={() => append({ name: "", color: "#00008B" })} > - + Add Tag @@ -151,7 +151,7 @@ export function FormMonitorTag({ size="icon" onClick={() => remove(index)} > - + ))} diff --git a/apps/dashboard/src/components/forms/monitor/form-general.tsx b/apps/dashboard/src/components/forms/monitor/form-general.tsx index ab01845ea..0046efc2c 100644 --- a/apps/dashboard/src/components/forms/monitor/form-general.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-general.tsx @@ -13,6 +13,7 @@ import { textBodyAssertion, } from "@openstatus/assertions"; import { monitorMethods } from "@openstatus/db/src/schema"; +import { Globe, Network, Add, Server, Close } from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, @@ -54,7 +55,6 @@ import { } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; -import { Globe, Network, Plus, Server, X } from "lucide-react"; import { useEffect, useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -426,7 +426,7 @@ export function FormGeneral({ field.onChange(newHeaders); }} > - + ))} @@ -442,7 +442,7 @@ export function FormGeneral({ ]); }} > - + Add Header @@ -604,7 +604,7 @@ export function FormGeneral({ field.onChange(newAssertions); }} > - + ))} @@ -627,7 +627,7 @@ export function FormGeneral({ ]); }} > - + Add Status Assertion @@ -890,7 +890,7 @@ export function FormGeneral({ field.onChange(newAssertions); }} > - + ))} @@ -914,7 +914,7 @@ export function FormGeneral({ ]); }} > - + Add DNS Record Assertion diff --git a/apps/dashboard/src/components/forms/monitor/form-otel.tsx b/apps/dashboard/src/components/forms/monitor/form-otel.tsx index b5a431eff..f7082b9d8 100644 --- a/apps/dashboard/src/components/forms/monitor/form-otel.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-otel.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Lock, Add, Close } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, @@ -11,7 +12,6 @@ import { FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; -import { Lock, Plus, X } from "lucide-react"; import NextLink from "next/link"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; @@ -151,7 +151,7 @@ export function FormOtel({ field.onChange(newHeaders); }} > - + ))} @@ -168,7 +168,7 @@ export function FormOtel({ ]); }} > - + Add Header diff --git a/apps/dashboard/src/components/forms/monitor/form-scheduling-regions.tsx b/apps/dashboard/src/components/forms/monitor/form-scheduling-regions.tsx index bf2322db0..884e1cff1 100644 --- a/apps/dashboard/src/components/forms/monitor/form-scheduling-regions.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-scheduling-regions.tsx @@ -5,6 +5,7 @@ import { type Region, monitorPeriodicity, } from "@openstatus/db/src/schema/constants"; +import { Error, Globe, Info } from "@openstatus/icons"; import { formatRegionCode, groupByContinent, @@ -24,7 +25,6 @@ import { Slider } from "@openstatus/ui/components/ui/slider"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; -import { CircleX, Globe, Info } from "lucide-react"; import { useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -174,7 +174,7 @@ export function FormSchedulingRegions({ /> {!periodicity.includes(watchPeriodicity) ? ( - + The periodicity you are selecting is not allowed for your plan. setOpenDialog(true)}> Upgrade your plan diff --git a/apps/dashboard/src/components/forms/monitor/form-tags.tsx b/apps/dashboard/src/components/forms/monitor/form-tags.tsx index b44901feb..491bf8828 100644 --- a/apps/dashboard/src/components/forms/monitor/form-tags.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-tags.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, Expand } from "@openstatus/icons"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { @@ -26,7 +27,6 @@ import { } from "@openstatus/ui/components/ui/popover"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { Check, ChevronsUpDown } from "lucide-react"; import { useEffect, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -174,7 +174,7 @@ export function FormTags({
)} - + diff --git a/apps/dashboard/src/components/forms/monitor/form-visibility.tsx b/apps/dashboard/src/components/forms/monitor/form-visibility.tsx index d90dc0a4f..31e7bb6e1 100644 --- a/apps/dashboard/src/components/forms/monitor/form-visibility.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-visibility.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Lock } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, @@ -11,7 +12,6 @@ import { FormLabel, } from "@openstatus/ui/components/ui/form"; import { Switch } from "@openstatus/ui/components/ui/switch"; -import { Lock } from "lucide-react"; import NextLink from "next/link"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/dashboard/src/components/forms/notifications/form-webhook.tsx b/apps/dashboard/src/components/forms/notifications/form-webhook.tsx index 30b7c1ddc..9d838cc49 100644 --- a/apps/dashboard/src/components/forms/notifications/form-webhook.tsx +++ b/apps/dashboard/src/components/forms/notifications/form-webhook.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Add, Close } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { FormControl, @@ -15,7 +16,6 @@ import { Input } from "@openstatus/ui/components/ui/input"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; -import { Plus, X } from "lucide-react"; import React, { useTransition } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -226,7 +226,7 @@ export function FormWebhook({ aria-label="Remove header" onClick={() => remove(index)} > - + ))} @@ -237,7 +237,7 @@ export function FormWebhook({ type="button" onClick={() => append({ key: "", value: "" })} > - + Add Header diff --git a/apps/dashboard/src/components/forms/onboarding/create-monitor.tsx b/apps/dashboard/src/components/forms/onboarding/create-monitor.tsx index 10866dd72..867765b09 100644 --- a/apps/dashboard/src/components/forms/onboarding/create-monitor.tsx +++ b/apps/dashboard/src/components/forms/onboarding/create-monitor.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Close } from "@openstatus/icons"; import { Form, FormControl, @@ -17,7 +18,6 @@ import { InputGroupInput, } from "@openstatus/ui/components/ui/input-group"; import { isTRPCClientError } from "@trpc/client"; -import { X } from "lucide-react"; import { useRef, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -99,7 +99,7 @@ export function CreateMonitorForm({ inputRef.current?.focus(); }} > - + ) : null} diff --git a/apps/dashboard/src/components/forms/onboarding/create-page.tsx b/apps/dashboard/src/components/forms/onboarding/create-page.tsx index 6263e84cd..f8228745c 100644 --- a/apps/dashboard/src/components/forms/onboarding/create-page.tsx +++ b/apps/dashboard/src/components/forms/onboarding/create-page.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { System, Dark, Add, Light, Close } from "@openstatus/icons"; import { THEME_KEYS, type ThemeKey } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { @@ -28,7 +29,6 @@ import { import { useDebounce } from "@openstatus/ui/hooks/use-debounce"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; -import { Laptop, Moon, Plus, Sun, X } from "lucide-react"; import { useTheme } from "next-themes"; import { useEffect, useTransition } from "react"; import { useFieldArray, useForm } from "react-hook-form"; @@ -50,9 +50,9 @@ const SLUG_PATTERN_MESSAGE = "Only use digits (0-9), hyphen (-) or lowercase characters (a-z)."; const FORCE_THEME_OPTIONS = [ - { value: "light", label: "Light", icon: Sun }, - { value: "dark", label: "Dark", icon: Moon }, - { value: "system", label: "System", icon: Laptop }, + { value: "light", label: "Light", icon: Light }, + { value: "dark", label: "Dark", icon: Dark }, + { value: "system", label: "System", icon: System }, ] as const; const schema = z.object({ @@ -268,7 +268,7 @@ export function CreatePageForm({ className="shrink-0" onClick={() => remove(index)} > - + )} @@ -285,7 +285,7 @@ export function CreatePageForm({ size="sm" onClick={() => append({ name: "" })} > - + Add another )} diff --git a/apps/dashboard/src/components/forms/onboarding/question.tsx b/apps/dashboard/src/components/forms/onboarding/question.tsx index b24117ad2..28d752c67 100644 --- a/apps/dashboard/src/components/forms/onboarding/question.tsx +++ b/apps/dashboard/src/components/forms/onboarding/question.tsx @@ -1,13 +1,21 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { + Check, + More, + Blog, + Search as SearchIcon, + AI, + Team, +} from "@openstatus/icons"; import { BlueskyIcon, GitHubIcon, LinkedInIcon, XIcon, YouTubeIcon, -} from "@openstatus/icons"; +} from "@openstatus/icons/brand"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { @@ -24,14 +32,6 @@ import { RadioGroupItem, } from "@openstatus/ui/components/ui/radio-group"; import { cn } from "@openstatus/ui/lib/utils"; -import { - Check, - MoreHorizontal, - Newspaper, - Search, - Sparkles, - Users, -} from "lucide-react"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -40,16 +40,16 @@ import { z } from "zod"; import { Note } from "@/components/common/note"; const sources = [ - { id: "search-engine", title: "Search Engine", icon: Search }, + { id: "search-engine", title: "Search Engine", icon: SearchIcon }, { id: "github", title: "GitHub", icon: GitHubIcon }, { id: "linkedin", title: "LinkedIn", icon: LinkedInIcon }, { id: "bluesky", title: "Bluesky", icon: BlueskyIcon }, { id: "twitter", title: "X / Twitter", icon: XIcon }, - { id: "ai-chat", title: "AI Chat", icon: Sparkles }, - { id: "recommendation", title: "Friend / Colleague", icon: Users }, + { id: "ai-chat", title: "AI Chat", icon: AI }, + { id: "recommendation", title: "Friend / Colleague", icon: Team }, { id: "youtube", title: "YouTube", icon: YouTubeIcon }, - { id: "blog", title: "Blog / Article", icon: Newspaper }, - { id: "other", title: "Other", icon: MoreHorizontal }, + { id: "blog", title: "Blog / Article", icon: Blog }, + { id: "other", title: "Other", icon: More }, ] as const; const schema = z.object({ diff --git a/apps/dashboard/src/components/forms/private-location/form.tsx b/apps/dashboard/src/components/forms/private-location/form.tsx index 006206861..2ea670a65 100644 --- a/apps/dashboard/src/components/forms/private-location/form.tsx +++ b/apps/dashboard/src/components/forms/private-location/form.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Add, Check, Close, Copy } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, @@ -21,7 +22,6 @@ import { import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; -import { Check, Copy, Plus, X } from "lucide-react"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -252,7 +252,7 @@ export function FormPrivateLocation({ ); }} > - + ))} @@ -266,7 +266,7 @@ export function FormPrivateLocation({ field.onChange([...field.value, { key: "", value: "" }]); }} > - + Add Metadata diff --git a/apps/dashboard/src/components/forms/settings/form-api-key.tsx b/apps/dashboard/src/components/forms/settings/form-api-key.tsx index 979e18e6c..694c14120 100644 --- a/apps/dashboard/src/components/forms/settings/form-api-key.tsx +++ b/apps/dashboard/src/components/forms/settings/form-api-key.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Calendar as CalendarIcon, Check, Copy } from "@openstatus/icons"; import { AlertDialog, AlertDialogContent, @@ -44,7 +45,6 @@ import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { format, parse } from "date-fns"; -import { CalendarIcon, Check, Copy } from "lucide-react"; import { useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; diff --git a/apps/dashboard/src/components/forms/settings/form-members.tsx b/apps/dashboard/src/components/forms/settings/form-members.tsx index f33041d9d..8b6cd7196 100644 --- a/apps/dashboard/src/components/forms/settings/form-members.tsx +++ b/apps/dashboard/src/components/forms/settings/form-members.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Lock } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, @@ -17,7 +18,6 @@ import { TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { Tabs } from "@openstatus/ui/components/ui/tabs"; -import { Lock } from "lucide-react"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; diff --git a/apps/dashboard/src/components/forms/settings/form-slug.tsx b/apps/dashboard/src/components/forms/settings/form-slug.tsx index b49fb4966..1911115eb 100644 --- a/apps/dashboard/src/components/forms/settings/form-slug.tsx +++ b/apps/dashboard/src/components/forms/settings/form-slug.tsx @@ -1,8 +1,8 @@ "use client"; +import { Check, Copy } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; -import { Check, Copy } from "lucide-react"; import { z } from "zod"; import { diff --git a/apps/dashboard/src/components/forms/status-page/form-appearance.tsx b/apps/dashboard/src/components/forms/status-page/form-appearance.tsx index e7a294209..88b9014f9 100644 --- a/apps/dashboard/src/components/forms/status-page/form-appearance.tsx +++ b/apps/dashboard/src/components/forms/status-page/form-appearance.tsx @@ -1,4 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowUpRight, System, Dark, Light } from "@openstatus/icons"; import type { ThemeKey } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { @@ -18,7 +19,6 @@ import { SelectValue, } from "@openstatus/ui/components/ui/select"; import { isTRPCClientError } from "@trpc/client"; -import { ArrowUpRight, Laptop, Moon, Sun } from "lucide-react"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -112,19 +112,19 @@ export function FormAppearance({
- + Light
- + Dark
- + System
diff --git a/apps/dashboard/src/components/forms/status-page/form-configuration.tsx b/apps/dashboard/src/components/forms/status-page/form-configuration.tsx index a16a43993..9697a9fc2 100644 --- a/apps/dashboard/src/components/forms/status-page/form-configuration.tsx +++ b/apps/dashboard/src/components/forms/status-page/form-configuration.tsx @@ -1,4 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowUpRight } from "@openstatus/icons"; import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { @@ -27,7 +28,6 @@ import { } from "@openstatus/ui/components/ui/select"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; -import { ArrowUpRight } from "lucide-react"; import { parseAsStringLiteral, useQueryStates } from "nuqs"; import { useEffect, useState, useTransition } from "react"; import { type UseFormReturn, useForm } from "react-hook-form"; diff --git a/apps/dashboard/src/components/forms/status-page/form-custom-domain.tsx b/apps/dashboard/src/components/forms/status-page/form-custom-domain.tsx index df6864c1a..d3fc9e24b 100644 --- a/apps/dashboard/src/components/forms/status-page/form-custom-domain.tsx +++ b/apps/dashboard/src/components/forms/status-page/form-custom-domain.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Lock } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, @@ -15,7 +16,6 @@ import { } from "@openstatus/ui/components/ui/input-group"; import { Label } from "@openstatus/ui/components/ui/label"; import { isTRPCClientError } from "@trpc/client"; -import { Lock } from "lucide-react"; import type React from "react"; import { useEffect, useTransition } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/dashboard/src/components/forms/status-page/form-custom-theme.tsx b/apps/dashboard/src/components/forms/status-page/form-custom-theme.tsx index dab6f3ce8..c0b1cd694 100644 --- a/apps/dashboard/src/components/forms/status-page/form-custom-theme.tsx +++ b/apps/dashboard/src/components/forms/status-page/form-custom-theme.tsx @@ -1,4 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { Collapse, Expand, Lock } from "@openstatus/icons"; import { type CustomTheme, formatThemeVars, @@ -25,7 +26,6 @@ import { import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; -import { ChevronsDownUp, ChevronsUpDown, Lock } from "lucide-react"; import { useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -140,9 +140,9 @@ export function FormCustomTheme({ onClick={() => setExtended((v) => !v)} > {extended ? ( - + ) : ( - + )} {extended ? "Collapse" : "Extend"} diff --git a/apps/dashboard/src/components/forms/status-page/form-locale.tsx b/apps/dashboard/src/components/forms/status-page/form-locale.tsx index 05067c8e4..86a3c414f 100644 --- a/apps/dashboard/src/components/forms/status-page/form-locale.tsx +++ b/apps/dashboard/src/components/forms/status-page/form-locale.tsx @@ -1,4 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { Lock } from "@openstatus/icons"; import { type Locale, localeDetails, locales } from "@openstatus/locales"; import { Button } from "@openstatus/ui/components/ui/button"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; @@ -19,7 +20,6 @@ import { SelectValue, } from "@openstatus/ui/components/ui/select"; import { isTRPCClientError } from "@trpc/client"; -import { Lock } from "lucide-react"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; diff --git a/apps/dashboard/src/components/forms/status-page/form-monitors.tsx b/apps/dashboard/src/components/forms/status-page/form-monitors.tsx index a5ce18a71..4108b4c81 100644 --- a/apps/dashboard/src/components/forms/status-page/form-monitors.tsx +++ b/apps/dashboard/src/components/forms/status-page/form-monitors.tsx @@ -2,6 +2,7 @@ import type { UniqueIdentifier } from "@dnd-kit/core"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, Expand, DragHandle, Add, Delete } from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, @@ -42,13 +43,6 @@ import { } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; -import { - Check, - ChevronsUpDown, - GripVertical, - Plus, - Trash2, -} from "lucide-react"; import { useCallback, useEffect, useState, useTransition } from "react"; import { type UseFormReturn, useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -371,7 +365,7 @@ export function FormMonitors({ {field.value.length > 0 ? `${field.value.length} monitors selected` : "Select monitors"} - + @@ -447,7 +441,7 @@ export function FormMonitors({ className="w-full" disabled={legacy} > - + Add Group @@ -466,7 +460,7 @@ export function FormMonitors({ className="w-full" onClick={handleAddGroup} > - + Add Group )} @@ -549,7 +543,7 @@ function MonitorRow({ monitor, className, ...props }: MonitorRowProps) {
-
diff --git a/apps/dashboard/src/components/forms/status-report-update/form.tsx b/apps/dashboard/src/components/forms/status-report-update/form.tsx index eefef7556..3c35a0c8a 100644 --- a/apps/dashboard/src/components/forms/status-report-update/form.tsx +++ b/apps/dashboard/src/components/forms/status-report-update/form.tsx @@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { statusReportStatus } from "@openstatus/db/src/schema"; import { pageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; +import { Calendar as CalendarIcon, Clock } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; @@ -38,7 +39,6 @@ import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { format } from "date-fns"; -import { CalendarIcon, ClockIcon } from "lucide-react"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -283,7 +283,7 @@ export function FormStatusReportUpdate({ }} />
-
diff --git a/apps/dashboard/src/components/forms/status-report/form.tsx b/apps/dashboard/src/components/forms/status-report/form.tsx index a0f89469a..76828518e 100644 --- a/apps/dashboard/src/components/forms/status-report/form.tsx +++ b/apps/dashboard/src/components/forms/status-report/form.tsx @@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { statusReportStatus } from "@openstatus/db/src/schema"; import { pageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; +import { Calendar as CalendarIcon, Clock } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; @@ -38,7 +39,6 @@ import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { format } from "date-fns"; -import { CalendarIcon, ClockIcon } from "lucide-react"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -309,7 +309,7 @@ export function FormStatusReport({ }} />
-
diff --git a/apps/dashboard/src/components/forms/step-card.tsx b/apps/dashboard/src/components/forms/step-card.tsx index 4cbcce0d1..d892b26db 100644 --- a/apps/dashboard/src/components/forms/step-card.tsx +++ b/apps/dashboard/src/components/forms/step-card.tsx @@ -1,3 +1,4 @@ +import { Check } from "@openstatus/icons"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Card, @@ -7,7 +8,6 @@ import { } from "@openstatus/ui/components/ui/card"; import { cn } from "@openstatus/ui/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; -import { Check } from "lucide-react"; export type StepCardVariant = NonNullable< VariantProps["variant"] diff --git a/apps/dashboard/src/components/forms/subscriber/form.tsx b/apps/dashboard/src/components/forms/subscriber/form.tsx index 4f923987f..ab49da242 100644 --- a/apps/dashboard/src/components/forms/subscriber/form.tsx +++ b/apps/dashboard/src/components/forms/subscriber/form.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Add, Close } from "@openstatus/icons"; import { detectWebhookFlavor } from "@openstatus/subscriptions/client"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; @@ -17,7 +18,6 @@ import { Input } from "@openstatus/ui/components/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; -import { Plus, X } from "lucide-react"; import React, { useTransition } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -346,7 +346,7 @@ export function FormSubscriber({ aria-label="Remove header" onClick={() => remove(idx)} > - + ))} @@ -357,7 +357,7 @@ export function FormSubscriber({ type="button" onClick={() => append({ key: "", value: "" })} > - + Add Header diff --git a/apps/dashboard/src/components/layout/auth-layout.tsx b/apps/dashboard/src/components/layout/auth-layout.tsx index 9311a23c4..5b3088235 100644 --- a/apps/dashboard/src/components/layout/auth-layout.tsx +++ b/apps/dashboard/src/components/layout/auth-layout.tsx @@ -1,5 +1,5 @@ +import { BookOpen } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; -import { BookOpen } from "lucide-react"; import { SHELL_CONTENT_COLUMN, diff --git a/apps/dashboard/src/components/layout/onboarding-layout.tsx b/apps/dashboard/src/components/layout/onboarding-layout.tsx index f37f4b5f4..0e68ba106 100644 --- a/apps/dashboard/src/components/layout/onboarding-layout.tsx +++ b/apps/dashboard/src/components/layout/onboarding-layout.tsx @@ -1,12 +1,12 @@ "use client"; +import { ArrowUpRight, Check, Copy } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { ButtonGroup, ButtonGroupText, } from "@openstatus/ui/components/ui/button-group"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; -import { ArrowUpRight, Check, Copy } from "lucide-react"; import { SHELL_CONTENT_COLUMN, diff --git a/apps/dashboard/src/components/metric/metric-card.tsx b/apps/dashboard/src/components/metric/metric-card.tsx index 515890964..5403b6232 100644 --- a/apps/dashboard/src/components/metric/metric-card.tsx +++ b/apps/dashboard/src/components/metric/metric-card.tsx @@ -1,9 +1,9 @@ +import { ChevronDown, ChevronUp } from "@openstatus/icons"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; import type { VariantProps } from "class-variance-authority"; import { cva } from "class-variance-authority"; -import { ChevronDown, ChevronUp } from "lucide-react"; import Link from "next/link"; import type React from "react"; diff --git a/apps/dashboard/src/components/nav/app-sidebar.tsx b/apps/dashboard/src/components/nav/app-sidebar.tsx index d003704a3..2ee4863f1 100644 --- a/apps/dashboard/src/components/nav/app-sidebar.tsx +++ b/apps/dashboard/src/components/nav/app-sidebar.tsx @@ -1,12 +1,13 @@ "use client"; +import { SidebarLeft } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail, - SidebarTrigger, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import { @@ -102,7 +103,17 @@ export function AppSidebarTrigger() { - +

diff --git a/apps/dashboard/src/components/nav/nav-banner-checklist.tsx b/apps/dashboard/src/components/nav/nav-banner-checklist.tsx index 18fe04ace..6f7820d39 100644 --- a/apps/dashboard/src/components/nav/nav-banner-checklist.tsx +++ b/apps/dashboard/src/components/nav/nav-banner-checklist.tsx @@ -1,5 +1,6 @@ "use client"; +import { ArrowUpRight, Success, Pending, Close } from "@openstatus/icons"; import { SidebarGroup, SidebarGroupLabel, @@ -8,7 +9,6 @@ import { SidebarMenuItem, } from "@openstatus/ui/components/ui/sidebar"; import { useQuery } from "@tanstack/react-query"; -import { ArrowUpRight, CircleCheck, CircleDashed, X } from "lucide-react"; import { Link } from "@/components/common/link"; import { useTRPC } from "@/lib/trpc/client"; @@ -77,7 +77,7 @@ export function NavBannerChecklist({ className="relative top-0 right-0" onClick={handleClose} > - + @@ -88,12 +88,12 @@ export function NavBannerChecklist({ > {item.checked ? ( <> - + {item.title} ) : ( <> - diff --git a/apps/dashboard/src/components/nav/nav-banner-upgrade.tsx b/apps/dashboard/src/components/nav/nav-banner-upgrade.tsx index b729b121b..6142e3e92 100644 --- a/apps/dashboard/src/components/nav/nav-banner-upgrade.tsx +++ b/apps/dashboard/src/components/nav/nav-banner-upgrade.tsx @@ -1,5 +1,6 @@ "use client"; +import { Launch, Close } from "@openstatus/icons"; import { SidebarGroup, SidebarGroupLabel, @@ -9,7 +10,6 @@ import { SidebarMenuItem, } from "@openstatus/ui/components/ui/sidebar"; import { useQuery } from "@tanstack/react-query"; -import { Rocket, X } from "lucide-react"; import { useState } from "react"; import { useTRPC } from "@/lib/trpc/client"; @@ -31,12 +31,12 @@ export function NavBannerUpgrade({ handleClose }: { handleClose: () => void }) { onClick={handleClose} className="relative top-0 right-0" > - + - + Unlock custom domains, teams, 1 min. checks, subscriptions and more. diff --git a/apps/dashboard/src/components/nav/nav-breadcrumb.tsx b/apps/dashboard/src/components/nav/nav-breadcrumb.tsx index 33b544d66..faddde7bf 100644 --- a/apps/dashboard/src/components/nav/nav-breadcrumb.tsx +++ b/apps/dashboard/src/components/nav/nav-breadcrumb.tsx @@ -1,5 +1,6 @@ "use client"; +import type { IconType } from "@openstatus/icons"; import { Breadcrumb, BreadcrumbItem, @@ -8,7 +9,6 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@openstatus/ui/components/ui/breadcrumb"; -import type { LucideIcon } from "lucide-react"; import Link from "next/link"; import { Fragment } from "react"; @@ -18,12 +18,12 @@ interface NavBreadcrumbProps { type: "link"; label: string; href: string; - icon?: LucideIcon; + icon?: IconType; } | { type: "page"; label: string; - icon?: LucideIcon; + icon?: IconType; } )[]; } diff --git a/apps/dashboard/src/components/nav/nav-feedback.tsx b/apps/dashboard/src/components/nav/nav-feedback.tsx index 1ad0cfdd1..8f97d9032 100644 --- a/apps/dashboard/src/components/nav/nav-feedback.tsx +++ b/apps/dashboard/src/components/nav/nav-feedback.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { AudioLines, Inbox, Loading, Mic } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, @@ -18,7 +19,6 @@ import { import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { useMutation } from "@tanstack/react-query"; -import { AudioLines, Inbox, LoaderCircle, Mic } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -227,7 +227,7 @@ export function NavFeedback() { disabled={feedbackMutation.isPending} > {feedbackMutation.isPending ? ( - + ) : ( <> Send diff --git a/apps/dashboard/src/components/nav/nav-help.tsx b/apps/dashboard/src/components/nav/nav-help.tsx index ad3b29ad6..82d1b5352 100644 --- a/apps/dashboard/src/components/nav/nav-help.tsx +++ b/apps/dashboard/src/components/nav/nav-help.tsx @@ -1,5 +1,6 @@ "use client"; +import { Help } from "@openstatus/icons"; import { DropdownMenu, DropdownMenuContent, @@ -15,7 +16,6 @@ import { SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; -import { HelpCircle } from "lucide-react"; import Link from "next/link"; import { FormDialogSupportContact } from "@/components/forms/support-contact/dialog"; @@ -34,7 +34,7 @@ export function NavHelp() { className="font-commit-mono tracking-tight" tooltip="Get Help" > - + Get Help diff --git a/apps/dashboard/src/components/nav/nav-logout.tsx b/apps/dashboard/src/components/nav/nav-logout.tsx index c37a57682..740ae37c6 100644 --- a/apps/dashboard/src/components/nav/nav-logout.tsx +++ b/apps/dashboard/src/components/nav/nav-logout.tsx @@ -1,7 +1,7 @@ "use client"; +import { Logout } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; -import { LogOut } from "lucide-react"; import { signOut } from "next-auth/react"; export function NavLogout({ @@ -18,7 +18,7 @@ export function NavLogout({ }} {...props} > - + Log out ); diff --git a/apps/dashboard/src/components/nav/nav-main.tsx b/apps/dashboard/src/components/nav/nav-main.tsx index 1055de01b..f0a8ea4db 100644 --- a/apps/dashboard/src/components/nav/nav-main.tsx +++ b/apps/dashboard/src/components/nav/nav-main.tsx @@ -1,5 +1,6 @@ "use client"; +import { ChevronRight, type IconType } from "@openstatus/icons"; import { Collapsible, CollapsibleContent, @@ -16,7 +17,6 @@ import { SidebarMenuSubItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; -import { ChevronRight, type LucideIcon } from "lucide-react"; import Link from "next/link"; export function NavMain({ items, @@ -24,7 +24,7 @@ export function NavMain({ items: { title: string; url: string; - icon?: LucideIcon; + icon?: IconType; isActive?: boolean; items?: { title: string; diff --git a/apps/dashboard/src/components/nav/nav-monitors.tsx b/apps/dashboard/src/components/nav/nav-monitors.tsx index 74c6d4011..a8618ba1e 100644 --- a/apps/dashboard/src/components/nav/nav-monitors.tsx +++ b/apps/dashboard/src/components/nav/nav-monitors.tsx @@ -1,5 +1,6 @@ "use client"; +import { More, Add } from "@openstatus/icons"; import { SidebarGroup, SidebarGroupLabel, @@ -20,7 +21,6 @@ import { import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; -import { MoreHorizontal, Plus } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useState } from "react"; @@ -109,7 +109,7 @@ export function NavMonitors() { setOpenMobile(false); }} > - + Create Monitor @@ -201,7 +201,7 @@ export function NavMonitors() { align={isMobile ? "end" : "start"} > - + More diff --git a/apps/dashboard/src/components/nav/nav-overview.tsx b/apps/dashboard/src/components/nav/nav-overview.tsx index 9cdfead03..c2c980471 100644 --- a/apps/dashboard/src/components/nav/nav-overview.tsx +++ b/apps/dashboard/src/components/nav/nav-overview.tsx @@ -1,5 +1,6 @@ "use client"; +import type { IconType } from "@openstatus/icons"; import { SidebarGroup, SidebarGroupLabel, @@ -8,7 +9,6 @@ import { SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; -import type { LucideIcon } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -24,7 +24,7 @@ export function NavOverview({ items: { name: string; url: string; - icon: LucideIcon; + icon: IconType; }[]; }) { const pathname = usePathname(); diff --git a/apps/dashboard/src/components/nav/nav-search.tsx b/apps/dashboard/src/components/nav/nav-search.tsx index df55a7d4e..1cbb3c2e5 100644 --- a/apps/dashboard/src/components/nav/nav-search.tsx +++ b/apps/dashboard/src/components/nav/nav-search.tsx @@ -1,12 +1,12 @@ "use client"; +import { Search } from "@openstatus/icons"; import { Kbd } from "@openstatus/ui/components/ui/kbd"; import { SidebarMenuButton, SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; -import { Search } from "lucide-react"; import { useCommandMenu } from "@/components/command-menu/provider"; diff --git a/apps/dashboard/src/components/nav/nav-status-pages.tsx b/apps/dashboard/src/components/nav/nav-status-pages.tsx index b7ed58345..33508c884 100644 --- a/apps/dashboard/src/components/nav/nav-status-pages.tsx +++ b/apps/dashboard/src/components/nav/nav-status-pages.tsx @@ -1,5 +1,6 @@ "use client"; +import { More, Add } from "@openstatus/icons"; import { SidebarGroup, SidebarGroupLabel, @@ -19,7 +20,6 @@ import { } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { MoreHorizontal, Plus } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useState } from "react"; @@ -93,7 +93,7 @@ export function NavStatusPages() { setOpenMobile(false); }} > - + Create Status Page @@ -171,7 +171,7 @@ export function NavStatusPages() { align={isMobile ? "end" : "start"} > - + More diff --git a/apps/dashboard/src/components/nav/nav-tabs.tsx b/apps/dashboard/src/components/nav/nav-tabs.tsx index 019d53463..3c9adfc9f 100644 --- a/apps/dashboard/src/components/nav/nav-tabs.tsx +++ b/apps/dashboard/src/components/nav/nav-tabs.tsx @@ -1,6 +1,6 @@ "use client"; -import type { LucideIcon } from "lucide-react"; +import type { IconType } from "@openstatus/icons"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -10,7 +10,7 @@ interface NavTabsProps { items: { value: string; label: string; - icon: LucideIcon; + icon: IconType; href: string; }[]; } diff --git a/apps/dashboard/src/components/nav/nav-user.tsx b/apps/dashboard/src/components/nav/nav-user.tsx index b818653b5..3e49bca9b 100644 --- a/apps/dashboard/src/components/nav/nav-user.tsx +++ b/apps/dashboard/src/components/nav/nav-user.tsx @@ -1,5 +1,15 @@ "use client"; +import { + Expand, + Billing, + System, + Logout, + Dark, + AI, + Light, + Account, +} from "@openstatus/icons"; import { Avatar, AvatarFallback, @@ -25,16 +35,6 @@ import { useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import { useQuery } from "@tanstack/react-query"; -import { - ChevronsUpDown, - CreditCard, - Laptop, - LogOut, - Moon, - Sparkles, - Sun, - User, -} from "lucide-react"; import { signOut } from "next-auth/react"; import { useTheme } from "next-themes"; import Link from "next/link"; @@ -78,7 +78,7 @@ export function NavUser() { {user?.email} - + setOpenMobile(false)} className="font-commit-mono tracking-tight" > - + Upgrade Workspace @@ -128,31 +128,31 @@ export function NavUser() { href="/settings/account" onClick={() => setOpenMobile(false)} > - + Account {theme === "dark" ? ( - + ) : theme === "light" ? ( - + ) : ( - + )} Theme setTheme("light")}> - Light + Light setTheme("dark")}> - Dark + Dark setTheme("system")}> - System + System @@ -162,7 +162,7 @@ export function NavUser() { href="/settings/billing" onClick={() => setOpenMobile(false)} > - + Billing @@ -172,7 +172,7 @@ export function NavUser() { onClick={() => signOut()} className="font-commit-mono tracking-tight" > - + Log out diff --git a/apps/dashboard/src/components/nav/sidebar-metadata.tsx b/apps/dashboard/src/components/nav/sidebar-metadata.tsx index 15386c16a..4433cad89 100644 --- a/apps/dashboard/src/components/nav/sidebar-metadata.tsx +++ b/apps/dashboard/src/components/nav/sidebar-metadata.tsx @@ -1,3 +1,5 @@ +import type { IconType } from "@openstatus/icons"; +import { ChevronRight, More } from "@openstatus/icons"; import { Collapsible, CollapsibleContent, @@ -28,8 +30,6 @@ import { import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; import { TooltipProvider } from "@radix-ui/react-tooltip"; -import type { LucideIcon } from "lucide-react"; -import { ChevronRight, MoreHorizontal } from "lucide-react"; import Link from "next/link"; import * as React from "react"; @@ -48,7 +48,7 @@ export type SidebarMetadataListItem = { /** Trailing meta on the row (e.g. "2m ago"). */ meta?: React.ReactNode; /** Optional small leading icon. */ - icon?: LucideIcon; + icon?: IconType; /** Click target — renders as `next/link` when set. */ href?: string; /** Marks this row as the current selection. */ @@ -57,7 +57,7 @@ export type SidebarMetadataListItem = { actions?: { id: string; label: string; - icon: LucideIcon; + icon: IconType; variant: "default" | "destructive"; onClick?: () => Promise | void; }[]; @@ -231,7 +231,7 @@ function SidebarMetadataList({ // so override the variant directly and re-anchor at 50%. className="bg-sidebar-accent before:from-sidebar-accent -translate-y-1/2 peer-data-[size=default]/menu-button:top-1/2 before:absolute before:inset-y-0 before:right-full before:-left-6 before:bg-gradient-to-l before:to-transparent focus-visible:ring-inset" > - + More diff --git a/apps/dashboard/src/components/nav/sidebar-right.tsx b/apps/dashboard/src/components/nav/sidebar-right.tsx index 5a0beafc5..bdf2b296f 100644 --- a/apps/dashboard/src/components/nav/sidebar-right.tsx +++ b/apps/dashboard/src/components/nav/sidebar-right.tsx @@ -1,5 +1,6 @@ "use client"; +import { Sidebar as SidebarIcon } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Sidebar, @@ -19,7 +20,6 @@ import { TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; -import { PanelRight } from "lucide-react"; import * as React from "react"; import { Kbd } from "../common/kbd"; @@ -123,7 +123,7 @@ export function SidebarTrigger({ }} {...props} > - + Toggle Sidebar ); diff --git a/apps/dashboard/src/components/nav/workspace-switcher.tsx b/apps/dashboard/src/components/nav/workspace-switcher.tsx index 45205dafd..8c6f1abdd 100644 --- a/apps/dashboard/src/components/nav/workspace-switcher.tsx +++ b/apps/dashboard/src/components/nav/workspace-switcher.tsx @@ -1,5 +1,6 @@ "use client"; +import { Expand, Add } from "@openstatus/icons"; import { DropdownMenu, DropdownMenuContent, @@ -16,7 +17,6 @@ import { } from "@openstatus/ui/components/ui/sidebar"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; -import { ChevronsUpDown, Plus } from "lucide-react"; import { Link } from "@/components/common/link"; import { useTRPC } from "@/lib/trpc/client"; @@ -68,7 +68,7 @@ export function WorkspaceSwitcher({ className, side }: WorkspaceSwitcherProps) { - + - +

Add team member
diff --git a/apps/dashboard/src/components/onboarding/checks-table.tsx b/apps/dashboard/src/components/onboarding/checks-table.tsx index ea19ebebf..9effdba8f 100644 --- a/apps/dashboard/src/components/onboarding/checks-table.tsx +++ b/apps/dashboard/src/components/onboarding/checks-table.tsx @@ -1,6 +1,7 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Warning, Refresh } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { TableBody, @@ -16,7 +17,6 @@ import { getCoreRowModel, useReactTable, } from "@tanstack/react-table"; -import { AlertTriangle, RefreshCw } from "lucide-react"; import { useState } from "react"; import { getColumns } from "@/components/data-table/response-logs/columns"; @@ -73,7 +73,7 @@ export function OnboardingChecksTable({ )} {...props} > - +

Couldn't reach {url ? {url} : "your URL"} @@ -84,7 +84,7 @@ export function OnboardingChecksTable({

diff --git a/apps/dashboard/src/components/onboarding/feature-badges.tsx b/apps/dashboard/src/components/onboarding/feature-badges.tsx index c880a2d42..a2ff6f201 100644 --- a/apps/dashboard/src/components/onboarding/feature-badges.tsx +++ b/apps/dashboard/src/components/onboarding/feature-badges.tsx @@ -1,23 +1,23 @@ "use client"; -import { ModelContextProtocolIcon, SlackIcon } from "@openstatus/icons"; import { ArrowUpRight, - Bell, + Notification, BookOpen, - Braces, + Api, FileText, Globe, - Palette, - ScanEye, - ScrollText, - ServerCog, + Theme, + AuditLog, + Changelog, + PrivateLocation, Shield, Terminal, - UserPlus, - Users, - Zap, -} from "lucide-react"; + Invite, + Team, + Speed, +} from "@openstatus/icons"; +import { ModelContextProtocolIcon, SlackIcon } from "@openstatus/icons/brand"; import Link from "next/link"; import { QuestionForm } from "@/components/forms/onboarding/question"; @@ -42,7 +42,7 @@ const FEATURE_CATEGORIES: { label: string; items: FeatureLinkItem[] }[] = [ { name: "Changelog", href: `${WEB}/changelog`, - icon: ScrollText, + icon: Changelog, external: true, }, { @@ -59,19 +59,19 @@ const FEATURE_CATEGORIES: { label: string; items: FeatureLinkItem[] }[] = [ { name: "OpenTelemetry", href: `${DOCS}/guides/how-to-export-metrics-to-otlp-endpoint`, - icon: Zap, + icon: Speed, external: true, }, { name: "Private locations", href: `${DOCS}/tutorial/how-to-create-private-location`, - icon: ServerCog, + icon: PrivateLocation, external: true, }, { name: "Notifications", href: `${DOCS}/reference/notification`, - icon: Bell, + icon: Notification, external: true, }, ], @@ -88,7 +88,7 @@ const FEATURE_CATEGORIES: { label: string; items: FeatureLinkItem[] }[] = [ { name: "Themes Store", href: "https://themes.openstatus.dev", - icon: Palette, + icon: Theme, external: true, }, { @@ -106,7 +106,7 @@ const FEATURE_CATEGORIES: { label: string; items: FeatureLinkItem[] }[] = [ { name: "Subscribers", href: `${DOCS}/reference/subscriber`, - icon: Users, + icon: Team, external: true, }, ], @@ -117,7 +117,7 @@ const FEATURE_CATEGORIES: { label: string; items: FeatureLinkItem[] }[] = [ { name: "REST API", href: `${DOCS}/api-reference`, - icon: Braces, + icon: Api, external: true, }, { @@ -137,8 +137,8 @@ const FEATURE_CATEGORIES: { label: string; items: FeatureLinkItem[] }[] = [ { label: "Workspace", items: [ - { name: "Audit logs", href: "/settings/audit-logs", icon: ScanEye }, - { name: "Team & invites", href: "/settings/general", icon: UserPlus }, + { name: "Audit logs", href: "/settings/audit-logs", icon: AuditLog }, + { name: "Team & invites", href: "/settings/general", icon: Invite }, ], }, ]; diff --git a/apps/dashboard/src/components/theme-toggle.tsx b/apps/dashboard/src/components/theme-toggle.tsx index 238cfd8f6..138e5f035 100644 --- a/apps/dashboard/src/components/theme-toggle.tsx +++ b/apps/dashboard/src/components/theme-toggle.tsx @@ -1,5 +1,6 @@ "use client"; +import { System, Dark, Light } from "@openstatus/icons"; import { Select, SelectContent, @@ -8,7 +9,6 @@ import { SelectValue, } from "@openstatus/ui/components/ui/select"; import { cn } from "@openstatus/ui/lib/utils"; -import { Laptop, Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import type * as React from "react"; import { useState } from "react"; @@ -44,19 +44,19 @@ export function ThemeToggle({
- + Light
- + Dark
- + System
diff --git a/apps/dashboard/src/config/help.ts b/apps/dashboard/src/config/help.ts index 34102e4af..b3511fe01 100644 --- a/apps/dashboard/src/config/help.ts +++ b/apps/dashboard/src/config/help.ts @@ -1,15 +1,15 @@ -import { DiscordIcon, GitHubIcon } from "@openstatus/icons"; -import { Book, Braces, CalendarClock, LifeBuoy } from "lucide-react"; +import { Api, Docs, Schedule, Support } from "@openstatus/icons"; +import { DiscordIcon, GitHubIcon } from "@openstatus/icons/brand"; import type * as React from "react"; -// Lucide icons and the custom brand icons both accept svg props. +// Nucleo/lucide icons and the custom brand icons both accept svg props. export type HelpIcon = React.ComponentType>; // Single source of truth for the Get Help menu (sidebar nav-help) and the // command-menu "Get Help" group so their icons stay in sync. export const HELP_SUPPORT = { label: "Support", - icon: LifeBuoy as HelpIcon, + icon: Support as HelpIcon, keywords: ["feedback", "help", "contact"], }; @@ -24,19 +24,19 @@ export const HELP_LINKS = [ { label: "Docs", href: "https://www.openstatus.dev/docs", - icon: Book, + icon: Docs, keywords: ["documentation"], }, { label: "API Reference", href: "https://api.openstatus.dev/openapi", - icon: Braces, + icon: Api, keywords: ["openapi", "swagger"], }, { label: "Book a Call", href: "https://openstatus.dev/cal", - icon: CalendarClock, + icon: Schedule, keywords: ["demo", "meeting"], }, { diff --git a/apps/dashboard/src/config/nav.ts b/apps/dashboard/src/config/nav.ts index 57af077a6..721992c3e 100644 --- a/apps/dashboard/src/config/nav.ts +++ b/apps/dashboard/src/config/nav.ts @@ -1,19 +1,19 @@ -import type { LucideIcon } from "lucide-react"; import { - Activity, - Bell, - Bot, - Cog, - LayoutGrid, - MessageSquare, - PanelTop, + Agent, + Chat, + type IconType, + Monitor, + Notification, + Overview, + Settings, + StatusPage, Terminal, -} from "lucide-react"; +} from "@openstatus/icons"; export type NavItem = { label: string; href: string; - icon: LucideIcon; + icon: IconType; keywords?: string[]; }; @@ -24,37 +24,37 @@ export const NAV = { overview: { label: "Overview", href: "/overview", - icon: LayoutGrid, + icon: Overview, keywords: ["home", "dashboard"], }, monitors: { label: "Monitors", href: "/monitors", - icon: Activity, + icon: Monitor, keywords: ["checks", "uptime"], }, statusPages: { label: "Status Pages", href: "/status-pages", - icon: PanelTop, + icon: StatusPage, keywords: ["pages"], }, notifications: { label: "Notifications", href: "/notifications", - icon: Bell, + icon: Notification, keywords: ["notifiers", "alerts", "channels"], }, chat: { label: "Chat", href: "/chat", - icon: MessageSquare, + icon: Chat, keywords: ["assistant", "ai"], }, agents: { label: "Agents", href: "/agents", - icon: Bot, + icon: Agent, keywords: ["slack"], }, cli: { @@ -66,7 +66,7 @@ export const NAV = { settings: { label: "Settings", href: "/settings/general", - icon: Cog, + icon: Settings, keywords: ["workspace"], }, } satisfies Record; diff --git a/apps/dashboard/src/config/settings.ts b/apps/dashboard/src/config/settings.ts index 4309dc01a..4d591cb20 100644 --- a/apps/dashboard/src/config/settings.ts +++ b/apps/dashboard/src/config/settings.ts @@ -1,10 +1,17 @@ -import type { LucideIcon } from "lucide-react"; -import { Blocks, Cog, CreditCard, Globe, ScanEye, User } from "lucide-react"; +import { + Account, + AuditLog, + Billing, + Globe, + type IconType, + Integrations, + Settings, +} from "@openstatus/icons"; export type SettingsTab = { value: string; label: string; - icon: LucideIcon; + icon: IconType; href: string; keywords?: string[]; }; @@ -15,28 +22,28 @@ export const SETTINGS_TABS = [ { value: "general", label: "General", - icon: Cog, + icon: Settings, href: "/settings/general", keywords: ["workspace"], }, { value: "account", label: "Account", - icon: User, + icon: Account, href: "/settings/account", keywords: ["profile", "user"], }, { value: "billing", label: "Billing", - icon: CreditCard, + icon: Billing, href: "/settings/billing", keywords: ["plan", "subscription", "upgrade"], }, { value: "integrations", label: "Integrations", - icon: Blocks, + icon: Integrations, href: "/settings/integrations", }, { @@ -49,7 +56,7 @@ export const SETTINGS_TABS = [ { value: "audit-logs", label: "Audit Logs", - icon: ScanEye, + icon: AuditLog, href: "/settings/audit-logs", keywords: ["activity", "history"], }, diff --git a/apps/dashboard/src/data/audit-logs.client.ts b/apps/dashboard/src/data/audit-logs.client.ts index 99bec32a0..3c9c952a7 100644 --- a/apps/dashboard/src/data/audit-logs.client.ts +++ b/apps/dashboard/src/data/audit-logs.client.ts @@ -1,28 +1,22 @@ import type { PrivateLocation } from "@openstatus/db/src/schema"; +import { Alert, Success, Failed, Send, Incident } from "@openstatus/icons"; import { getRegionInfo } from "@openstatus/regions"; -import { - CircleAlert, - CircleCheck, - CircleMinus, - Send, - Siren, -} from "lucide-react"; import { formatMilliseconds } from "@/lib/formatter"; export const config = { "incident.created": { - icon: Siren, + icon: Incident, color: "text-destructive", title: "Incident Created", }, "incident.resolved": { - icon: CircleCheck, + icon: Success, color: "text-success", title: "Incident Resolved", }, "monitor.failed": { - icon: CircleMinus, + icon: Failed, color: "text-destructive", title: "Monitor Failed", }, @@ -32,12 +26,12 @@ export const config = { title: "Notification Sent", }, "monitor.recovered": { - icon: CircleCheck, + icon: Success, color: "text-success", title: "Monitor Recovered", }, "monitor.degraded": { - icon: CircleAlert, + icon: Alert, color: "text-warning", title: "Monitor Degraded", }, diff --git a/apps/dashboard/src/data/icons.ts b/apps/dashboard/src/data/icons.ts index 94e10d4f6..26724170a 100644 --- a/apps/dashboard/src/data/icons.ts +++ b/apps/dashboard/src/data/icons.ts @@ -1,12 +1,12 @@ "use client"; -import { Activity, AlertCircle, Search, SearchCheck } from "lucide-react"; +import { Monitor, Alert, Search, Resolved } from "@openstatus/icons"; export const status = { - resolved: SearchCheck, - investigating: AlertCircle, + resolved: Resolved, + investigating: Alert, identified: Search, - monitoring: Activity, + monitoring: Monitor, } as const; export const icons = { diff --git a/apps/dashboard/src/data/incidents.client.ts b/apps/dashboard/src/data/incidents.client.ts index b1c3ea968..c743a5cc3 100644 --- a/apps/dashboard/src/data/incidents.client.ts +++ b/apps/dashboard/src/data/incidents.client.ts @@ -1,10 +1,10 @@ -import { Bookmark, Check, Trash2 } from "lucide-react"; +import { Acknowledge, Check, Delete } from "@openstatus/icons"; export const actions = [ { id: "acknowledge", label: "Acknowledge", - icon: Bookmark, + icon: Acknowledge, variant: "default" as const, }, { @@ -16,7 +16,7 @@ export const actions = [ { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/dashboard/src/data/maintenances.client.ts b/apps/dashboard/src/data/maintenances.client.ts index d0a79e431..4ca23e6a6 100644 --- a/apps/dashboard/src/data/maintenances.client.ts +++ b/apps/dashboard/src/data/maintenances.client.ts @@ -1,16 +1,16 @@ -import { Cog, Trash2 } from "lucide-react"; +import { Settings, Delete } from "@openstatus/icons"; export const actions = [ { id: "edit", label: "Settings", - icon: Cog, + icon: Settings, variant: "default" as const, }, { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/dashboard/src/data/monitors.client.ts b/apps/dashboard/src/data/monitors.client.ts index 49bb6d479..2d3050ef0 100644 --- a/apps/dashboard/src/data/monitors.client.ts +++ b/apps/dashboard/src/data/monitors.client.ts @@ -1,12 +1,12 @@ import { - Cog, + Settings, Copy, - CopyPlus, + Duplicate, Globe, Network, Server, - Trash2, -} from "lucide-react"; + Delete, +} from "@openstatus/icons"; export const monitorTypes = [ { @@ -30,7 +30,7 @@ export const actions = [ { id: "edit", label: "Settings", - icon: Cog, + icon: Settings, variant: "default" as const, }, { @@ -42,7 +42,7 @@ export const actions = [ { id: "clone", label: "Clone", - icon: CopyPlus, + icon: Duplicate, variant: "default" as const, }, // { @@ -54,7 +54,7 @@ export const actions = [ { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/dashboard/src/data/notifications.client.ts b/apps/dashboard/src/data/notifications.client.ts index 638e28a94..74decabfa 100644 --- a/apps/dashboard/src/data/notifications.client.ts +++ b/apps/dashboard/src/data/notifications.client.ts @@ -1,3 +1,11 @@ +import { + Notification, + Settings, + Email, + Chat, + Delete, + Webhook, +} from "@openstatus/icons"; import { DiscordIcon, GoogleIcon, @@ -5,18 +13,10 @@ import { MicrosoftTeamsIcon, TelegramIcon, WhatsappIcon, -} from "@openstatus/icons"; -import { OpsGenieIcon } from "@openstatus/icons"; -import { PagerDutyIcon } from "@openstatus/icons"; -import { SlackIcon } from "@openstatus/icons"; -import { - BellIcon, - Cog, - Mail, - MessageCircle, - Trash2, - Webhook, -} from "lucide-react"; +} from "@openstatus/icons/brand"; +import { OpsGenieIcon } from "@openstatus/icons/brand"; +import { PagerDutyIcon } from "@openstatus/icons/brand"; +import { SlackIcon } from "@openstatus/icons/brand"; import { FormDiscord } from "@/components/forms/notifications/form-discord"; import { FormEmail } from "@/components/forms/notifications/form-email"; @@ -36,13 +36,13 @@ export const actions = [ { id: "edit", label: "Settings", - icon: Cog, + icon: Settings, variant: "default" as const, }, { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; @@ -77,12 +77,12 @@ export const config = { form: FormDiscord, }, email: { - icon: Mail, + icon: Email, label: "Email", form: FormEmail, }, sms: { - icon: MessageCircle, + icon: Chat, label: "SMS", form: FormSms, }, @@ -112,7 +112,7 @@ export const config = { form: FormPagerDuty, }, ntfy: { - icon: BellIcon, // TODO: add svg icon + icon: Notification, // TODO: add svg icon label: "Ntfy", form: FormNtfy, }, diff --git a/apps/dashboard/src/data/overview-events.client.ts b/apps/dashboard/src/data/overview-events.client.ts index 02e8aab49..1b5d213fd 100644 --- a/apps/dashboard/src/data/overview-events.client.ts +++ b/apps/dashboard/src/data/overview-events.client.ts @@ -1,12 +1,12 @@ import type { RouterOutputs } from "@openstatus/api"; import { - Activity, - type LucideIcon, - Megaphone, - PanelTop, - Siren, - Wrench, -} from "lucide-react"; + Monitor, + type IconType, + Report, + StatusPage, + Incident as IncidentIcon, + Maintenance as MaintenanceIcon, +} from "@openstatus/icons"; type Incident = RouterOutputs["incident"]["list"][number]; type StatusReport = RouterOutputs["statusReport"]["list"][number]; @@ -18,9 +18,9 @@ export type OverviewEvent = | { type: "maintenance"; maintenance: Maintenance }; export const eventTypeConfig = { - incident: { label: "Incident", icon: Siren }, - report: { label: "Status Report", icon: Megaphone }, - maintenance: { label: "Maintenance", icon: Wrench }, + incident: { label: "Incident", icon: IncidentIcon }, + report: { label: "Status Report", icon: Report }, + maintenance: { label: "Maintenance", icon: MaintenanceIcon }, } as const; export type OverviewEventType = keyof typeof eventTypeConfig; @@ -111,7 +111,7 @@ export type OverviewMetric = { value: number; href?: string; variant: "default" | "destructive" | "warning" | "info"; - icon: LucideIcon; + icon: IconType; }; export function buildOverviewData( @@ -164,14 +164,14 @@ export function buildOverviewData( value: monitors.length, href: "/monitors", variant: "default", - icon: Activity, + icon: Monitor, }, { title: "Status Pages", value: pages.length, href: "/status-pages", variant: "default", - icon: PanelTop, + icon: StatusPage, }, { title: "Open Incidents", diff --git a/apps/dashboard/src/data/page-components.client.ts b/apps/dashboard/src/data/page-components.client.ts index ff6f46922..9aba51217 100644 --- a/apps/dashboard/src/data/page-components.client.ts +++ b/apps/dashboard/src/data/page-components.client.ts @@ -1,10 +1,10 @@ -import { Trash2 } from "lucide-react"; +import { Delete } from "@openstatus/icons"; export const actions = [ { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/dashboard/src/data/region-metrics.client.ts b/apps/dashboard/src/data/region-metrics.client.ts index 5d803beed..8c5d7a70f 100644 --- a/apps/dashboard/src/data/region-metrics.client.ts +++ b/apps/dashboard/src/data/region-metrics.client.ts @@ -1,4 +1,4 @@ -import { Filter } from "lucide-react"; +import { Filter } from "@openstatus/icons"; export const actions = [ { diff --git a/apps/dashboard/src/data/status-pages.client.ts b/apps/dashboard/src/data/status-pages.client.ts index 8f2c87bef..25b1b9a08 100644 --- a/apps/dashboard/src/data/status-pages.client.ts +++ b/apps/dashboard/src/data/status-pages.client.ts @@ -1,10 +1,10 @@ -import { Cog, Copy, Trash2 } from "lucide-react"; +import { Settings, Copy, Delete } from "@openstatus/icons"; export const actions = [ { id: "edit", label: "Settings", - icon: Cog, + icon: Settings, variant: "default" as const, }, { @@ -22,7 +22,7 @@ export const actions = [ { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/dashboard/src/data/status-report-updates.client.ts b/apps/dashboard/src/data/status-report-updates.client.ts index d6612d4fb..a19f259c3 100644 --- a/apps/dashboard/src/data/status-report-updates.client.ts +++ b/apps/dashboard/src/data/status-report-updates.client.ts @@ -1,6 +1,6 @@ import type { StatusReportStatus } from "@openstatus/db/src/schema"; import type { PageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; -import { Cog, Trash2 } from "lucide-react"; +import { Settings, Delete } from "@openstatus/icons"; import type { FormValues as StatusReportUpdateFormValues } from "@/components/forms/status-report-update/form"; @@ -53,13 +53,13 @@ export const actions = [ { id: "edit", label: "Settings", - icon: Cog, + icon: Settings, variant: "default" as const, }, { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/dashboard/src/data/status-reports.client.ts b/apps/dashboard/src/data/status-reports.client.ts index 919b2b75e..6eb21c60e 100644 --- a/apps/dashboard/src/data/status-reports.client.ts +++ b/apps/dashboard/src/data/status-reports.client.ts @@ -1,28 +1,28 @@ -import { Cog, Eye, Plus, Trash2 } from "lucide-react"; +import { Settings, Show, Add, Delete } from "@openstatus/icons"; export const actions = [ { id: "edit", label: "Settings", - icon: Cog, + icon: Settings, variant: "default" as const, }, { id: "create-update", label: "Create Update", - icon: Plus, + icon: Add, variant: "default" as const, }, { id: "view-report", label: "View Report", - icon: Eye, + icon: Show, variant: "default" as const, }, { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/status-page/next.config.ts b/apps/status-page/next.config.ts index 4e4d402cb..399760a4b 100644 --- a/apps/status-page/next.config.ts +++ b/apps/status-page/next.config.ts @@ -36,10 +36,24 @@ const withNextIntl = createNextIntlPlugin({ }, }); +// ICON_SET=nucleo swaps the icon set at resolution time (see packages/icons) +const useNucleoIcons = process.env.ICON_SET === "nucleo"; + const nextConfig: NextConfig = { output: process.env.SELF_HOST === "true" ? "standalone" : undefined, experimental: { authInterrupts: true, + // barrel-optimize only when unaliased — the rewrite would bypass the nucleo alias + optimizePackageImports: useNucleoIcons ? [] : ["@openstatus/icons"], + }, + turbopack: useNucleoIcons + ? { resolveAlias: { "@openstatus/icons": "@openstatus/icons/nucleo" } } + : undefined, + webpack: (config) => { + if (useNucleoIcons) { + config.resolve.alias["@openstatus/icons$"] = "@openstatus/icons/nucleo"; + } + return config; }, images: { remotePatterns: [ diff --git a/apps/status-page/package.json b/apps/status-page/package.json index 58a35fa7e..1a6588abf 100644 --- a/apps/status-page/package.json +++ b/apps/status-page/package.json @@ -29,6 +29,7 @@ "@openstatus/db": "workspace:*", "@openstatus/emails": "workspace:*", "@openstatus/error": "workspace:*", + "@openstatus/icons": "workspace:*", "@openstatus/locales": "workspace:*", "@openstatus/react": "workspace:*", "@openstatus/theme-store": "workspace:*", diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(auth)/login/_components/section-magic-link.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(auth)/login/_components/section-magic-link.tsx index 0030f8675..edc01c0c9 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(auth)/login/_components/section-magic-link.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(auth)/login/_components/section-magic-link.tsx @@ -1,7 +1,7 @@ "use client"; +import { Inbox } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; -import { Inbox } from "lucide-react"; import { useExtracted } from "next-intl"; import { useParams } from "next/navigation"; import { useState } from "react"; diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(auth)/restricted/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(auth)/restricted/page.tsx index cbdecf1d2..0c120088a 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(auth)/restricted/page.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(auth)/restricted/page.tsx @@ -1,7 +1,7 @@ "use client"; +import { Restricted } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; -import { ShieldAlert } from "lucide-react"; import { useExtracted } from "next-intl"; import { notFound, useParams } from "next/navigation"; @@ -21,7 +21,7 @@ export default function RestrictedPage() { return (
- +

{t("Access Restricted")}

{t( diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/page.tsx index 964d2a630..86cdc83d6 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/page.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { Trend } from "@openstatus/icons"; import { Status, StatusContent, @@ -9,7 +10,6 @@ import { } from "@openstatus/ui/components/blocks/status-layout"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { useQuery } from "@tanstack/react-query"; -import { TrendingUp } from "lucide-react"; import { useExtracted } from "next-intl"; import { useParams } from "next/navigation"; import { useQueryStates } from "nuqs"; @@ -259,7 +259,7 @@ export default function Page() { variant="outline" className="py-px font-mono text-[10px]" > - {slowestRegion} + {slowestRegion} )} diff --git a/apps/status-page/src/components/button/button-back.tsx b/apps/status-page/src/components/button/button-back.tsx index 826e2713e..be715c568 100644 --- a/apps/status-page/src/components/button/button-back.tsx +++ b/apps/status-page/src/components/button/button-back.tsx @@ -1,8 +1,8 @@ "use client"; +import { Back } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; -import { ArrowLeft } from "lucide-react"; import { useExtracted } from "next-intl"; import Link from "next/link"; @@ -21,7 +21,7 @@ export function ButtonBack({ {...props} > - + {t("Back")} diff --git a/apps/status-page/src/components/button/button-copy-link.tsx b/apps/status-page/src/components/button/button-copy-link.tsx index 788f059bb..88658c111 100644 --- a/apps/status-page/src/components/button/button-copy-link.tsx +++ b/apps/status-page/src/components/button/button-copy-link.tsx @@ -1,9 +1,9 @@ "use client"; +import { Check, Copy } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; -import { Check, Copy } from "lucide-react"; import { useExtracted } from "next-intl"; export function ButtonCopyLink({ diff --git a/apps/status-page/src/components/content/metric-card.tsx b/apps/status-page/src/components/content/metric-card.tsx index 266b40f7b..4bd08f4c5 100644 --- a/apps/status-page/src/components/content/metric-card.tsx +++ b/apps/status-page/src/components/content/metric-card.tsx @@ -1,8 +1,8 @@ +import { ChevronDown, ChevronUp } from "@openstatus/icons"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { cn } from "@openstatus/ui/lib/utils"; import type { VariantProps } from "class-variance-authority"; import { cva } from "class-variance-authority"; -import { ChevronDown, ChevronUp } from "lucide-react"; const metricCardVariants = cva( "text-card-foreground flex flex-col gap-1 rounded-lg border px-3 py-2", diff --git a/apps/status-page/src/components/content/timestamp-hover-card.tsx b/apps/status-page/src/components/content/timestamp-hover-card.tsx index 8d03025cf..e70ecf23d 100644 --- a/apps/status-page/src/components/content/timestamp-hover-card.tsx +++ b/apps/status-page/src/components/content/timestamp-hover-card.tsx @@ -1,6 +1,7 @@ "use client"; import { UTCDate } from "@date-fns/utc"; +import { Check, Copy } from "@openstatus/icons"; import { HoverCard, HoverCardContent, @@ -14,7 +15,6 @@ import { } from "@radix-ui/react-hover-card"; import { format } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns"; -import { Check, Copy } from "lucide-react"; import { useExtracted } from "next-intl"; import { useEffect, useState } from "react"; diff --git a/apps/status-page/src/components/nav/footer.tsx b/apps/status-page/src/components/nav/footer.tsx index 1a2be0812..a74f26eb1 100644 --- a/apps/status-page/src/components/nav/footer.tsx +++ b/apps/status-page/src/components/nav/footer.tsx @@ -1,5 +1,6 @@ "use client"; +import { Clock } from "@openstatus/icons"; import { StatusPageFooter, StatusPageFooterActions, @@ -9,7 +10,6 @@ import { import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; -import { Clock } from "lucide-react"; import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; diff --git a/apps/status-page/src/components/nav/header.tsx b/apps/status-page/src/components/nav/header.tsx index 6f5b1cd0a..381463ee3 100644 --- a/apps/status-page/src/components/nav/header.tsx +++ b/apps/status-page/src/components/nav/header.tsx @@ -1,6 +1,7 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Menu, Chat } from "@openstatus/icons"; import { StatusPageGetInTouchIcon } from "@openstatus/ui/components/blocks/status-page-get-in-touch"; import { StatusPageHeader, @@ -23,7 +24,6 @@ import { import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; -import { Menu, MessageCircleMore } from "lucide-react"; import { useExtracted } from "next-intl"; import NextLink from "next/link"; import { useParams, usePathname } from "next/navigation"; @@ -152,7 +152,7 @@ export function Header({ {page?.contactUrl ? ( - + {t("Get in touch")} diff --git a/apps/status-page/src/components/status-page/floating-button.tsx b/apps/status-page/src/components/status-page/floating-button.tsx index 01e0d8074..d69ffd339 100644 --- a/apps/status-page/src/components/status-page/floating-button.tsx +++ b/apps/status-page/src/components/status-page/floating-button.tsx @@ -1,5 +1,6 @@ "use client"; +import { Check, Expand, Settings } from "@openstatus/icons"; import { type CustomTheme, THEMES, @@ -33,7 +34,6 @@ import { } from "@openstatus/ui/components/ui/select"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; -import { Check, ChevronsUpDown, Settings } from "lucide-react"; import { parseAsString, useQueryState } from "nuqs"; import type React from "react"; import { createContext, useContext, useEffect, useState } from "react"; @@ -320,7 +320,7 @@ export function FloatingButton({ {THEMES[communityTheme].name} - + diff --git a/apps/status-page/src/components/status-page/floating-theme.tsx b/apps/status-page/src/components/status-page/floating-theme.tsx index bbf8b90f9..f7f54df7f 100644 --- a/apps/status-page/src/components/status-page/floating-theme.tsx +++ b/apps/status-page/src/components/status-page/floating-theme.tsx @@ -1,5 +1,6 @@ "use client"; +import { Check, Expand, Theme } from "@openstatus/icons"; import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { @@ -17,7 +18,6 @@ import { PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { cn } from "@openstatus/ui/lib/utils"; -import { Check, ChevronsUpDown, Palette } from "lucide-react"; import { useEffect } from "react"; import { useState } from "react"; @@ -54,7 +54,7 @@ export function FloatingTheme({ className }: { className?: string }) { @@ -83,7 +83,7 @@ export function FloatingTheme({ className }: { className?: string }) { {THEMES[communityTheme].name} - + diff --git a/apps/status-page/src/components/status-page/status-updates.tsx b/apps/status-page/src/components/status-page/status-updates.tsx index 7ba925e44..f2e98a2ca 100644 --- a/apps/status-page/src/components/status-page/status-updates.tsx +++ b/apps/status-page/src/components/status-page/status-updates.tsx @@ -1,6 +1,7 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; +import { Inbox } from "@openstatus/icons"; import { StatusUpdates as BlockStatusUpdates, StatusUpdatesContent, @@ -21,7 +22,6 @@ import { } from "@openstatus/ui/components/ui/tabs"; import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { cn } from "@openstatus/ui/lib/utils"; -import { Inbox } from "lucide-react"; import { useExtracted } from "next-intl"; import { useParams } from "next/navigation"; import { useState } from "react"; diff --git a/apps/status-page/src/components/themes/theme-palette-picker.tsx b/apps/status-page/src/components/themes/theme-palette-picker.tsx index 0b38d78eb..1d06f8d31 100644 --- a/apps/status-page/src/components/themes/theme-palette-picker.tsx +++ b/apps/status-page/src/components/themes/theme-palette-picker.tsx @@ -1,5 +1,6 @@ "use client"; +import { Theme } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Kbd, KbdGroup } from "@openstatus/ui/components/ui/kbd"; import { useSidebar } from "@openstatus/ui/components/ui/sidebar"; @@ -8,7 +9,6 @@ import { TooltipContent, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; -import { Palette } from "lucide-react"; export function ThemePalettePicker() { const { toggleSidebar } = useSidebar(); @@ -16,7 +16,7 @@ export function ThemePalettePicker() { diff --git a/apps/status-page/src/components/themes/theme-select.tsx b/apps/status-page/src/components/themes/theme-select.tsx index 75f49ea41..6cbc45178 100644 --- a/apps/status-page/src/components/themes/theme-select.tsx +++ b/apps/status-page/src/components/themes/theme-select.tsx @@ -1,5 +1,6 @@ "use client"; +import { System, Dark, Light } from "@openstatus/icons"; import { Select, SelectContent, @@ -9,7 +10,6 @@ import { } from "@openstatus/ui/components/ui/select"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; -import { Laptop, Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import type * as React from "react"; import { useState } from "react"; @@ -46,19 +46,19 @@ export function ThemeSelect({

- + Light
- + Dark
- + System
diff --git a/apps/status-page/src/components/themes/theme-sidebar.tsx b/apps/status-page/src/components/themes/theme-sidebar.tsx index 3293962e1..f154774e3 100644 --- a/apps/status-page/src/components/themes/theme-sidebar.tsx +++ b/apps/status-page/src/components/themes/theme-sidebar.tsx @@ -1,5 +1,12 @@ "use client"; +import { + Check, + ChevronDown, + Copy, + Sidebar as SidebarIcon, + Reset, +} from "@openstatus/icons"; import { THEMES, type Theme, @@ -47,13 +54,6 @@ import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { useDebounce } from "@openstatus/ui/hooks/use-debounce"; import { useDebounceCallback } from "@openstatus/ui/hooks/use-debounce-callback"; import { cn } from "@openstatus/ui/lib/utils"; -import { - Check, - ChevronDown, - Copy, - PanelRightIcon, - RotateCcw, -} from "lucide-react"; import { useTheme } from "next-themes"; import { useQueryStates } from "nuqs"; import { useEffect, useState } from "react"; @@ -199,7 +199,7 @@ export function ThemeSidebar(props: React.ComponentProps) { onClick={() => setNewTheme(THEMES[t])} > Reset - + @@ -444,7 +444,7 @@ export function SidebarTrigger({ }} {...props} > - + Toggle Sidebar diff --git a/apps/status-page/src/data/icons.ts b/apps/status-page/src/data/icons.ts index 32bff55d6..92a382f5b 100644 --- a/apps/status-page/src/data/icons.ts +++ b/apps/status-page/src/data/icons.ts @@ -1,12 +1,12 @@ "use client"; -import { Activity, AlertCircle, SearchCheck } from "lucide-react"; +import { Monitor, Alert, Resolved } from "@openstatus/icons"; export const status = { - operational: SearchCheck, - investigating: AlertCircle, - identified: AlertCircle, - monitoring: Activity, + operational: Resolved, + investigating: Alert, + identified: Alert, + monitoring: Monitor, } as const; export const icons = { diff --git a/apps/status-page/src/data/incidents.client.ts b/apps/status-page/src/data/incidents.client.ts index fb66628e1..ea1e4636f 100644 --- a/apps/status-page/src/data/incidents.client.ts +++ b/apps/status-page/src/data/incidents.client.ts @@ -1,10 +1,10 @@ -import { Bookmark, Check, Trash2 } from "lucide-react"; +import { Acknowledge, Check, Delete } from "@openstatus/icons"; export const actions = [ { id: "acknowledge", label: "Acknowledge", - icon: Bookmark, + icon: Acknowledge, variant: "default" as const, }, { @@ -16,7 +16,7 @@ export const actions = [ { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/status-page/src/data/maintenances.client.ts b/apps/status-page/src/data/maintenances.client.ts index 013ede965..6e6b06a64 100644 --- a/apps/status-page/src/data/maintenances.client.ts +++ b/apps/status-page/src/data/maintenances.client.ts @@ -1,16 +1,16 @@ -import { Pencil, Trash2 } from "lucide-react"; +import { Edit, Delete } from "@openstatus/icons"; export const actions = [ { id: "edit", label: "Edit", - icon: Pencil, + icon: Edit, variant: "default" as const, }, { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/status-page/src/data/monitors.client.ts b/apps/status-page/src/data/monitors.client.ts index fa373034e..e55b6843b 100644 --- a/apps/status-page/src/data/monitors.client.ts +++ b/apps/status-page/src/data/monitors.client.ts @@ -1,10 +1,10 @@ -import { Code, Copy, CopyPlus, Pencil, Trash2 } from "lucide-react"; +import { Code, Copy, Duplicate, Edit, Delete } from "@openstatus/icons"; export const actions = [ { id: "edit", label: "Edit", - icon: Pencil, + icon: Edit, variant: "default" as const, }, { @@ -16,7 +16,7 @@ export const actions = [ { id: "clone", label: "Clone", - icon: CopyPlus, + icon: Duplicate, variant: "default" as const, }, { @@ -28,7 +28,7 @@ export const actions = [ { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/status-page/src/data/region-metrics.client.ts b/apps/status-page/src/data/region-metrics.client.ts index 47d92de4f..1d8e7aded 100644 --- a/apps/status-page/src/data/region-metrics.client.ts +++ b/apps/status-page/src/data/region-metrics.client.ts @@ -1,4 +1,4 @@ -import { Filter, Zap } from "lucide-react"; +import { Filter, Speed } from "@openstatus/icons"; export const actions = [ { @@ -10,7 +10,7 @@ export const actions = [ { id: "trigger", label: "Trigger", - icon: Zap, + icon: Speed, variant: "default" as const, }, ] as const; diff --git a/apps/status-page/src/data/status-pages.client.ts b/apps/status-page/src/data/status-pages.client.ts index 894038272..02dc5838b 100644 --- a/apps/status-page/src/data/status-pages.client.ts +++ b/apps/status-page/src/data/status-pages.client.ts @@ -1,10 +1,10 @@ -import { Copy, Pencil, Tag, Trash2 } from "lucide-react"; +import { Copy, Edit, Tag, Delete } from "@openstatus/icons"; export const actions = [ { id: "edit", label: "Edit", - icon: Pencil, + icon: Edit, variant: "default" as const, }, { @@ -22,7 +22,7 @@ export const actions = [ { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/status-page/src/data/status-report-updates.client.ts b/apps/status-page/src/data/status-report-updates.client.ts index b1b10f9ae..14c8adfab 100644 --- a/apps/status-page/src/data/status-report-updates.client.ts +++ b/apps/status-page/src/data/status-report-updates.client.ts @@ -1,16 +1,16 @@ -import { Pencil, Trash2 } from "lucide-react"; +import { Edit, Delete } from "@openstatus/icons"; export const actions = [ { id: "edit", label: "Edit", - icon: Pencil, + icon: Edit, variant: "default" as const, }, { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/status-page/src/data/status-reports.client.ts b/apps/status-page/src/data/status-reports.client.ts index b05a95f43..d629e5744 100644 --- a/apps/status-page/src/data/status-reports.client.ts +++ b/apps/status-page/src/data/status-reports.client.ts @@ -1,22 +1,22 @@ -import { Pencil, Plus, Trash2 } from "lucide-react"; +import { Edit, Add, Delete } from "@openstatus/icons"; export const actions = [ { id: "edit", label: "Edit", - icon: Pencil, + icon: Edit, variant: "default" as const, }, { id: "create-update", label: "Create Update", - icon: Plus, + icon: Add, variant: "default" as const, }, { id: "delete", label: "Delete", - icon: Trash2, + icon: Delete, variant: "destructive" as const, }, ] as const; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index e70c786ea..cfe7daf92 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -49,10 +49,22 @@ const agentDiscoveryHeaders = [ }, ]; +// ICON_SET=nucleo swaps the icon set at resolution time (see packages/icons) +const useNucleoIcons = process.env.ICON_SET === "nucleo"; + /** @type {import('next').NextConfig} */ const nextConfig: NextConfig = { reactStrictMode: true, transpilePackages: ["@openstatus/ui", "@openstatus/api", "next-mdx-remote"], + turbopack: useNucleoIcons + ? { resolveAlias: { "@openstatus/icons": "@openstatus/icons/nucleo" } } + : undefined, + webpack: (config) => { + if (useNucleoIcons) { + config.resolve.alias["@openstatus/icons$"] = "@openstatus/icons/nucleo"; + } + return config; + }, env: { OPENSTATUS_MCP_SERVER_VERSION: readMcpServerVersion(), }, @@ -68,6 +80,8 @@ const nextConfig: NextConfig = { experimental: { turbopackScopeHoisting: false, // serverMinification:false, + // barrel-optimize only when unaliased — the rewrite would bypass the nucleo alias + optimizePackageImports: useNucleoIcons ? [] : ["@openstatus/icons"], }, serverExternalPackages: ["@google-cloud/tasks"], expireTime: 180, // 3 minutes diff --git a/apps/web/src/app/(landing)/status/external-service-components.tsx b/apps/web/src/app/(landing)/status/external-service-components.tsx index aa0cced93..4d9f872f4 100644 --- a/apps/web/src/app/(landing)/status/external-service-components.tsx +++ b/apps/web/src/app/(landing)/status/external-service-components.tsx @@ -1,5 +1,6 @@ "use client"; +import { ChevronDown, ChevronRight } from "@openstatus/icons"; import { StatusBar } from "@openstatus/ui/components/blocks/status-bar"; import type { StatusBarData, @@ -12,7 +13,6 @@ import { CollapsibleTrigger, } from "@openstatus/ui/components/ui/collapsible"; import { cn } from "@openstatus/ui/lib/utils"; -import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; import { useState } from "react"; import { ExternalServicePill } from "./external-service-pill"; @@ -240,9 +240,9 @@ function SectionBlock({ {open ? ( - + ) : ( - + )} {label} diff --git a/apps/web/src/app/(landing)/status/service-search.tsx b/apps/web/src/app/(landing)/status/service-search.tsx index eab3ff9a0..74281965f 100644 --- a/apps/web/src/app/(landing)/status/service-search.tsx +++ b/apps/web/src/app/(landing)/status/service-search.tsx @@ -1,11 +1,11 @@ "use client"; +import { Search as SearchIcon } from "@openstatus/icons"; import { InputGroup, InputGroupAddon, InputGroupInput, } from "@openstatus/ui/components/ui/input-group"; -import { SearchIcon } from "lucide-react"; import { useQueryState } from "nuqs"; import { useEffect, useRef } from "react"; diff --git a/apps/web/src/components/icon-cloud-provider.tsx b/apps/web/src/components/icon-cloud-provider.tsx index c1e9d7a24..438072855 100644 --- a/apps/web/src/components/icon-cloud-provider.tsx +++ b/apps/web/src/components/icon-cloud-provider.tsx @@ -1,11 +1,11 @@ -import { Fly, Koyeb, Railway } from "@openstatus/icons"; +import { Globe } from "@openstatus/icons"; +import { Fly, Koyeb, Railway } from "@openstatus/icons/brand"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; -import { Globe } from "lucide-react"; import { cn } from "../lib/utils"; diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index 90463561a..6db64d849 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -1,186 +1,66 @@ import { - Activity, - AlertTriangle, - ArrowLeftRight, - Bell, - Book, - BookOpenCheck, - Bot, - Calendar, - Camera, - CandlestickChart, - Check, - ChevronsLeftRightEllipsis, + Monitor, + Compare, Clock, - Cog, + Settings, Command, Copy, - Cpu, - CreditCard, - Eye, - EyeOff, - File, - FileClock, - FileText, Fingerprint, - Gauge, Globe, - Hammer, - Hourglass, + Maintenance, Image, Info, - KeyRound, - Laptop, - LayoutDashboard, - Library, - LineChart, + ApiKey, Link, - Linkedin, - Megaphone, - MessageCircle, - Minus, - Moon, Network, - Newspaper, - Package, - Palette, - PanelTop, - Pencil, Play, - Plug, - Puzzle, - Radar, - Ratio, Search, - SearchCheck, + Resolved, Server, - ShieldCheck, - Siren, - Sparkles, - SunMedium, Table, - Tag, - Terminal, - Timer, - ToyBrick, - Trash, - TwitterIcon, - UserCircle, - Users, + Account, Webhook, - Workflow, - Youtube, - Zap, -} from "lucide-react"; -import type { LucideIcon, LucideProps } from "lucide-react"; + Speed, +} from "@openstatus/icons"; +import type { IconType, IconProps } from "@openstatus/icons"; +import { + BlueskyIcon, + DiscordIcon, + GitHubIcon, + LinkedInIcon, + XIcon, +} from "@openstatus/icons/brand"; -export type Icon = LucideIcon; -export type IconProps = LucideProps; +export type Icon = IconType; +export type { IconProps }; export type ValidIcon = keyof typeof Icons; export const Icons = { - activity: Activity, - "layout-dashboard": LayoutDashboard, - link: Link, - siren: Siren, - "panel-top": PanelTop, - table: Table, - "toy-brick": ToyBrick, - gauge: Gauge, - package: Package, - library: Library, - cog: Cog, - cpu: Cpu, - hammer: Hammer, - search: Search, - "search-check": SearchCheck, - palette: Palette, + activity: Monitor, + clock: Clock, + cog: Settings, + command: Command, + compare: Compare, + copy: Copy, fingerprint: Fingerprint, - pencil: Pencil, - "message-circle": MessageCircle, - calendar: Calendar, - tag: Tag, - trash: Trash, - twitter: TwitterIcon, - terminal: Terminal, globe: Globe, - compare: ArrowLeftRight, - plug: Plug, - copy: Copy, - check: Check, - play: Play, - bot: Bot, - puzzle: Puzzle, + hammer: Maintenance, image: Image, - bell: Bell, - zap: Zap, - eye: Eye, - file: File, - "file-text": FileText, - workflow: Workflow, - "eye-off": EyeOff, - network: Network, - users: Users, - key: KeyRound, - "credit-card": CreditCard, - "alert-triangle": AlertTriangle, - "file-clock": FileClock, - megaphone: Megaphone, - webhook: Webhook, - minus: Minus, - sun: SunMedium, - moon: Moon, - laptop: Laptop, - sparkles: Sparkles, - timer: Timer, - clock: Clock, - "line-chart": LineChart, - linkedin: Linkedin, - book: Book, - newspaper: Newspaper, - youtube: Youtube, - "hour-glass": Hourglass, - "candlestick-chart": CandlestickChart, - ratio: Ratio, - user: UserCircle, - camera: Camera, - "book-open-check": BookOpenCheck, - "shield-check": ShieldCheck, info: Info, + key: ApiKey, + link: Link, + network: Network, + play: Play, + search: Search, + "search-check": Resolved, server: Server, - command: Command, - radar: Radar, - "chevron-left-right-ellipsis": ChevronsLeftRightEllipsis, - discord: ({ ...props }: LucideProps) => ( - - - - ), - google: (props: LucideProps) => ( - - - - ), - github: (props: LucideProps) => ( - - - - ), - bluesky: (props: LucideProps) => ( - - - - ), + table: Table, + user: Account, + webhook: Webhook, + zap: Speed, + bluesky: BlueskyIcon, + discord: DiscordIcon, + github: GitHubIcon, + linkedin: LinkedInIcon, + twitter: XIcon, } as const; diff --git a/apps/web/src/content/cmdk.tsx b/apps/web/src/content/cmdk.tsx index dcd2579ac..a698b36fa 100644 --- a/apps/web/src/content/cmdk.tsx +++ b/apps/web/src/content/cmdk.tsx @@ -1,5 +1,10 @@ "use client"; +import { + Loading, + Search as SearchIcon, + Close as XIcon, +} from "@openstatus/icons"; import { Command, CommandEmpty, @@ -19,7 +24,6 @@ import { useDebounce } from "@openstatus/ui/hooks/use-debounce"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Command as CommandPrimitive } from "cmdk"; -import { Loader2, Search, X } from "lucide-react"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; import * as React from "react"; @@ -310,7 +314,7 @@ export function CmdK({ onClick={() => setOpen(true)} > - Search... + SearchIcon... ⌘K @@ -324,7 +328,7 @@ export function CmdK({ }} className="top-[15%] translate-y-0 overflow-hidden rounded-none p-0 font-mono shadow-2xl lg:max-w-2xl xl:max-w-3xl" > - Search + SearchIcon { // e.key === "Escape" || @@ -341,9 +345,9 @@ export function CmdK({ cmdk-input-wrapper="" > {loading || fetching ? ( - + ) : ( - + )} {page ? ( @@ -354,7 +358,7 @@ export function CmdK({ className="opacity-60 hover:opacity-100" onClick={() => setPages([])} > - + ) : scope === "all" ? ( diff --git a/apps/web/src/content/docs-feedback.tsx b/apps/web/src/content/docs-feedback.tsx index b9cefb174..362d25028 100644 --- a/apps/web/src/content/docs-feedback.tsx +++ b/apps/web/src/content/docs-feedback.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Inbox, Loading } from "@openstatus/icons"; import { Form, FormControl, @@ -15,7 +16,6 @@ import { PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; -import { Inbox, LoaderCircle } from "lucide-react"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -185,7 +185,7 @@ function DocsFeedbackBar({ path }: { path: string }) { disabled={form.formState.isSubmitting} > {form.formState.isSubmitting ? ( - + ) : ( <> Send diff --git a/apps/web/src/content/docs-sidebar.tsx b/apps/web/src/content/docs-sidebar.tsx index 8b7546c23..e28081909 100644 --- a/apps/web/src/content/docs-sidebar.tsx +++ b/apps/web/src/content/docs-sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { Menu } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Drawer, @@ -7,7 +8,6 @@ import { DrawerTitle, DrawerTrigger, } from "@openstatus/ui/components/ui/drawer"; -import { Menu } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; diff --git a/apps/web/src/content/theme-toggle.tsx b/apps/web/src/content/theme-toggle.tsx index a39e10289..c7677bb59 100644 --- a/apps/web/src/content/theme-toggle.tsx +++ b/apps/web/src/content/theme-toggle.tsx @@ -1,6 +1,6 @@ "use client"; -import { Laptop, Moon, Sun } from "lucide-react"; +import { System, Dark, Light } from "@openstatus/icons"; import { useTheme } from "next-themes"; import type * as React from "react"; import { useEffect, useState } from "react"; @@ -28,13 +28,13 @@ export function ThemeToggle({ {...props} >
- +
- +
- +
); diff --git a/packages/icons/README.md b/packages/icons/README.md index ea0aff1a8..a2b33f36f 100644 --- a/packages/icons/README.md +++ b/packages/icons/README.md @@ -1,186 +1,23 @@ -# Lucide React Icons Used in Dashboard +# @openstatus/icons -This document lists all the lucide-react icons currently used in the OpenStatus dashboard application. +Single import surface for icons across the openstatus apps. -## Complete Icon List +- `@openstatus/icons` — the app icon set (lucide-backed by default) +- `@openstatus/icons/nucleo` — same names, Nucleo-backed where vendored; selected + at build time by setting `ICON_SET=nucleo` (aliased in each app's `next.config.ts`) +- `@openstatus/icons/brand` — third-party brand/logo icons (GitHub, Slack, …) -- Activity -- AlertCircle -- ArrowUpRight -- AudioLines -- Bell -- Book -- Bookmark -- Braces -- CalendarClock -- CalendarIcon -- Check -- CheckCircle2 -- CheckIcon -- ChevronDown -- ChevronDownIcon -- ChevronLeft -- ChevronRight -- ChevronRightIcon -- ChevronUp -- ChevronUpIcon -- ChevronsUpDown -- CircleCheck -- CircleDashed -- CircleIcon -- Clock -- ClockIcon -- Cog -- Copy -- CopyPlus -- CreditCard -- ExternalLink -- Eye -- FileDown -- FileJson -- Filter -- Globe -- GripVertical -- Hammer -- HelpCircle -- Info -- Inbox -- Key -- Laptop -- LayoutGrid -- LifeBuoy -- List -- Loader -- LoaderCircle -- Lock -- LogOut -- Logs -- Megaphone -- Mic -- Moon -- MoreHorizontal -- Network -- PanelLeftIcon -- PanelRight -- PanelTop -- Pencil -- Plus -- PlusCircle -- Rocket -- Search -- SearchCheck -- SearchIcon -- Settings -- Settings2 -- Siren -- Sparkles -- Sun -- TableProperties -- Terminal -- Trash2 -- User -- Users -- Workflow -- X -- XCircle -- XIcon +Both sets must export identical names — enforced by `src/parity.ts` at compile time. -## Usage Statistics +Nucleo icons are used under the [Nucleo license](https://nucleoapp.com/license) +(max 100 icons in open-source projects, with copyright notice). With the approval from [Sebastiano Guerriero](https://x.com/guerriero_se), we have the authorization to add up to 140-150 icons. -- **Total Icons**: 75 unique icons -- **Most Common**: Check, Plus, X, ChevronRight, ChevronDown, ChevronUp -- **Categories**: - - Navigation (Chevron*, Arrow*, MoreHorizontal) - - Actions (Plus, X, Trash2, Copy, Pencil) - - Status (Check, AlertCircle, CheckCircle2, XCircle) - - UI Elements (Settings, Search, Bell, Lock) - - Content (Book, Braces, TableProperties, Globe) +## Licensing -## Icon Categories +This package is `private` and not published to npm. -### Navigation & Direction -- ArrowUpRight -- ChevronDown, ChevronDownIcon -- ChevronLeft -- ChevronRight, ChevronRightIcon -- ChevronUp, ChevronUpIcon -- ChevronsUpDown - -### Actions & Operations -- Check, CheckIcon -- Copy, CopyPlus -- Eye -- Pencil -- Plus, PlusCircle -- Trash2 -- X, XIcon - -### Status & Feedback -- AlertCircle -- CheckCircle2 -- CircleCheck -- CircleDashed -- Loader, LoaderCircle -- XCircle - -### UI & Interface -- Bell -- Lock -- MoreHorizontal -- PanelLeftIcon, PanelRight, PanelTop -- Search, SearchIcon -- Settings, Settings2 - -### Content & Data -- Book -- Braces -- TableProperties -- Globe -- Network -- Terminal - -### Time & Calendar -- CalendarClock -- CalendarIcon -- Clock, ClockIcon - -### User & Account -- CreditCard -- LogOut -- User -- Users - -### System & Technical -- Activity -- Cog -- FileDown, FileJson -- Key -- Logs -- Workflow - -### Theme & Appearance -- Laptop -- Moon -- Sun - -### Communication -- AudioLines -- Inbox -- Mic -- Megaphone - -### Miscellaneous -- Bookmark -- ExternalLink -- Filter -- GripVertical -- Hammer -- HelpCircle -- Info -- LayoutGrid -- LifeBuoy -- List -- Rocket -- SearchCheck -- Siren -- Sparkles +- Package source, `src/lucide/` and `src/brand/`: MIT (lucide re-exports remain + under the [lucide ISC license](https://lucide.dev/license)). +- `src/nucleo/`: vendored [Nucleo](https://nucleoapp.com) icons, copyright Nucleo, + used under the [Nucleo license](https://nucleoapp.com/license) — see `src/nucleo/NOTICE`. + These icons may **not** be extracted, redistributed, or resold outside of openstatus. diff --git a/packages/icons/package.json b/packages/icons/package.json index 889ca2a40..636addb29 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -1,14 +1,34 @@ { "name": "@openstatus/icons", "version": "0.0.0", - "license": "MIT", - "main": "./src/index.tsx", - "types": "./src/index.tsx", + "private": true, + "license": "SEE LICENSE IN README.md", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/lucide/index.ts", + "default": "./src/lucide/index.ts" + }, + "./lucide": { + "types": "./src/lucide/index.ts", + "default": "./src/lucide/index.ts" + }, + "./nucleo": { + "types": "./src/nucleo/index.ts", + "default": "./src/nucleo/index.ts" + }, + "./brand": { + "types": "./src/brand/index.tsx", + "default": "./src/brand/index.tsx" + } + }, "scripts": { "check": "deno check --sloppy-imports .", - "lint": "oxlint" + "lint": "oxlint", + "tsc": "tsc --noEmit" }, "dependencies": { + "lucide-react": "catalog:", "react": "catalog:" }, "devDependencies": { diff --git a/packages/icons/src/betterstack.tsx b/packages/icons/src/brand/betterstack.tsx similarity index 100% rename from packages/icons/src/betterstack.tsx rename to packages/icons/src/brand/betterstack.tsx diff --git a/packages/icons/src/bluesky.tsx b/packages/icons/src/brand/bluesky.tsx similarity index 100% rename from packages/icons/src/bluesky.tsx rename to packages/icons/src/brand/bluesky.tsx diff --git a/packages/icons/src/discord.tsx b/packages/icons/src/brand/discord.tsx similarity index 100% rename from packages/icons/src/discord.tsx rename to packages/icons/src/brand/discord.tsx diff --git a/packages/icons/src/fly.tsx b/packages/icons/src/brand/fly.tsx similarity index 100% rename from packages/icons/src/fly.tsx rename to packages/icons/src/brand/fly.tsx diff --git a/packages/icons/src/github.tsx b/packages/icons/src/brand/github.tsx similarity index 100% rename from packages/icons/src/github.tsx rename to packages/icons/src/brand/github.tsx diff --git a/packages/icons/src/google.tsx b/packages/icons/src/brand/google.tsx similarity index 100% rename from packages/icons/src/google.tsx rename to packages/icons/src/brand/google.tsx diff --git a/packages/icons/src/grafana.tsx b/packages/icons/src/brand/grafana.tsx similarity index 100% rename from packages/icons/src/grafana.tsx rename to packages/icons/src/brand/grafana.tsx diff --git a/packages/icons/src/index.tsx b/packages/icons/src/brand/index.tsx similarity index 100% rename from packages/icons/src/index.tsx rename to packages/icons/src/brand/index.tsx diff --git a/packages/icons/src/instatus.tsx b/packages/icons/src/brand/instatus.tsx similarity index 100% rename from packages/icons/src/instatus.tsx rename to packages/icons/src/brand/instatus.tsx diff --git a/packages/icons/src/koyeb.tsx b/packages/icons/src/brand/koyeb.tsx similarity index 100% rename from packages/icons/src/koyeb.tsx rename to packages/icons/src/brand/koyeb.tsx diff --git a/packages/icons/src/linkedin.tsx b/packages/icons/src/brand/linkedin.tsx similarity index 100% rename from packages/icons/src/linkedin.tsx rename to packages/icons/src/brand/linkedin.tsx diff --git a/packages/icons/src/markdown.tsx b/packages/icons/src/brand/markdown.tsx similarity index 100% rename from packages/icons/src/markdown.tsx rename to packages/icons/src/brand/markdown.tsx diff --git a/packages/icons/src/modelcontextprotocol.tsx b/packages/icons/src/brand/modelcontextprotocol.tsx similarity index 100% rename from packages/icons/src/modelcontextprotocol.tsx rename to packages/icons/src/brand/modelcontextprotocol.tsx diff --git a/packages/icons/src/ms-teams.tsx b/packages/icons/src/brand/ms-teams.tsx similarity index 100% rename from packages/icons/src/ms-teams.tsx rename to packages/icons/src/brand/ms-teams.tsx diff --git a/packages/icons/src/opsgenie.tsx b/packages/icons/src/brand/opsgenie.tsx similarity index 100% rename from packages/icons/src/opsgenie.tsx rename to packages/icons/src/brand/opsgenie.tsx diff --git a/packages/icons/src/pagerduty.tsx b/packages/icons/src/brand/pagerduty.tsx similarity index 100% rename from packages/icons/src/pagerduty.tsx rename to packages/icons/src/brand/pagerduty.tsx diff --git a/packages/icons/src/railway.tsx b/packages/icons/src/brand/railway.tsx similarity index 100% rename from packages/icons/src/railway.tsx rename to packages/icons/src/brand/railway.tsx diff --git a/packages/icons/src/slack.tsx b/packages/icons/src/brand/slack.tsx similarity index 100% rename from packages/icons/src/slack.tsx rename to packages/icons/src/brand/slack.tsx diff --git a/packages/icons/src/statuspage.tsx b/packages/icons/src/brand/statuspage.tsx similarity index 100% rename from packages/icons/src/statuspage.tsx rename to packages/icons/src/brand/statuspage.tsx diff --git a/packages/icons/src/telegram.tsx b/packages/icons/src/brand/telegram.tsx similarity index 100% rename from packages/icons/src/telegram.tsx rename to packages/icons/src/brand/telegram.tsx diff --git a/packages/icons/src/whatsapp.tsx b/packages/icons/src/brand/whatsapp.tsx similarity index 100% rename from packages/icons/src/whatsapp.tsx rename to packages/icons/src/brand/whatsapp.tsx diff --git a/packages/icons/src/x.tsx b/packages/icons/src/brand/x.tsx similarity index 100% rename from packages/icons/src/x.tsx rename to packages/icons/src/brand/x.tsx diff --git a/packages/icons/src/youtube.tsx b/packages/icons/src/brand/youtube.tsx similarity index 100% rename from packages/icons/src/youtube.tsx rename to packages/icons/src/brand/youtube.tsx diff --git a/packages/icons/src/lucide/index.ts b/packages/icons/src/lucide/index.ts new file mode 100644 index 000000000..590470b96 --- /dev/null +++ b/packages/icons/src/lucide/index.ts @@ -0,0 +1,122 @@ +// Single import surface for icons across all openstatus apps. +// The nucleo set mirrors these names; see ../nucleo/index.ts. +export type { IconProps, IconType } from "../types"; +export { + Sparkles as AI, + User as Account, + Bookmark as Acknowledge, + Plus as Add, + Bot as Agent, + CircleAlert as Alert, + Braces as Api, + Key as ApiKey, + Workflow as ApiTrigger, + ArrowDown, + ArrowUpRight, + AudioLines, + ScanEye as AuditLog, + ArrowLeft as Back, + CreditCard as Billing, + Newspaper as Blog, + BookOpen, + Calendar, + ScrollText as Changelog, + MessageCircle as Chat, + Check, + ChevronDown, + ChevronRight, + ChevronUp, + Clock, + X as Close, + Code, + ChevronsDownUp as Collapse, + Command, + ArrowLeftRight as Compare, + LayoutTemplate as Components, + Copy, + Moon as Dark, + Database, + Trash2 as Delete, + Book as Docs, + FileDown as Download, + GripVertical as DragHandle, + CopyPlus as Duplicate, + Pencil as Edit, + Mail as Email, + CornerDownLeft as Enter, + CircleX as Error, + ChevronsUpDown as Expand, + ExternalLink, + CircleMinus as Failed, + FileJson, + FileText, + Filter, + Fingerprint, + Globe, + HelpCircle as Help, + EyeOff as Hide, + Image, + Gauge as Impact, + Inbox, + Siren as Incident, + Info, + Blocks as Integrations, + UserPlus as Invite, + Rocket as Launch, + Sun as Light, + Link, + Link2 as Linked, + ListFilter, + LoaderCircle as Loading, + Lock, + LogIn as Login, + LogOut as Logout, + Logs, + Hammer as Maintenance, + Menu, + Mic, + Activity as Monitor, + MoreHorizontal as More, + Network, + ArrowRight as Next, + Bell as Notification, + LayoutGrid as Overview, + CircleDashed as Pending, + Play, + Plug, + ServerCog as PrivateLocation, + RefreshCw as Refresh, + MapPin as Region, + Minus as Remove, + Megaphone as Report, + RotateCcw as Reset, + SearchCheck as Resolved, + ShieldAlert as Restricted, + CalendarClock as Schedule, + Search, + Send, + Server, + Cog as Settings, + Shield, + ShieldUser, + Eye as Show, + PanelRight as Sidebar, + PanelLeft as SidebarLeft, + Zap as Speed, + PanelTop as StatusPage, + Square as Stop, + CircleCheck as Success, + LifeBuoy as Support, + Laptop as System, + Table, + TableProperties, + Tag, + Users as Team, + Terminal, + Palette as Theme, + TrendingUp as Trend, + Link2Off as Unlinked, + LockOpen as Unlock, + TriangleAlert as Warning, + Webhook, +} from "lucide-react"; diff --git a/packages/icons/src/nucleo/NOTICE b/packages/icons/src/nucleo/NOTICE new file mode 100644 index 000000000..c1d3d2b3d --- /dev/null +++ b/packages/icons/src/nucleo/NOTICE @@ -0,0 +1,4 @@ +The icon components in this directory are derived from Nucleo icons +(https://nucleoapp.com), copyright Nucleo, and are used under the Nucleo +license (https://nucleoapp.com/license). They may not be extracted, +redistributed, or resold as icons outside of this project. diff --git a/packages/icons/src/nucleo/account.tsx b/packages/icons/src/nucleo/account.tsx new file mode 100644 index 000000000..890405756 --- /dev/null +++ b/packages/icons/src/nucleo/account.tsx @@ -0,0 +1,39 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (User); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Account = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Account.displayName = "Account"; diff --git a/packages/icons/src/nucleo/acknowledge.tsx b/packages/icons/src/nucleo/acknowledge.tsx new file mode 100644 index 000000000..e347a8916 --- /dev/null +++ b/packages/icons/src/nucleo/acknowledge.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Bookmark); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Acknowledge = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Acknowledge.displayName = "Acknowledge"; diff --git a/packages/icons/src/nucleo/add.tsx b/packages/icons/src/nucleo/add.tsx new file mode 100644 index 000000000..1065cf300 --- /dev/null +++ b/packages/icons/src/nucleo/add.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Plus); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Add = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Add.displayName = "Add"; diff --git a/packages/icons/src/nucleo/agent.tsx b/packages/icons/src/nucleo/agent.tsx new file mode 100644 index 000000000..9daebb5c1 --- /dev/null +++ b/packages/icons/src/nucleo/agent.tsx @@ -0,0 +1,77 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Robot); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Agent = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + + + ), +); +Agent.displayName = "Agent"; diff --git a/packages/icons/src/nucleo/ai.tsx b/packages/icons/src/nucleo/ai.tsx new file mode 100644 index 000000000..1618c7189 --- /dev/null +++ b/packages/icons/src/nucleo/ai.tsx @@ -0,0 +1,34 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Sparkle3); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const AI = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +AI.displayName = "AI"; diff --git a/packages/icons/src/nucleo/alert.tsx b/packages/icons/src/nucleo/alert.tsx new file mode 100644 index 000000000..dbfd447bc --- /dev/null +++ b/packages/icons/src/nucleo/alert.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (CircleWarning); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Alert = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Alert.displayName = "Alert"; diff --git a/packages/icons/src/nucleo/api-key.tsx b/packages/icons/src/nucleo/api-key.tsx new file mode 100644 index 000000000..0aa4f2fc6 --- /dev/null +++ b/packages/icons/src/nucleo/api-key.tsx @@ -0,0 +1,34 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Key); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const ApiKey = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +ApiKey.displayName = "ApiKey"; diff --git a/packages/icons/src/nucleo/api-trigger.tsx b/packages/icons/src/nucleo/api-trigger.tsx new file mode 100644 index 000000000..58fc5a5ad --- /dev/null +++ b/packages/icons/src/nucleo/api-trigger.tsx @@ -0,0 +1,71 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (CodeFork); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const ApiTrigger = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + ), +); +ApiTrigger.displayName = "ApiTrigger"; diff --git a/packages/icons/src/nucleo/api.tsx b/packages/icons/src/nucleo/api.tsx new file mode 100644 index 000000000..145ec38cf --- /dev/null +++ b/packages/icons/src/nucleo/api.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (BracketsCurly); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Api = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Api.displayName = "Api"; diff --git a/packages/icons/src/nucleo/arrow-down.tsx b/packages/icons/src/nucleo/arrow-down.tsx new file mode 100644 index 000000000..82d0b9347 --- /dev/null +++ b/packages/icons/src/nucleo/arrow-down.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ArrowDown); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const ArrowDown = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +ArrowDown.displayName = "ArrowDown"; diff --git a/packages/icons/src/nucleo/arrow-up-right.tsx b/packages/icons/src/nucleo/arrow-up-right.tsx new file mode 100644 index 000000000..46df42323 --- /dev/null +++ b/packages/icons/src/nucleo/arrow-up-right.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ArrowUpRight); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const ArrowUpRight = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +ArrowUpRight.displayName = "ArrowUpRight"; diff --git a/packages/icons/src/nucleo/audio-lines.tsx b/packages/icons/src/nucleo/audio-lines.tsx new file mode 100644 index 000000000..5732e46b3 --- /dev/null +++ b/packages/icons/src/nucleo/audio-lines.tsx @@ -0,0 +1,89 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (WaveformLines); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const AudioLines = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + ), +); +AudioLines.displayName = "AudioLines"; diff --git a/packages/icons/src/nucleo/audit-log.tsx b/packages/icons/src/nucleo/audit-log.tsx new file mode 100644 index 000000000..a5c37060a --- /dev/null +++ b/packages/icons/src/nucleo/audit-log.tsx @@ -0,0 +1,69 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (EyeScanner); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const AuditLog = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + ), +); +AuditLog.displayName = "AuditLog"; diff --git a/packages/icons/src/nucleo/back.tsx b/packages/icons/src/nucleo/back.tsx new file mode 100644 index 000000000..d1df6b17a --- /dev/null +++ b/packages/icons/src/nucleo/back.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ArrowLeft); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Back = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Back.displayName = "Back"; diff --git a/packages/icons/src/nucleo/billing.tsx b/packages/icons/src/nucleo/billing.tsx new file mode 100644 index 000000000..9afdb232e --- /dev/null +++ b/packages/icons/src/nucleo/billing.tsx @@ -0,0 +1,70 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (CreditCard); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Billing = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Billing.displayName = "Billing"; diff --git a/packages/icons/src/nucleo/blog.tsx b/packages/icons/src/nucleo/blog.tsx new file mode 100644 index 000000000..da9475b9f --- /dev/null +++ b/packages/icons/src/nucleo/blog.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Newspaper); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Blog = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Blog.displayName = "Blog"; diff --git a/packages/icons/src/nucleo/book-open.tsx b/packages/icons/src/nucleo/book-open.tsx new file mode 100644 index 000000000..8b6320f55 --- /dev/null +++ b/packages/icons/src/nucleo/book-open.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (BookOpen); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const BookOpen = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +BookOpen.displayName = "BookOpen"; diff --git a/packages/icons/src/nucleo/calendar.tsx b/packages/icons/src/nucleo/calendar.tsx new file mode 100644 index 000000000..cec61c0b7 --- /dev/null +++ b/packages/icons/src/nucleo/calendar.tsx @@ -0,0 +1,68 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Calendar); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Calendar = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Calendar.displayName = "Calendar"; diff --git a/packages/icons/src/nucleo/changelog.tsx b/packages/icons/src/nucleo/changelog.tsx new file mode 100644 index 000000000..1480fb4ab --- /dev/null +++ b/packages/icons/src/nucleo/changelog.tsx @@ -0,0 +1,71 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ScrollText); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Changelog = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + ), +); +Changelog.displayName = "Changelog"; diff --git a/packages/icons/src/nucleo/chat.tsx b/packages/icons/src/nucleo/chat.tsx new file mode 100644 index 000000000..38de6162b --- /dev/null +++ b/packages/icons/src/nucleo/chat.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Msg); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Chat = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Chat.displayName = "Chat"; diff --git a/packages/icons/src/nucleo/check.tsx b/packages/icons/src/nucleo/check.tsx new file mode 100644 index 000000000..fd74e22f4 --- /dev/null +++ b/packages/icons/src/nucleo/check.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Check); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Check = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Check.displayName = "Check"; diff --git a/packages/icons/src/nucleo/chevron-down.tsx b/packages/icons/src/nucleo/chevron-down.tsx new file mode 100644 index 000000000..579080bcc --- /dev/null +++ b/packages/icons/src/nucleo/chevron-down.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ChevronDown); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const ChevronDown = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +ChevronDown.displayName = "ChevronDown"; diff --git a/packages/icons/src/nucleo/chevron-right.tsx b/packages/icons/src/nucleo/chevron-right.tsx new file mode 100644 index 000000000..c04953218 --- /dev/null +++ b/packages/icons/src/nucleo/chevron-right.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ChevronRight); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const ChevronRight = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +ChevronRight.displayName = "ChevronRight"; diff --git a/packages/icons/src/nucleo/chevron-up.tsx b/packages/icons/src/nucleo/chevron-up.tsx new file mode 100644 index 000000000..613678c48 --- /dev/null +++ b/packages/icons/src/nucleo/chevron-up.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ChevronUp); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const ChevronUp = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +ChevronUp.displayName = "ChevronUp"; diff --git a/packages/icons/src/nucleo/clock.tsx b/packages/icons/src/nucleo/clock.tsx new file mode 100644 index 000000000..05a2999da --- /dev/null +++ b/packages/icons/src/nucleo/clock.tsx @@ -0,0 +1,39 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Clock); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Clock = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Clock.displayName = "Clock"; diff --git a/packages/icons/src/nucleo/close.tsx b/packages/icons/src/nucleo/close.tsx new file mode 100644 index 000000000..fe4462ff9 --- /dev/null +++ b/packages/icons/src/nucleo/close.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Xmark); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Close = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Close.displayName = "Close"; diff --git a/packages/icons/src/nucleo/code.tsx b/packages/icons/src/nucleo/code.tsx new file mode 100644 index 000000000..71b92020d --- /dev/null +++ b/packages/icons/src/nucleo/code.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Code); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Code = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Code.displayName = "Code"; diff --git a/packages/icons/src/nucleo/collapse.tsx b/packages/icons/src/nucleo/collapse.tsx new file mode 100644 index 000000000..9b49ad453 --- /dev/null +++ b/packages/icons/src/nucleo/collapse.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ChevronReduceY); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Collapse = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Collapse.displayName = "Collapse"; diff --git a/packages/icons/src/nucleo/command.tsx b/packages/icons/src/nucleo/command.tsx new file mode 100644 index 000000000..2b7a851b1 --- /dev/null +++ b/packages/icons/src/nucleo/command.tsx @@ -0,0 +1,66 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Command); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Command = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + ), +); +Command.displayName = "Command"; diff --git a/packages/icons/src/nucleo/compare.tsx b/packages/icons/src/nucleo/compare.tsx new file mode 100644 index 000000000..56d4118c8 --- /dev/null +++ b/packages/icons/src/nucleo/compare.tsx @@ -0,0 +1,60 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ArrowsOppositeDirectionX); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Compare = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Compare.displayName = "Compare"; diff --git a/packages/icons/src/nucleo/components.tsx b/packages/icons/src/nucleo/components.tsx new file mode 100644 index 000000000..acce639c3 --- /dev/null +++ b/packages/icons/src/nucleo/components.tsx @@ -0,0 +1,74 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (GridLayout); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Components = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Components.displayName = "Components"; diff --git a/packages/icons/src/nucleo/copy.tsx b/packages/icons/src/nucleo/copy.tsx new file mode 100644 index 000000000..1d2e333b6 --- /dev/null +++ b/packages/icons/src/nucleo/copy.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Copy2); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Copy = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Copy.displayName = "Copy"; diff --git a/packages/icons/src/nucleo/dark.tsx b/packages/icons/src/nucleo/dark.tsx new file mode 100644 index 000000000..9e0788b50 --- /dev/null +++ b/packages/icons/src/nucleo/dark.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Moon); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Dark = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Dark.displayName = "Dark"; diff --git a/packages/icons/src/nucleo/database.tsx b/packages/icons/src/nucleo/database.tsx new file mode 100644 index 000000000..41ff09353 --- /dev/null +++ b/packages/icons/src/nucleo/database.tsx @@ -0,0 +1,47 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Database); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Database = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Database.displayName = "Database"; diff --git a/packages/icons/src/nucleo/delete.tsx b/packages/icons/src/nucleo/delete.tsx new file mode 100644 index 000000000..75c0aea6d --- /dev/null +++ b/packages/icons/src/nucleo/delete.tsx @@ -0,0 +1,64 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Trash2); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Delete = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + ), +); +Delete.displayName = "Delete"; diff --git a/packages/icons/src/nucleo/docs.tsx b/packages/icons/src/nucleo/docs.tsx new file mode 100644 index 000000000..b37a39e59 --- /dev/null +++ b/packages/icons/src/nucleo/docs.tsx @@ -0,0 +1,53 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Book); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Docs = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Docs.displayName = "Docs"; diff --git a/packages/icons/src/nucleo/download.tsx b/packages/icons/src/nucleo/download.tsx new file mode 100644 index 000000000..8344ae3ca --- /dev/null +++ b/packages/icons/src/nucleo/download.tsx @@ -0,0 +1,70 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (FileDownload); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Download = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + ), +); +Download.displayName = "Download"; diff --git a/packages/icons/src/nucleo/drag-handle.tsx b/packages/icons/src/nucleo/drag-handle.tsx new file mode 100644 index 000000000..8317681bc --- /dev/null +++ b/packages/icons/src/nucleo/drag-handle.tsx @@ -0,0 +1,82 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (GripDotsVertical); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const DragHandle = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + ), +); +DragHandle.displayName = "DragHandle"; diff --git a/packages/icons/src/nucleo/duplicate.tsx b/packages/icons/src/nucleo/duplicate.tsx new file mode 100644 index 000000000..debc12bd4 --- /dev/null +++ b/packages/icons/src/nucleo/duplicate.tsx @@ -0,0 +1,55 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ClonePlus); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Duplicate = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Duplicate.displayName = "Duplicate"; diff --git a/packages/icons/src/nucleo/edit.tsx b/packages/icons/src/nucleo/edit.tsx new file mode 100644 index 000000000..8fbde104d --- /dev/null +++ b/packages/icons/src/nucleo/edit.tsx @@ -0,0 +1,45 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Pencil); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Edit = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Edit.displayName = "Edit"; diff --git a/packages/icons/src/nucleo/email.tsx b/packages/icons/src/nucleo/email.tsx new file mode 100644 index 000000000..5833d5a0e --- /dev/null +++ b/packages/icons/src/nucleo/email.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Envelope); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Email = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Email.displayName = "Email"; diff --git a/packages/icons/src/nucleo/enter.tsx b/packages/icons/src/nucleo/enter.tsx new file mode 100644 index 000000000..562b5427a --- /dev/null +++ b/packages/icons/src/nucleo/enter.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// derived from nucleo ReturnKey (ui-18): keycap border removed, arrow scaled 1.5x to fill the grid +export const Enter = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Enter.displayName = "Enter"; diff --git a/packages/icons/src/nucleo/error.tsx b/packages/icons/src/nucleo/error.tsx new file mode 100644 index 000000000..d3ba3b54e --- /dev/null +++ b/packages/icons/src/nucleo/error.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (CircleXmark); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Error = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Error.displayName = "Error"; diff --git a/packages/icons/src/nucleo/expand.tsx b/packages/icons/src/nucleo/expand.tsx new file mode 100644 index 000000000..2bd42ddb5 --- /dev/null +++ b/packages/icons/src/nucleo/expand.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ChevronExpandY); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Expand = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Expand.displayName = "Expand"; diff --git a/packages/icons/src/nucleo/external-link.tsx b/packages/icons/src/nucleo/external-link.tsx new file mode 100644 index 000000000..637fb43c0 --- /dev/null +++ b/packages/icons/src/nucleo/external-link.tsx @@ -0,0 +1,49 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ShareUpRight); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const ExternalLink = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +ExternalLink.displayName = "ExternalLink"; diff --git a/packages/icons/src/nucleo/failed.tsx b/packages/icons/src/nucleo/failed.tsx new file mode 100644 index 000000000..3b0b9703f --- /dev/null +++ b/packages/icons/src/nucleo/failed.tsx @@ -0,0 +1,42 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (CircleMinus); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Failed = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Failed.displayName = "Failed"; diff --git a/packages/icons/src/nucleo/file-json.tsx b/packages/icons/src/nucleo/file-json.tsx new file mode 100644 index 000000000..6632b3c44 --- /dev/null +++ b/packages/icons/src/nucleo/file-json.tsx @@ -0,0 +1,76 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-core-outline-24 (FileJson) +export const FileJson = forwardRef( + ({ size = 24, strokeWidth = 2, ...props }, ref) => ( + + + + + + + + + ), +); +FileJson.displayName = "FileJson"; diff --git a/packages/icons/src/nucleo/file-text.tsx b/packages/icons/src/nucleo/file-text.tsx new file mode 100644 index 000000000..61e46a6cb --- /dev/null +++ b/packages/icons/src/nucleo/file-text.tsx @@ -0,0 +1,72 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (FileContent); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const FileText = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + ), +); +FileText.displayName = "FileText"; diff --git a/packages/icons/src/nucleo/filter.tsx b/packages/icons/src/nucleo/filter.tsx new file mode 100644 index 000000000..06cdcedac --- /dev/null +++ b/packages/icons/src/nucleo/filter.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Filter); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Filter = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Filter.displayName = "Filter"; diff --git a/packages/icons/src/nucleo/fingerprint.tsx b/packages/icons/src/nucleo/fingerprint.tsx new file mode 100644 index 000000000..d1325b767 --- /dev/null +++ b/packages/icons/src/nucleo/fingerprint.tsx @@ -0,0 +1,62 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Fingerprint); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Fingerprint = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + ), +); +Fingerprint.displayName = "Fingerprint"; diff --git a/packages/icons/src/nucleo/globe.tsx b/packages/icons/src/nucleo/globe.tsx new file mode 100644 index 000000000..280378535 --- /dev/null +++ b/packages/icons/src/nucleo/globe.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Globe); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Globe = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Globe.displayName = "Globe"; diff --git a/packages/icons/src/nucleo/help.tsx b/packages/icons/src/nucleo/help.tsx new file mode 100644 index 000000000..cbdba6287 --- /dev/null +++ b/packages/icons/src/nucleo/help.tsx @@ -0,0 +1,45 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (CircleQuestion); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Help = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Help.displayName = "Help"; diff --git a/packages/icons/src/nucleo/hide.tsx b/packages/icons/src/nucleo/hide.tsx new file mode 100644 index 000000000..961386cb1 --- /dev/null +++ b/packages/icons/src/nucleo/hide.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (EyeSlash); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Hide = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Hide.displayName = "Hide"; diff --git a/packages/icons/src/nucleo/image.tsx b/packages/icons/src/nucleo/image.tsx new file mode 100644 index 000000000..c26ea4f3f --- /dev/null +++ b/packages/icons/src/nucleo/image.tsx @@ -0,0 +1,50 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Image); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Image = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Image.displayName = "Image"; diff --git a/packages/icons/src/nucleo/impact.tsx b/packages/icons/src/nucleo/impact.tsx new file mode 100644 index 000000000..801a4b246 --- /dev/null +++ b/packages/icons/src/nucleo/impact.tsx @@ -0,0 +1,55 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Gauge); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Impact = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Impact.displayName = "Impact"; diff --git a/packages/icons/src/nucleo/inbox.tsx b/packages/icons/src/nucleo/inbox.tsx new file mode 100644 index 000000000..5467cc433 --- /dev/null +++ b/packages/icons/src/nucleo/inbox.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Inbox); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Inbox = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Inbox.displayName = "Inbox"; diff --git a/packages/icons/src/nucleo/incident.tsx b/packages/icons/src/nucleo/incident.tsx new file mode 100644 index 000000000..b2f782cfa --- /dev/null +++ b/packages/icons/src/nucleo/incident.tsx @@ -0,0 +1,90 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Siren); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Incident = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + + + ), +); +Incident.displayName = "Incident"; diff --git a/packages/icons/src/nucleo/index.ts b/packages/icons/src/nucleo/index.ts new file mode 100644 index 000000000..992960f43 --- /dev/null +++ b/packages/icons/src/nucleo/index.ts @@ -0,0 +1,120 @@ +// Nucleo-backed icon set, selected via the ICON_SET=nucleo build alias. +// Vendored from nucleo-ui-outline-18 (plus nucleo-core-outline-24 where noted) under the Nucleo license (see NOTICE). +export type { IconProps, IconType } from "../types"; +export { AI } from "./ai"; +export { Account } from "./account"; +export { Acknowledge } from "./acknowledge"; +export { Add } from "./add"; +export { Agent } from "./agent"; +export { Alert } from "./alert"; +export { Api } from "./api"; +export { ApiKey } from "./api-key"; +export { ApiTrigger } from "./api-trigger"; +export { ArrowDown } from "./arrow-down"; +export { ArrowUpRight } from "./arrow-up-right"; +export { AudioLines } from "./audio-lines"; +export { AuditLog } from "./audit-log"; +export { Back } from "./back"; +export { Billing } from "./billing"; +export { Blog } from "./blog"; +export { BookOpen } from "./book-open"; +export { Calendar } from "./calendar"; +export { Changelog } from "./changelog"; +export { Chat } from "./chat"; +export { Check } from "./check"; +export { ChevronDown } from "./chevron-down"; +export { ChevronRight } from "./chevron-right"; +export { ChevronUp } from "./chevron-up"; +export { Clock } from "./clock"; +export { Close } from "./close"; +export { Code } from "./code"; +export { Collapse } from "./collapse"; +export { Command } from "./command"; +export { Compare } from "./compare"; +export { Components } from "./components"; +export { Copy } from "./copy"; +export { Dark } from "./dark"; +export { Database } from "./database"; +export { Delete } from "./delete"; +export { Docs } from "./docs"; +export { Download } from "./download"; +export { DragHandle } from "./drag-handle"; +export { Duplicate } from "./duplicate"; +export { Edit } from "./edit"; +export { Email } from "./email"; +export { Enter } from "./enter"; +export { Error } from "./error"; +export { Expand } from "./expand"; +export { ExternalLink } from "./external-link"; +export { Failed } from "./failed"; +export { FileJson } from "./file-json"; +export { FileText } from "./file-text"; +export { Filter } from "./filter"; +export { Fingerprint } from "./fingerprint"; +export { Globe } from "./globe"; +export { Help } from "./help"; +export { Hide } from "./hide"; +export { Image } from "./image"; +export { Impact } from "./impact"; +export { Inbox } from "./inbox"; +export { Incident } from "./incident"; +export { Info } from "./info"; +export { Integrations } from "./integrations"; +export { Invite } from "./invite"; +export { Launch } from "./launch"; +export { Light } from "./light"; +export { Link } from "./link"; +export { Linked } from "./linked"; +export { ListFilter } from "./list-filter"; +export { Loading } from "./loading"; +export { Lock } from "./lock"; +export { Login } from "./login"; +export { Logout } from "./logout"; +export { Logs } from "./logs"; +export { Maintenance } from "./maintenance"; +export { Menu } from "./menu"; +export { Mic } from "./mic"; +export { Monitor } from "./monitor"; +export { More } from "./more"; +export { Network } from "./network"; +export { Next } from "./next"; +export { Notification } from "./notification"; +export { Overview } from "./overview"; +export { Pending } from "./pending"; +export { Play } from "./play"; +export { Plug } from "./plug"; +export { PrivateLocation } from "./private-location"; +export { Refresh } from "./refresh"; +export { Region } from "./region"; +export { Remove } from "./remove"; +export { Report } from "./report"; +export { Reset } from "./reset"; +export { Resolved } from "./resolved"; +export { Restricted } from "./restricted"; +export { Schedule } from "./schedule"; +export { Search } from "./search"; +export { Send } from "./send"; +export { Server } from "./server"; +export { Settings } from "./settings"; +export { Shield } from "./shield"; +export { ShieldUser } from "./shield-user"; +export { Show } from "./show"; +export { Sidebar } from "./sidebar"; +export { SidebarLeft } from "./sidebar-left"; +export { Speed } from "./speed"; +export { StatusPage } from "./status-page"; +export { Stop } from "./stop"; +export { Success } from "./success"; +export { Support } from "./support"; +export { System } from "./system"; +export { Table } from "./table"; +export { TableProperties } from "./table-properties"; +export { Tag } from "./tag"; +export { Team } from "./team"; +export { Terminal } from "./terminal"; +export { Theme } from "./theme"; +export { Trend } from "./trend"; +export { Unlinked } from "./unlinked"; +export { Unlock } from "./unlock"; +export { Warning } from "./warning"; +export { Webhook } from "lucide-react"; diff --git a/packages/icons/src/nucleo/info.tsx b/packages/icons/src/nucleo/info.tsx new file mode 100644 index 000000000..207eec2a7 --- /dev/null +++ b/packages/icons/src/nucleo/info.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (CircleInfo); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Info = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Info.displayName = "Info"; diff --git a/packages/icons/src/nucleo/integrations.tsx b/packages/icons/src/nucleo/integrations.tsx new file mode 100644 index 000000000..8502cc82d --- /dev/null +++ b/packages/icons/src/nucleo/integrations.tsx @@ -0,0 +1,65 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (GridSparkle); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Integrations = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Integrations.displayName = "Integrations"; diff --git a/packages/icons/src/nucleo/invite.tsx b/packages/icons/src/nucleo/invite.tsx new file mode 100644 index 000000000..b5e1cb943 --- /dev/null +++ b/packages/icons/src/nucleo/invite.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (UserPlus); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Invite = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Invite.displayName = "Invite"; diff --git a/packages/icons/src/nucleo/launch.tsx b/packages/icons/src/nucleo/launch.tsx new file mode 100644 index 000000000..5cfed4e73 --- /dev/null +++ b/packages/icons/src/nucleo/launch.tsx @@ -0,0 +1,59 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Rocket); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Launch = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + ), +); +Launch.displayName = "Launch"; diff --git a/packages/icons/src/nucleo/light.tsx b/packages/icons/src/nucleo/light.tsx new file mode 100644 index 000000000..e32fb8dc1 --- /dev/null +++ b/packages/icons/src/nucleo/light.tsx @@ -0,0 +1,126 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Sun); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Light = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + + + + ), +); +Light.displayName = "Light"; diff --git a/packages/icons/src/nucleo/link.tsx b/packages/icons/src/nucleo/link.tsx new file mode 100644 index 000000000..942fe9b0a --- /dev/null +++ b/packages/icons/src/nucleo/link.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Link); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Link = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Link.displayName = "Link"; diff --git a/packages/icons/src/nucleo/linked.tsx b/packages/icons/src/nucleo/linked.tsx new file mode 100644 index 000000000..727610669 --- /dev/null +++ b/packages/icons/src/nucleo/linked.tsx @@ -0,0 +1,48 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Link2); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Linked = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Linked.displayName = "Linked"; diff --git a/packages/icons/src/nucleo/list-filter.tsx b/packages/icons/src/nucleo/list-filter.tsx new file mode 100644 index 000000000..63f998c21 --- /dev/null +++ b/packages/icons/src/nucleo/list-filter.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (BarsFilter); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const ListFilter = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +ListFilter.displayName = "ListFilter"; diff --git a/packages/icons/src/nucleo/loading.tsx b/packages/icons/src/nucleo/loading.tsx new file mode 100644 index 000000000..1656ab8ff --- /dev/null +++ b/packages/icons/src/nucleo/loading.tsx @@ -0,0 +1,115 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Loader); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Loading = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + + + ), +); +Loading.displayName = "Loading"; diff --git a/packages/icons/src/nucleo/lock.tsx b/packages/icons/src/nucleo/lock.tsx new file mode 100644 index 000000000..8306645c4 --- /dev/null +++ b/packages/icons/src/nucleo/lock.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Lock); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Lock = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Lock.displayName = "Lock"; diff --git a/packages/icons/src/nucleo/login.tsx b/packages/icons/src/nucleo/login.tsx new file mode 100644 index 000000000..8733d44d4 --- /dev/null +++ b/packages/icons/src/nucleo/login.tsx @@ -0,0 +1,49 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (RectLogin); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Login = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Login.displayName = "Login"; diff --git a/packages/icons/src/nucleo/logout.tsx b/packages/icons/src/nucleo/logout.tsx new file mode 100644 index 000000000..d16f6df08 --- /dev/null +++ b/packages/icons/src/nucleo/logout.tsx @@ -0,0 +1,49 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (RectLogout); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Logout = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Logout.displayName = "Logout"; diff --git a/packages/icons/src/nucleo/logs.tsx b/packages/icons/src/nucleo/logs.tsx new file mode 100644 index 000000000..46a3885f1 --- /dev/null +++ b/packages/icons/src/nucleo/logs.tsx @@ -0,0 +1,89 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (MenuBars); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Logs = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + ), +); +Logs.displayName = "Logs"; diff --git a/packages/icons/src/nucleo/maintenance.tsx b/packages/icons/src/nucleo/maintenance.tsx new file mode 100644 index 000000000..3383c0223 --- /dev/null +++ b/packages/icons/src/nucleo/maintenance.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Hammer2); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Maintenance = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Maintenance.displayName = "Maintenance"; diff --git a/packages/icons/src/nucleo/menu.tsx b/packages/icons/src/nucleo/menu.tsx new file mode 100644 index 000000000..c62d868e2 --- /dev/null +++ b/packages/icons/src/nucleo/menu.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Menu); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Menu = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Menu.displayName = "Menu"; diff --git a/packages/icons/src/nucleo/mic.tsx b/packages/icons/src/nucleo/mic.tsx new file mode 100644 index 000000000..f98d83a78 --- /dev/null +++ b/packages/icons/src/nucleo/mic.tsx @@ -0,0 +1,53 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Microphone2); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Mic = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Mic.displayName = "Mic"; diff --git a/packages/icons/src/nucleo/monitor.tsx b/packages/icons/src/nucleo/monitor.tsx new file mode 100644 index 000000000..424b7dbf5 --- /dev/null +++ b/packages/icons/src/nucleo/monitor.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ChartActivity2); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Monitor = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Monitor.displayName = "Monitor"; diff --git a/packages/icons/src/nucleo/more.tsx b/packages/icons/src/nucleo/more.tsx new file mode 100644 index 000000000..0c4100577 --- /dev/null +++ b/packages/icons/src/nucleo/more.tsx @@ -0,0 +1,51 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Dots); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const More = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +More.displayName = "More"; diff --git a/packages/icons/src/nucleo/network.tsx b/packages/icons/src/nucleo/network.tsx new file mode 100644 index 000000000..0d4189b1a --- /dev/null +++ b/packages/icons/src/nucleo/network.tsx @@ -0,0 +1,81 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Sitemap); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Network = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + ), +); +Network.displayName = "Network"; diff --git a/packages/icons/src/nucleo/next.tsx b/packages/icons/src/nucleo/next.tsx new file mode 100644 index 000000000..4427e0fea --- /dev/null +++ b/packages/icons/src/nucleo/next.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ArrowRight); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Next = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Next.displayName = "Next"; diff --git a/packages/icons/src/nucleo/notification.tsx b/packages/icons/src/nucleo/notification.tsx new file mode 100644 index 000000000..2c8b3db0d --- /dev/null +++ b/packages/icons/src/nucleo/notification.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Bell); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Notification = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Notification.displayName = "Notification"; diff --git a/packages/icons/src/nucleo/overview.tsx b/packages/icons/src/nucleo/overview.tsx new file mode 100644 index 000000000..c29bf47ea --- /dev/null +++ b/packages/icons/src/nucleo/overview.tsx @@ -0,0 +1,74 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Grid2); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Overview = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Overview.displayName = "Overview"; diff --git a/packages/icons/src/nucleo/pending.tsx b/packages/icons/src/nucleo/pending.tsx new file mode 100644 index 000000000..5d69d5166 --- /dev/null +++ b/packages/icons/src/nucleo/pending.tsx @@ -0,0 +1,88 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (CircleDashed); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Pending = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + + + ), +); +Pending.displayName = "Pending"; diff --git a/packages/icons/src/nucleo/play.tsx b/packages/icons/src/nucleo/play.tsx new file mode 100644 index 000000000..a8795fdf8 --- /dev/null +++ b/packages/icons/src/nucleo/play.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (MediaPlay); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Play = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Play.displayName = "Play"; diff --git a/packages/icons/src/nucleo/plug.tsx b/packages/icons/src/nucleo/plug.tsx new file mode 100644 index 000000000..d16655cbf --- /dev/null +++ b/packages/icons/src/nucleo/plug.tsx @@ -0,0 +1,63 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Plug); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Plug = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Plug.displayName = "Plug"; diff --git a/packages/icons/src/nucleo/private-location.tsx b/packages/icons/src/nucleo/private-location.tsx new file mode 100644 index 000000000..ed3b5fc3b --- /dev/null +++ b/packages/icons/src/nucleo/private-location.tsx @@ -0,0 +1,74 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Router); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const PrivateLocation = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + + ), +); +PrivateLocation.displayName = "PrivateLocation"; diff --git a/packages/icons/src/nucleo/refresh.tsx b/packages/icons/src/nucleo/refresh.tsx new file mode 100644 index 000000000..8912bf912 --- /dev/null +++ b/packages/icons/src/nucleo/refresh.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ArrowRotateClockwise); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Refresh = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Refresh.displayName = "Refresh"; diff --git a/packages/icons/src/nucleo/region.tsx b/packages/icons/src/nucleo/region.tsx new file mode 100644 index 000000000..a0dcf0f1e --- /dev/null +++ b/packages/icons/src/nucleo/region.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (MapPin); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Region = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Region.displayName = "Region"; diff --git a/packages/icons/src/nucleo/remove.tsx b/packages/icons/src/nucleo/remove.tsx new file mode 100644 index 000000000..603487d3a --- /dev/null +++ b/packages/icons/src/nucleo/remove.tsx @@ -0,0 +1,31 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Minus); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Remove = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Remove.displayName = "Remove"; diff --git a/packages/icons/src/nucleo/report.tsx b/packages/icons/src/nucleo/report.tsx new file mode 100644 index 000000000..773b5b6fe --- /dev/null +++ b/packages/icons/src/nucleo/report.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Megaphone); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Report = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Report.displayName = "Report"; diff --git a/packages/icons/src/nucleo/reset.tsx b/packages/icons/src/nucleo/reset.tsx new file mode 100644 index 000000000..6a064543e --- /dev/null +++ b/packages/icons/src/nucleo/reset.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Undo); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Reset = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Reset.displayName = "Reset"; diff --git a/packages/icons/src/nucleo/resolved.tsx b/packages/icons/src/nucleo/resolved.tsx new file mode 100644 index 000000000..1ac0d16cd --- /dev/null +++ b/packages/icons/src/nucleo/resolved.tsx @@ -0,0 +1,45 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (MagnifierCheck); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Resolved = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Resolved.displayName = "Resolved"; diff --git a/packages/icons/src/nucleo/restricted.tsx b/packages/icons/src/nucleo/restricted.tsx new file mode 100644 index 000000000..fdc3ea604 --- /dev/null +++ b/packages/icons/src/nucleo/restricted.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ShieldAlert); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Restricted = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Restricted.displayName = "Restricted"; diff --git a/packages/icons/src/nucleo/schedule.tsx b/packages/icons/src/nucleo/schedule.tsx new file mode 100644 index 000000000..d2ad66e7c --- /dev/null +++ b/packages/icons/src/nucleo/schedule.tsx @@ -0,0 +1,58 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (CalendarClock); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Schedule = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + ), +); +Schedule.displayName = "Schedule"; diff --git a/packages/icons/src/nucleo/search.tsx b/packages/icons/src/nucleo/search.tsx new file mode 100644 index 000000000..0b19b7596 --- /dev/null +++ b/packages/icons/src/nucleo/search.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Magnifier); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Search = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Search.displayName = "Search"; diff --git a/packages/icons/src/nucleo/send.tsx b/packages/icons/src/nucleo/send.tsx new file mode 100644 index 000000000..e9237fa7c --- /dev/null +++ b/packages/icons/src/nucleo/send.tsx @@ -0,0 +1,49 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (PaperPlane); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Send = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Send.displayName = "Send"; diff --git a/packages/icons/src/nucleo/server.tsx b/packages/icons/src/nucleo/server.tsx new file mode 100644 index 000000000..375c1994f --- /dev/null +++ b/packages/icons/src/nucleo/server.tsx @@ -0,0 +1,78 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Server); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Server = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + ), +); +Server.displayName = "Server"; diff --git a/packages/icons/src/nucleo/settings.tsx b/packages/icons/src/nucleo/settings.tsx new file mode 100644 index 000000000..3cdeabb93 --- /dev/null +++ b/packages/icons/src/nucleo/settings.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Gear); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Settings = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Settings.displayName = "Settings"; diff --git a/packages/icons/src/nucleo/shield-user.tsx b/packages/icons/src/nucleo/shield-user.tsx new file mode 100644 index 000000000..2f8dfe01b --- /dev/null +++ b/packages/icons/src/nucleo/shield-user.tsx @@ -0,0 +1,48 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ShieldUser); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const ShieldUser = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +ShieldUser.displayName = "ShieldUser"; diff --git a/packages/icons/src/nucleo/shield.tsx b/packages/icons/src/nucleo/shield.tsx new file mode 100644 index 000000000..b0d517276 --- /dev/null +++ b/packages/icons/src/nucleo/shield.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Shield); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Shield = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Shield.displayName = "Shield"; diff --git a/packages/icons/src/nucleo/show.tsx b/packages/icons/src/nucleo/show.tsx new file mode 100644 index 000000000..785e6192c --- /dev/null +++ b/packages/icons/src/nucleo/show.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Eye); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Show = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Show.displayName = "Show"; diff --git a/packages/icons/src/nucleo/sidebar-left.tsx b/packages/icons/src/nucleo/sidebar-left.tsx new file mode 100644 index 000000000..29410c136 --- /dev/null +++ b/packages/icons/src/nucleo/sidebar-left.tsx @@ -0,0 +1,45 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (LayoutLeft); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const SidebarLeft = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +SidebarLeft.displayName = "SidebarLeft"; diff --git a/packages/icons/src/nucleo/sidebar.tsx b/packages/icons/src/nucleo/sidebar.tsx new file mode 100644 index 000000000..1ef74100e --- /dev/null +++ b/packages/icons/src/nucleo/sidebar.tsx @@ -0,0 +1,46 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (LayoutRight); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Sidebar = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Sidebar.displayName = "Sidebar"; diff --git a/packages/icons/src/nucleo/speed.tsx b/packages/icons/src/nucleo/speed.tsx new file mode 100644 index 000000000..b4e709e75 --- /dev/null +++ b/packages/icons/src/nucleo/speed.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Bolt); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Speed = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Speed.displayName = "Speed"; diff --git a/packages/icons/src/nucleo/status-page.tsx b/packages/icons/src/nucleo/status-page.tsx new file mode 100644 index 000000000..9e2e5eb88 --- /dev/null +++ b/packages/icons/src/nucleo/status-page.tsx @@ -0,0 +1,46 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (LayoutTop); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const StatusPage = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +StatusPage.displayName = "StatusPage"; diff --git a/packages/icons/src/nucleo/stop.tsx b/packages/icons/src/nucleo/stop.tsx new file mode 100644 index 000000000..ac7423970 --- /dev/null +++ b/packages/icons/src/nucleo/stop.tsx @@ -0,0 +1,33 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (MediaStop); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Stop = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + ), +); +Stop.displayName = "Stop"; diff --git a/packages/icons/src/nucleo/success.tsx b/packages/icons/src/nucleo/success.tsx new file mode 100644 index 000000000..3f5502c83 --- /dev/null +++ b/packages/icons/src/nucleo/success.tsx @@ -0,0 +1,39 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (CircleCheck); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Success = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Success.displayName = "Success"; diff --git a/packages/icons/src/nucleo/support.tsx b/packages/icons/src/nucleo/support.tsx new file mode 100644 index 000000000..b6fb0f32a --- /dev/null +++ b/packages/icons/src/nucleo/support.tsx @@ -0,0 +1,124 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (LifeRing); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Support = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + + + + + + + + ), +); +Support.displayName = "Support"; diff --git a/packages/icons/src/nucleo/system.tsx b/packages/icons/src/nucleo/system.tsx new file mode 100644 index 000000000..cb6f1027d --- /dev/null +++ b/packages/icons/src/nucleo/system.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Laptop); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const System = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +System.displayName = "System"; diff --git a/packages/icons/src/nucleo/table-properties.tsx b/packages/icons/src/nucleo/table-properties.tsx new file mode 100644 index 000000000..b0412e7b4 --- /dev/null +++ b/packages/icons/src/nucleo/table-properties.tsx @@ -0,0 +1,45 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (TableCols2); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const TableProperties = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +TableProperties.displayName = "TableProperties"; diff --git a/packages/icons/src/nucleo/table.tsx b/packages/icons/src/nucleo/table.tsx new file mode 100644 index 000000000..8635b5b2f --- /dev/null +++ b/packages/icons/src/nucleo/table.tsx @@ -0,0 +1,55 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (TableRows3Cols2); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Table = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Table.displayName = "Table"; diff --git a/packages/icons/src/nucleo/tag.tsx b/packages/icons/src/nucleo/tag.tsx new file mode 100644 index 000000000..7c9db6f43 --- /dev/null +++ b/packages/icons/src/nucleo/tag.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Tag); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Tag = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Tag.displayName = "Tag"; diff --git a/packages/icons/src/nucleo/team.tsx b/packages/icons/src/nucleo/team.tsx new file mode 100644 index 000000000..8a006b65d --- /dev/null +++ b/packages/icons/src/nucleo/team.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Users); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Team = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + ), +); +Team.displayName = "Team"; diff --git a/packages/icons/src/nucleo/terminal.tsx b/packages/icons/src/nucleo/terminal.tsx new file mode 100644 index 000000000..591da4b8d --- /dev/null +++ b/packages/icons/src/nucleo/terminal.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Terminal); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Terminal = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Terminal.displayName = "Terminal"; diff --git a/packages/icons/src/nucleo/theme.tsx b/packages/icons/src/nucleo/theme.tsx new file mode 100644 index 000000000..9e95a3274 --- /dev/null +++ b/packages/icons/src/nucleo/theme.tsx @@ -0,0 +1,57 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Palette); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Theme = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Theme.displayName = "Theme"; diff --git a/packages/icons/src/nucleo/trend.tsx b/packages/icons/src/nucleo/trend.tsx new file mode 100644 index 000000000..affce110c --- /dev/null +++ b/packages/icons/src/nucleo/trend.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (ArrowTrendUp); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Trend = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Trend.displayName = "Trend"; diff --git a/packages/icons/src/nucleo/unlinked.tsx b/packages/icons/src/nucleo/unlinked.tsx new file mode 100644 index 000000000..21567e9bf --- /dev/null +++ b/packages/icons/src/nucleo/unlinked.tsx @@ -0,0 +1,61 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (Link2Slash); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Unlinked = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + + + ), +); +Unlinked.displayName = "Unlinked"; diff --git a/packages/icons/src/nucleo/unlock.tsx b/packages/icons/src/nucleo/unlock.tsx new file mode 100644 index 000000000..5125d5c1c --- /dev/null +++ b/packages/icons/src/nucleo/unlock.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (LockOpen); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Unlock = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Unlock.displayName = "Unlock"; diff --git a/packages/icons/src/nucleo/warning.tsx b/packages/icons/src/nucleo/warning.tsx new file mode 100644 index 000000000..4f9e2ef6b --- /dev/null +++ b/packages/icons/src/nucleo/warning.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (TriangleWarning); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Warning = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + + ), +); +Warning.displayName = "Warning"; diff --git a/packages/icons/src/parity.ts b/packages/icons/src/parity.ts new file mode 100644 index 000000000..a3a75bd65 --- /dev/null +++ b/packages/icons/src/parity.ts @@ -0,0 +1,16 @@ +import type * as Lucide from "./lucide"; +import type * as Nucleo from "./nucleo"; + +// Both icon sets must expose identical names so the ICON_SET build alias +// can swap them freely. A missing icon fails compilation here. +type MissingInNucleo = Exclude; +type MissingInLucide = Exclude; + +export type AssertIconParity = [MissingInNucleo, MissingInLucide] extends [ + never, + never, +] + ? true + : { missingInNucleo: MissingInNucleo; missingInLucide: MissingInLucide }; + +export const assertIconParity: AssertIconParity = true; diff --git a/packages/icons/src/types.ts b/packages/icons/src/types.ts new file mode 100644 index 000000000..edcc4208b --- /dev/null +++ b/packages/icons/src/types.ts @@ -0,0 +1,6 @@ +import type { LucideProps } from "lucide-react"; +import type { ComponentType } from "react"; + +export type IconProps = LucideProps; +// structural, so it matches lucide's forwardRef exotics and the vendored nucleo components alike +export type IconType = ComponentType; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9c5b56c3..846825f83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1127,6 +1127,9 @@ importers: '@openstatus/error': specifier: workspace:* version: link:../../packages/error + '@openstatus/icons': + specifier: workspace:* + version: link:../../packages/icons '@openstatus/locales': specifier: workspace:* version: link:../../packages/locales @@ -2061,6 +2064,9 @@ importers: packages/icons: dependencies: + lucide-react: + specifier: 'catalog:' + version: 0.525.0(react@19.2.6) react: specifier: 'catalog:' version: 19.2.6 -- 2.51.2 From b3817f2dad368bc53608582b6bcaa4524a7fd246 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:54:21 +0200 Subject: [PATCH 012/109] Remove Delete Account from dashboard command menu (#2419) * Remove Delete Account from dashboard command menu Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GUANeYicduydQzqLpikDEp * ci: apply automated fixes --------- Co-authored-by: Claude Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../src/components/command-menu/config.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/apps/dashboard/src/components/command-menu/config.ts b/apps/dashboard/src/components/command-menu/config.ts index bd45b7e77..199e0819b 100644 --- a/apps/dashboard/src/components/command-menu/config.ts +++ b/apps/dashboard/src/components/command-menu/config.ts @@ -1,12 +1,4 @@ -import { - AtSign, - KeyRound, - Palette, - Pencil, - Plus, - Trash2, - Users, -} from "lucide-react"; +import { AtSign, KeyRound, Palette, Pencil, Plus, Users } from "lucide-react"; import type * as React from "react"; import { MONITOR_TABS } from "@/app/(dashboard)/monitors/[id]/constants"; @@ -113,11 +105,6 @@ const SETTINGS_ANCHORS: CommandLink[] = [ icon: Palette, keywords: ["theme", "dark mode"], }, - { - label: "Delete Account", - href: "/settings/account#delete-account", - icon: Trash2, - }, ]; export const SETTINGS: CommandLink[] = [ -- 2.51.2 From 60e0cbd0d95fe2c05926017ba82b03603e0bbf2a Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:41:42 +0200 Subject: [PATCH 013/109] chore: dashboard integrations api key note (#2420) --- .../app/(dashboard)/settings/integrations/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx index 0d6ae200b..4c9293c60 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx @@ -1,7 +1,11 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { KeyRound } from "lucide-react"; +import Link from "next/link"; +import { NoteButton } from "@/components/common/note"; +import { NoteDismissible } from "@/components/common/note-dismissible"; import { Section, SectionDescription, @@ -36,6 +40,13 @@ export default function Page() { Connect third-party services to your workspace. + + + Looking for your API keys? They live in the General settings page. + + Go API keys + + Date: Thu, 23 Jul 2026 22:00:32 +0200 Subject: [PATCH 014/109] chore: planned maintenance label (#2421) * chore: planned maintenance label * fix: ts * test: status label --- .../[locale]/(public)/feed/[type]/route.ts | 15 ++++--------- packages/emails/emails/status-report.tsx | 10 +++------ packages/emails/package.json | 1 + .../emails/src/client.integration.test.tsx | 22 +++++++++---------- packages/emails/src/client.tsx | 8 ++++--- .../src/channels/slack-blocks.ts | 13 +++-------- .../subscriptions/src/channels/webhook.ts | 6 ++--- packages/utils/package.json | 3 ++- packages/utils/src/index.ts | 1 + packages/utils/src/status.test.ts | 20 +++++++++++++++++ packages/utils/src/status.ts | 18 +++++++++++++++ pnpm-lock.yaml | 3 +++ 12 files changed, 74 insertions(+), 46 deletions(-) create mode 100644 packages/utils/src/status.test.ts create mode 100644 packages/utils/src/status.ts diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts index f8ea2279c..4208f5333 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts @@ -1,3 +1,4 @@ +import { statusLabel } from "@openstatus/utils"; import { Feed } from "feed"; import { notFound, unauthorized } from "next/navigation"; @@ -5,14 +6,6 @@ import { auth } from "../../../../../../../lib/auth"; import { getBaseUrl } from "../../../../../../../lib/base-url"; import { getQueryClient, trpc } from "../../../../../../../lib/trpc/server"; -const STATUS_LABELS = { - investigating: "Investigating", - identified: "Identified", - monitoring: "Monitoring", - resolved: "Resolved", - maintenance: "Maintenance", -} as const; - export const revalidate = 60; export async function GET( @@ -90,7 +83,7 @@ export async function GET( const maintenanceUrl = `${baseUrl}/events/maintenance/${maintenance.id}`; feed.addItem({ id: maintenanceUrl, - title: `Maintenance - ${maintenance.title}`, + title: `${statusLabel("maintenance")} - ${maintenance.title}`, link: maintenanceUrl, description: maintenance.message, date: maintenance.updatedAt ?? maintenance.createdAt ?? new Date(), @@ -99,10 +92,10 @@ export async function GET( for (const statusReport of page.statusReports ?? []) { const statusReportUrl = `${baseUrl}/events/report/${statusReport.id}`; - const status = STATUS_LABELS[statusReport.status] ?? statusReport.status; + const status = statusLabel(statusReport.status); const statusReportUpdates = (statusReport.statusReportUpdates ?? []) .map((update) => { - const updateStatus = STATUS_LABELS[update.status] ?? update.status; + const updateStatus = statusLabel(update.status); return `${updateStatus}: ${update.message}.`; }) .join("\n\n"); diff --git a/packages/emails/emails/status-report.tsx b/packages/emails/emails/status-report.tsx index 0fe8ad192..91b8a2893 100644 --- a/packages/emails/emails/status-report.tsx +++ b/packages/emails/emails/status-report.tsx @@ -1,5 +1,6 @@ /** @jsxRuntime automatic @jsxImportSource react */ +import { statusLabel } from "@openstatus/utils"; import { Body, Column, @@ -76,13 +77,8 @@ function StatusReportEmail({ {pageTitle} - - {status} + + {statusLabel(status)} diff --git a/packages/emails/package.json b/packages/emails/package.json index 229b1c93a..fa7798ea9 100644 --- a/packages/emails/package.json +++ b/packages/emails/package.json @@ -17,6 +17,7 @@ "test": "deno test --parallel -A --no-check --sloppy-imports" }, "dependencies": { + "@openstatus/utils": "workspace:*", "@t3-oss/env-core": "catalog:", "effect": "catalog:", "react": "catalog:", diff --git a/packages/emails/src/client.integration.test.tsx b/packages/emails/src/client.integration.test.tsx index fde8e72d2..8829ab403 100644 --- a/packages/emails/src/client.integration.test.tsx +++ b/packages/emails/src/client.integration.test.tsx @@ -1,5 +1,6 @@ /** @jsxRuntime automatic @jsxImportSource react */ +import { statusLabel } from "@openstatus/utils"; import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; import { render } from "react-email"; @@ -94,20 +95,21 @@ describe("Status Report Email - Subject Line", () => { ); }); - test('does not prepend "RESOLVED:" for any non-resolved status', () => { - const nonResolved = [ - "investigating", - "identified", - "monitoring", - "maintenance", - ] as const; + test("uses the bare report title for ongoing report statuses", () => { + const ongoing = ["investigating", "identified", "monitoring"] as const; - for (const status of nonResolved) { + for (const status of ongoing) { const subject = statusReportSubject({ status, reportTitle }); expect(subject).toBe(reportTitle); expect(subject).not.toContain("RESOLVED:"); } }); + + test('prepends "Planned Maintenance:" when status is "maintenance"', () => { + expect(statusReportSubject({ status: "maintenance", reportTitle })).toBe( + `Planned Maintenance: ${reportTitle}`, + ); + }); }); describe("Status Report Email - Email Content Validation", () => { @@ -156,9 +158,7 @@ describe("Status Report Email - Email Content Validation", () => { />, ); - // Should render without errors and contain the status - // Note: status is rendered lowercase in HTML with text-transform: uppercase CSS - expect(html).toContain(status); + expect(html).toContain(statusLabel(status)); } }); }); diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index d14664815..074709ec6 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -1,5 +1,6 @@ /** @jsxRuntime automatic @jsxImportSource react */ +import { statusLabel } from "@openstatus/utils"; import { type Duration, Effect, Schedule } from "effect"; import { render } from "react-email"; import { Resend } from "resend"; @@ -23,9 +24,10 @@ export function statusReportSubject(req: { status: StatusReportProps["status"]; reportTitle: string; }): string { - return req.status === "resolved" - ? `RESOLVED: ${req.reportTitle}` - : req.reportTitle; + if (req.status === "resolved") return `RESOLVED: ${req.reportTitle}`; + if (req.status === "maintenance") + return `${statusLabel("maintenance")}: ${req.reportTitle}`; + return req.reportTitle; } // Deterministic Resend rejections: retrying the identical request can never diff --git a/packages/subscriptions/src/channels/slack-blocks.ts b/packages/subscriptions/src/channels/slack-blocks.ts index be418d372..ad843de8d 100644 --- a/packages/subscriptions/src/channels/slack-blocks.ts +++ b/packages/subscriptions/src/channels/slack-blocks.ts @@ -1,4 +1,5 @@ import { COLORS } from "@openstatus/notification-base"; +import { statusLabel } from "@openstatus/utils"; import type { KnownBlock, MessageAttachment } from "@slack/web-api"; import type { PageUpdate, Subscription } from "../types"; @@ -13,14 +14,6 @@ const STATUS_EMOJI: Record = { maintenance: "🔧", }; -const STATUS_LABEL: Record = { - investigating: "Investigating", - identified: "Identified", - monitoring: "Monitoring", - resolved: "Resolved", - maintenance: "Maintenance", -}; - function statusColor(status: PageUpdate["status"]): StatusColor { switch (status) { case "investigating": @@ -61,7 +54,7 @@ export function buildRootMessage( subscription: Subscription, ): SlackRootMessage { const emoji = STATUS_EMOJI[pageUpdate.status]; - const label = STATUS_LABEL[pageUpdate.status]; + const label = statusLabel(pageUpdate.status); const origin = pageOrigin(subscription); const blocks: KnownBlock[] = [ @@ -130,7 +123,7 @@ export function buildRootMessage( export function buildReplyMessage(pageUpdate: PageUpdate): SlackReplyMessage { const emoji = STATUS_EMOJI[pageUpdate.status]; - const label = STATUS_LABEL[pageUpdate.status]; + const label = statusLabel(pageUpdate.status); const heading = `${emoji} *${label}* · ${pageUpdate.date}`; return { diff --git a/packages/subscriptions/src/channels/webhook.ts b/packages/subscriptions/src/channels/webhook.ts index c09212776..13cb72e52 100644 --- a/packages/subscriptions/src/channels/webhook.ts +++ b/packages/subscriptions/src/channels/webhook.ts @@ -1,5 +1,5 @@ import { COLORS, COLOR_DECIMALS } from "@openstatus/notification-base"; -import { assertSafeUrl } from "@openstatus/utils"; +import { assertSafeUrl, statusLabel } from "@openstatus/utils"; import { z } from "zod"; import { WEBHOOK_PAYLOAD_VERSION } from "../payload"; @@ -148,7 +148,7 @@ function buildSlackPayload( fields: [ { type: "mrkdwn", - text: `*Status*\n${pageUpdate.status}`, + text: `*Status*\n${statusLabel(pageUpdate.status)}`, }, { type: "mrkdwn", @@ -242,7 +242,7 @@ function buildDiscordPayload( fields: [ { name: "Status", - value: pageUpdate.status, + value: statusLabel(pageUpdate.status), inline: true, }, { diff --git a/packages/utils/package.json b/packages/utils/package.json index 23419d760..639732845 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -7,7 +7,8 @@ "author": "", "main": "src/index.ts", "scripts": { - "check": "deno check --sloppy-imports ." + "check": "deno check --sloppy-imports .", + "test": "deno test --parallel -A --no-check --sloppy-imports" }, "dependencies": { "@openstatus/assertions": "workspace:*", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 381c101d3..65ae7343a 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -13,6 +13,7 @@ export { MONITOR_JOB_TYPES, } from "./constants"; export { yieldMany, iteratorToStream } from "./stream"; +export { statusLabel, type PageUpdateStatus } from "./status"; export function transformHeaders(headers: { key: string; value: string }[]) { return headers.length > 0 diff --git a/packages/utils/src/status.test.ts b/packages/utils/src/status.test.ts new file mode 100644 index 000000000..7e1eae0b9 --- /dev/null +++ b/packages/utils/src/status.test.ts @@ -0,0 +1,20 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { type PageUpdateStatus, statusLabel } from "./status"; + +describe("statusLabel", () => { + const labels: Record = { + investigating: "Investigating", + identified: "Identified", + monitoring: "Monitoring", + resolved: "Resolved", + maintenance: "Planned Maintenance", + }; + + for (const [status, label] of Object.entries(labels)) { + it(`labels ${status} as "${label}"`, () => { + expect(statusLabel(status as PageUpdateStatus)).toBe(label); + }); + } +}); diff --git a/packages/utils/src/status.ts b/packages/utils/src/status.ts new file mode 100644 index 000000000..21b8a18e9 --- /dev/null +++ b/packages/utils/src/status.ts @@ -0,0 +1,18 @@ +export type PageUpdateStatus = + | "investigating" + | "identified" + | "monitoring" + | "resolved" + | "maintenance"; + +const STATUS_LABEL: Record = { + investigating: "Investigating", + identified: "Identified", + monitoring: "Monitoring", + resolved: "Resolved", + maintenance: "Planned Maintenance", +}; + +export function statusLabel(status: PageUpdateStatus): string { + return STATUS_LABEL[status]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 846825f83..a6804644b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1993,6 +1993,9 @@ importers: packages/emails: dependencies: + '@openstatus/utils': + specifier: workspace:* + version: link:../utils '@t3-oss/env-core': specifier: 'catalog:' version: 0.13.11(typescript@5.9.3)(zod@4.1.13) -- 2.51.2 From 1b11553f1539b8a7e96b45d0cc125c1502cb9c5c Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:22:37 +0200 Subject: [PATCH 015/109] chore: add more dashboard cmdk actions (#2422) * chore: add more dashboard cmdk actions * refactor: improve structure * fix: format * fix: review * chore: minor stuff --- .../components/command-menu/command-menu.tsx | 379 ++++++------------ .../src/components/command-menu/config.ts | 141 ------- .../components/command-menu/group-view.tsx | 48 +++ .../command-menu/groups/entities.ts | 109 +++++ .../command-menu/groups/monitor-scope.ts | 19 + .../components/command-menu/groups/static.ts | 161 ++++++++ .../command-menu/groups/status-page-scope.ts | 82 ++++ .../src/components/command-menu/types.ts | 48 +++ .../command-menu/use-command-menu-data.ts | 66 +++ .../forms/maintenance/sheet-create.tsx | 8 +- .../status-report-update/sheet-create.tsx | 8 +- .../forms/status-report-update/sheet.tsx | 12 +- .../forms/status-report/sheet-create.tsx | 8 +- 13 files changed, 683 insertions(+), 406 deletions(-) delete mode 100644 apps/dashboard/src/components/command-menu/config.ts create mode 100644 apps/dashboard/src/components/command-menu/group-view.tsx create mode 100644 apps/dashboard/src/components/command-menu/groups/entities.ts create mode 100644 apps/dashboard/src/components/command-menu/groups/monitor-scope.ts create mode 100644 apps/dashboard/src/components/command-menu/groups/static.ts create mode 100644 apps/dashboard/src/components/command-menu/groups/status-page-scope.ts create mode 100644 apps/dashboard/src/components/command-menu/types.ts create mode 100644 apps/dashboard/src/components/command-menu/use-command-menu-data.ts diff --git a/apps/dashboard/src/components/command-menu/command-menu.tsx b/apps/dashboard/src/components/command-menu/command-menu.tsx index eddb7b085..bd544cf72 100644 --- a/apps/dashboard/src/components/command-menu/command-menu.tsx +++ b/apps/dashboard/src/components/command-menu/command-menu.tsx @@ -1,11 +1,9 @@ "use client"; -import { Close, Dark, Light, Search } from "@openstatus/icons"; +import { Close, Search } from "@openstatus/icons"; import { Command, CommandEmpty, - CommandGroup, - CommandItem, CommandList, CommandLoading, } from "@openstatus/ui/components/ui/command"; @@ -14,94 +12,61 @@ import { DialogContent, DialogTitle, } from "@openstatus/ui/components/ui/dialog"; -import { useQuery } from "@tanstack/react-query"; import { Command as CommandPrimitive } from "cmdk"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; import * as React from "react"; import { FormSheetMaintenanceCreate } from "@/components/forms/maintenance/sheet-create"; +import { FormSheetStatusReportUpdateCreate } from "@/components/forms/status-report-update/sheet-create"; import { FormSheetStatusReportCreate } from "@/components/forms/status-report/sheet-create"; import { FormDialogSupportContact } from "@/components/forms/support-contact/dialog"; import { scrollToHash, useScrollToHash } from "@/hooks/use-scroll-to-hash"; -import { useTRPC } from "@/lib/trpc/client"; import { switchWorkspace } from "@/lib/workspace-cookie"; +import { GroupView } from "./group-view"; import { - type CommandLink, - type CommandPage, - type CommandSheetAction, - CREATE_ACTIONS, - CREATE_LINKS, - HELP_LINK_ITEMS, - HELP_SUPPORT_ACTION, - MONITOR_ACTIONS, - NAVIGATION, - SETTINGS, - STATUS_PAGE_ACTIONS, -} from "./config"; + maintenancesGroup, + monitorsGroup, + statusPagesGroup, + statusReportsGroup, + workspacesGroup, +} from "./groups/entities"; +import { monitorScopeGroups } from "./groups/monitor-scope"; +import { + createGroup, + helpGroup, + navigationGroup, + settingsGroup, + themeGroup, +} from "./groups/static"; +import { statusPageScopeGroups } from "./groups/status-page-scope"; import { useCommandMenu } from "./provider"; - -type ActiveSheet = CommandSheetAction["sheet"] | null; - -const IDLE_CAP = 5; - -function LinkItem({ - item, - onSelect, -}: { - item: CommandLink; - onSelect: (item: CommandLink) => void; -}) { - return ( - onSelect(item)} - > - - {item.label} - - ); -} - -function ActionItem({ - action, - onSelect, -}: { - action: CommandSheetAction; - onSelect: () => void; -}) { - return ( - - - {action.label} - - ); -} +import type { + CommandAction, + CommandMenuGroup, + CommandPage, + CommandSheet, +} from "./types"; +import { pageLabel } from "./types"; +import { useCommandMenuData } from "./use-command-menu-data"; export function CommandMenu() { const { open, setOpen } = useCommandMenu(); const [search, setSearch] = React.useState(""); const [pages, setPages] = React.useState([]); - const [activeSheet, setActiveSheet] = React.useState(null); + const [activeSheet, setActiveSheet] = React.useState( + null, + ); const [mounted, setMounted] = React.useState(false); const inputRef = React.useRef(null); const router = useRouter(); const { resolvedTheme, setTheme } = useTheme(); - const trpc = useTRPC(); useScrollToHash(); - const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); - const { data: statusPages } = useQuery(trpc.page.list.queryOptions()); - const { data: workspaces } = useQuery(trpc.workspace.list.queryOptions()); - const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const data = useCommandMenuData({ open }); const page = pages.length > 0 ? pages[pages.length - 1] : null; @@ -143,27 +108,60 @@ export function CommandMenu() { if (hashIndex !== -1) scrollToHash(href.slice(hashIndex + 1)); }; - const selectLink = (item: CommandLink) => { - if (item.external) { - window.open(item.href, "_blank", "noreferrer"); - setOpen(false); - return; - } - navigate(item.href); - }; - - const openSheet = (sheet: Exclude) => { + const openSheet = (next: CommandSheet) => { setOpen(false); // Defer so the palette Dialog releases focus + scroll lock before the // sheet/dialog claims them — otherwise Radix layers fight during overlap. - setTimeout(() => setActiveSheet(sheet), 0); + setTimeout(() => setActiveSheet(next), 0); }; - const idleCap = (items: T[]) => - search ? items : items.slice(0, IDLE_CAP); + const dispatch = (action: CommandAction) => { + switch (action.type) { + case "navigate": + navigate(action.href); + break; + case "external": + window.open(action.href, "_blank", "noreferrer"); + setOpen(false); + break; + case "sheet": + openSheet(action.sheet); + break; + case "push": + pushPage(action.page); + break; + case "run": + // Intentionally leaves the palette open (theme toggle, workspace switch). + action.run(); + break; + } + }; - const otherWorkspaces = - workspaces?.filter((w) => w.slug !== workspace?.slug) ?? []; + const groups: CommandMenuGroup[] = page + ? page.type === "monitor" + ? monitorScopeGroups(page) + : statusPageScopeGroups({ + page, + statusReports: data.sortedStatusReports, + maintenances: data.sortedMaintenances, + }) + : [ + monitorsGroup(data.monitors), + statusPagesGroup(data.statusPages), + statusReportsGroup(data.sortedStatusReports), + maintenancesGroup(data.sortedMaintenances, data.pageTitleById), + navigationGroup(), + createGroup(), + settingsGroup(), + workspacesGroup(data.otherWorkspaces, switchWorkspace), + helpGroup(), + ...(mounted ? [themeGroup(resolvedTheme, setTheme)] : []), + ].filter((g): g is CommandMenuGroup => g !== null); + + const reportForUpdate = + activeSheet?.sheet === "status-report-update" + ? data.statusReports?.find((r) => r.id === activeSheet.reportId) + : undefined; return ( <> @@ -191,7 +189,7 @@ export function CommandMenu() { {page ? ( - {page.type === "monitor" ? page.name : page.title} + {pageLabel(page)} + ) : ( + + + + )} + + + ); +} diff --git a/apps/dashboard/src/app/onboarding/page.tsx b/apps/dashboard/src/app/onboarding/page.tsx index 2d30beb0c..fbdbc731f 100644 --- a/apps/dashboard/src/app/onboarding/page.tsx +++ b/apps/dashboard/src/app/onboarding/page.tsx @@ -2,7 +2,7 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import type { SearchParams } from "nuqs"; -import { getQueryClient, trpc } from "@/lib/trpc/server"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; @@ -55,6 +55,7 @@ export default async function Page({ const [monitors, pages] = await Promise.all([ queryClient.fetchQuery(trpc.monitor.list.queryOptions()), queryClient.fetchQuery(trpc.page.list.queryOptions()), + queryClient.fetchQuery(trpc.apiKey.list.queryOptions()), ]); const updates: { @@ -89,5 +90,9 @@ export default async function Page({ redirect(`/onboarding?${next.toString()}`); } - return ; + return ( + + + + ); } diff --git a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx index 079ac235e..73c8072cb 100644 --- a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx +++ b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx @@ -14,7 +14,7 @@ import { QuickActions } from "@/components/dropdowns/quick-actions"; import { formatDate } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; -type ApiKey = RouterOutputs["apiKeyRouter"]["getAll"][number]; +type ApiKey = RouterOutputs["apiKey"]["list"][number]; /** * Map a key's `scopes` array to a single human label. v1 keys carry one @@ -47,7 +47,7 @@ export function DataTable({ }) { const trpc = useTRPC(); const revokeApiKeyMutation = useMutation( - trpc.apiKeyRouter.revoke.mutationOptions({ + trpc.apiKey.revoke.mutationOptions({ onSuccess: () => refetch(), }), ); diff --git a/apps/dashboard/src/components/forms/api-key/dialog.tsx b/apps/dashboard/src/components/forms/api-key/dialog.tsx new file mode 100644 index 000000000..25b9d442b --- /dev/null +++ b/apps/dashboard/src/components/forms/api-key/dialog.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { Check, Copy } from "@openstatus/icons"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@openstatus/ui/components/ui/alert-dialog"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@openstatus/ui/components/ui/dialog"; +import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import { useTRPC } from "@/lib/trpc/client"; + +import { ApiKeyForm, type FormValues } from "./form"; + +export function CreateApiKeyDialog({ + children, + defaultScope = "read", +}: { + children: React.ReactNode; + defaultScope?: FormValues["scope"]; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { copy, isCopied } = useCopyToClipboard(); + const [result, setResult] = useState<{ + token: string; + key: string; + } | null>(null); + const [open, setOpen] = useState(false); + + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const createApiKeyMutation = useMutation( + trpc.apiKey.create.mutationOptions(), + ); + + return ( + <> + + {children} + { + event.preventDefault(); + document.body.style.pointerEvents = ""; + }} + > + + Create API Key + + Create a new API key to access your workspace data. + + + setOpen(false)} + onSubmit={async (values) => { + const data = await createApiKeyMutation.mutateAsync({ + name: values.name.trim(), + description: values.description?.trim() || undefined, + expiresAt: values.expiresAt + ? new Date(values.expiresAt) + : undefined, + scopes: [values.scope], + }); + if (!data) { + throw new Error("Failed to create API key"); + } + queryClient.invalidateQueries({ + queryKey: trpc.apiKey.list.queryKey(), + }); + setResult({ token: data.token, key: data.key.name }); + setOpen(false); + }} + /> + + + setResult(null)}> + { + event.preventDefault(); + document.body.style.pointerEvents = ""; + }} + > + + API Key Created + + Ensure you copy your API key before closing this dialog. You will + not see it again. + + +
+ +
+ + + +
+
+ + ); +} diff --git a/apps/dashboard/src/components/forms/api-key/form.tsx b/apps/dashboard/src/components/forms/api-key/form.tsx new file mode 100644 index 000000000..d8db436fa --- /dev/null +++ b/apps/dashboard/src/components/forms/api-key/form.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Calendar as CalendarIcon } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { Calendar } from "@openstatus/ui/components/ui/calendar"; +import { DialogFooter } from "@openstatus/ui/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@openstatus/ui/components/ui/form"; +import { Input } from "@openstatus/ui/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@openstatus/ui/components/ui/popover"; +import { + RadioGroup, + RadioGroupItem, +} from "@openstatus/ui/components/ui/radio-group"; +import { Textarea } from "@openstatus/ui/components/ui/textarea"; +import { cn } from "@openstatus/ui/lib/utils"; +import { isTRPCClientError } from "@trpc/client"; +import { format, parse } from "date-fns"; +import { useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +export const schema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + expiresAt: z.string().optional(), + // Single-value radio. The wire format on the create-key API is + // an array (`scopes: Scope[]`) so per-resource scopes can land + // additively later, but the v1 dashboard surface only ever picks + // one of two options — flat enum here, lift to array on submit. + // + // Duplicates `apiKeySettableScopes` from `@openstatus/db` — the + // dashboard's client bundle can't reach into the db package + // (drizzle pulls in node-only deps via the schema barrel). The + // services input schema validates whatever the form sends, so + // drift here surfaces as a parse error on submit, not a silent + // mismatch. Keep this list in sync with + // `packages/db/src/schema/api-keys/constants.ts`. + scope: z.enum(["read", "write"]), +}); + +export type FormValues = z.infer; + +interface ApiKeyFormProps { + defaultValues?: Partial; + onSubmit: (values: FormValues) => Promise; + onCancel?: () => void; + submitDisabled?: boolean; +} + +export function ApiKeyForm({ + defaultValues, + onSubmit, + onCancel, + submitDisabled, +}: ApiKeyFormProps) { + const [isPending, startTransition] = useTransition(); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: defaultValues?.name ?? "", + description: defaultValues?.description ?? "", + expiresAt: defaultValues?.expiresAt ?? "", + // Default Read-only: AI agents are the most common new use + // case for keys, and read-only is the safer starting point. + // CI/CD users actively pick "Read & write." + scope: defaultValues?.scope ?? "read", + }, + }); + + function submitAction(values: FormValues) { + if (isPending) return; + + startTransition(async () => { + try { + const promise = onSubmit(values); + toast.promise(promise, { + loading: "Creating...", + success: () => "Created", + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "Failed to create API key"; + }, + }); + await promise; + form.reset(); + } catch (error) { + console.error(error); + } + }); + } + + return ( + + +
+ ( + + Name + + + + + + )} + /> + ( + + Description + +