mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
Port IMS telemetry into Primora conventions: - core.components/core.events (migration 00004 + sqlc) scoped to projects - POST /api/v1/ingest — single or <=500-event batches via project prm_ keys (X-Primora-Key alias), auto-registering components, permissive CORS - Issues: fingerprint-grouped errors with count/severity/first+last seen - Reads: events w/ filters + before-cursor, components, stats (1h/24h/7d/30d bucketed series + component health + totals + metric names), metric series (avg/p50/p95/max) - GET .../telemetry/stream — project-scoped SSE through an in-process hub (buffered subs, drop-on-slow, 25s keepalives); ?api_key=/?token= auth for EventSource - Telemetry page: health/issues/events/metrics/integrate tabs, live badge, 390px-clean; demo stubs - @primora/client SDK (createPrimoraClient: capture*/heartbeat/installAuto, queued keepalive flushes) - MCP: primora_list_issues/list_events/telemetry_stats/metric_series - IMS README deprecation note pointing here Also fixes workspace pins to 0.3.0 — npm ci in the frontend image was resolving the unpublished 0.2.0 spec from the registry. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1023 lines
34 KiB
TypeScript
1023 lines
34 KiB
TypeScript
/**
|
|
* Demo mode — fully client-side workspace simulation.
|
|
* Enabled via VITE_DEMO_MODE=true, ?demo=true, or the "Try demo mode" action.
|
|
*/
|
|
|
|
import {
|
|
OrganizationInvitation,
|
|
OrganizationMember,
|
|
ProjectMember,
|
|
} from "@primora/api-client";
|
|
import type {
|
|
ApiKey,
|
|
AuditLog,
|
|
Bucket,
|
|
BucketObject,
|
|
Collection,
|
|
DBConnection,
|
|
Document,
|
|
MeResponse,
|
|
MetricPoint,
|
|
OrganizationSummary,
|
|
ProjectOverview,
|
|
ProjectSummary,
|
|
TelemetryComponent,
|
|
TelemetryEvent,
|
|
TelemetryIssue,
|
|
TelemetryStats,
|
|
} from "@primora/api-client";
|
|
|
|
const STORAGE_KEY = "primora_demo_mode";
|
|
|
|
export const isDemoMode = () => {
|
|
if (import.meta.env.VITE_DEMO_MODE === "true") return true;
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get("demo") === "true") return true;
|
|
return localStorage.getItem(STORAGE_KEY) === "true";
|
|
};
|
|
|
|
export const enableDemoMode = () => {
|
|
localStorage.setItem(STORAGE_KEY, "true");
|
|
window.location.search = "?demo=true";
|
|
};
|
|
|
|
export const disableDemoMode = () => {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
window.location.href = window.location.pathname;
|
|
};
|
|
|
|
export const demoSession = {
|
|
user: {
|
|
id: "demo-user-1",
|
|
authSubject: "demo|demo-user-1",
|
|
email: "[email protected]",
|
|
name: "Demo User",
|
|
emailVerified: true,
|
|
},
|
|
session: {
|
|
id: "demo-session-1",
|
|
userId: "demo-user-1",
|
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
token: "demo-token",
|
|
ipAddress: "127.0.0.1",
|
|
userAgent: navigator.userAgent,
|
|
},
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* mutable demo state — mutations actually persist for the session */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const day = 24 * 60 * 60 * 1000;
|
|
const ago = (days: number) => new Date(Date.now() - days * day).toISOString();
|
|
const inDays = (days: number) => new Date(Date.now() + days * day).toISOString();
|
|
|
|
let organizations: OrganizationSummary[] = [
|
|
{
|
|
id: "org-1",
|
|
name: "Acme Corporation",
|
|
slug: "acme-corp",
|
|
membershipRole: "owner",
|
|
projects: [
|
|
{
|
|
id: "proj-1",
|
|
name: "Production API",
|
|
slug: "production-api",
|
|
description: "Main production API service",
|
|
membershipRole: "admin",
|
|
},
|
|
{
|
|
id: "proj-2",
|
|
name: "Mobile App",
|
|
slug: "mobile-app",
|
|
description: "iOS and Android application",
|
|
membershipRole: "developer",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
let orgMembers: OrganizationMember[] = [
|
|
{
|
|
user_id: "demo-user-1",
|
|
name: "Demo User",
|
|
email: "[email protected]",
|
|
email_verified: true,
|
|
role: OrganizationMember.role.OWNER,
|
|
joined_at: ago(90),
|
|
},
|
|
{
|
|
user_id: "user-2",
|
|
name: "Alice Johnson",
|
|
email: "[email protected]",
|
|
email_verified: true,
|
|
role: OrganizationMember.role.ADMIN,
|
|
joined_at: ago(60),
|
|
},
|
|
{
|
|
user_id: "user-3",
|
|
name: "Bob Smith",
|
|
email: "[email protected]",
|
|
email_verified: false,
|
|
role: OrganizationMember.role.MEMBER,
|
|
joined_at: ago(30),
|
|
},
|
|
];
|
|
|
|
let invitations: OrganizationInvitation[] = [
|
|
{
|
|
id: "inv-1",
|
|
organization_id: "org-1",
|
|
email: "[email protected]",
|
|
org_role: OrganizationInvitation.org_role.MEMBER,
|
|
project_id: null,
|
|
project_name: null,
|
|
project_role: null,
|
|
status: OrganizationInvitation.status.PENDING,
|
|
expires_at: inDays(7),
|
|
created_at: ago(2),
|
|
invited_by_user_id: "demo-user-1",
|
|
accepted_at: null,
|
|
},
|
|
{
|
|
id: "inv-2",
|
|
organization_id: "org-1",
|
|
email: "[email protected]",
|
|
org_role: OrganizationInvitation.org_role.MEMBER,
|
|
project_id: "proj-1",
|
|
project_name: "Production API",
|
|
project_role: OrganizationInvitation.project_role.DEVELOPER,
|
|
status: OrganizationInvitation.status.PENDING,
|
|
expires_at: inDays(5),
|
|
created_at: ago(1),
|
|
invited_by_user_id: "demo-user-1",
|
|
accepted_at: null,
|
|
},
|
|
];
|
|
|
|
let projectMembers: ProjectMember[] = [
|
|
{
|
|
user_id: "demo-user-1",
|
|
name: "Demo User",
|
|
email: "[email protected]",
|
|
email_verified: true,
|
|
role: ProjectMember.role.ADMIN,
|
|
joined_at: ago(90),
|
|
},
|
|
{
|
|
user_id: "user-2",
|
|
name: "Alice Johnson",
|
|
email: "[email protected]",
|
|
email_verified: true,
|
|
role: ProjectMember.role.DEVELOPER,
|
|
joined_at: ago(60),
|
|
},
|
|
{
|
|
user_id: "user-3",
|
|
name: "Bob Smith",
|
|
email: "[email protected]",
|
|
email_verified: false,
|
|
role: ProjectMember.role.VIEWER,
|
|
joined_at: ago(30),
|
|
},
|
|
];
|
|
|
|
/** Shape mirrors the better-auth admin `listUsers` response. */
|
|
export interface DemoAuthUser {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
emailVerified: boolean;
|
|
role: string;
|
|
banned: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
let authUsers: DemoAuthUser[] = [
|
|
{ id: "demo-user-1", name: "Demo User", email: "[email protected]", emailVerified: true, role: "admin", banned: false, createdAt: ago(90) },
|
|
{ id: "user-2", name: "Alice Johnson", email: "[email protected]", emailVerified: true, role: "user", banned: false, createdAt: ago(60) },
|
|
{ id: "user-3", name: "Bob Smith", email: "[email protected]", emailVerified: false, role: "user", banned: false, createdAt: ago(30) },
|
|
{ id: "user-4", name: "Carol White", email: "[email protected]", emailVerified: true, role: "user", banned: true, createdAt: ago(14) },
|
|
];
|
|
|
|
let apiKeys: ApiKey[] = [
|
|
{
|
|
id: "key-1",
|
|
project_id: "proj-1",
|
|
name: "Production key",
|
|
prefix: "pk_live_7f2a",
|
|
last_used_at: ago(0.2),
|
|
revoked_at: null,
|
|
},
|
|
{
|
|
id: "key-2",
|
|
project_id: "proj-1",
|
|
name: "CI deploy",
|
|
prefix: "pk_test_9c41",
|
|
last_used_at: ago(2),
|
|
revoked_at: null,
|
|
},
|
|
{
|
|
id: "key-3",
|
|
project_id: "proj-1",
|
|
name: "Old integration",
|
|
prefix: "pk_old_11be",
|
|
last_used_at: ago(45),
|
|
revoked_at: ago(7),
|
|
},
|
|
];
|
|
|
|
let buckets: Bucket[] = [
|
|
{ id: "bucket-1", project_id: "proj-1", name: "User Avatars", slug: "avatars", visibility: "public" },
|
|
{ id: "bucket-2", project_id: "proj-1", name: "Documents", slug: "documents", visibility: "private" },
|
|
{ id: "bucket-3", project_id: "proj-1", name: "Media Files", slug: "media", visibility: "public" },
|
|
];
|
|
|
|
let objects: BucketObject[] = [
|
|
{ id: "obj-1", bucket_id: "bucket-1", object_key: "profile/user-123.jpg", content_type: "image/jpeg", size_bytes: 245678, checksum_sha256: "a1b2c3", created_at: ago(30) },
|
|
{ id: "obj-2", bucket_id: "bucket-1", object_key: "profile/user-456.png", content_type: "image/png", size_bytes: 189234, checksum_sha256: "d4e5f6", created_at: ago(25) },
|
|
{ id: "obj-3", bucket_id: "bucket-1", object_key: "documents/report-2024.pdf", content_type: "application/pdf", size_bytes: 1234567, checksum_sha256: "789abc", created_at: ago(20) },
|
|
{ id: "obj-4", bucket_id: "bucket-1", object_key: "config/settings.json", content_type: "application/json", size_bytes: 4567, checksum_sha256: "def012", created_at: ago(15) },
|
|
{ id: "obj-5", bucket_id: "bucket-1", object_key: "docs/README.md", content_type: "text/markdown", size_bytes: 2048, checksum_sha256: "345fed", created_at: ago(3) },
|
|
];
|
|
|
|
let auditLogs: AuditLog[] = [
|
|
{
|
|
id: "log-1",
|
|
created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
|
action: "object.uploaded",
|
|
resource_type: "object",
|
|
resource_id: "profile/user-123.jpg",
|
|
request_id: "req-abc123",
|
|
metadata: { bucket: "avatars", size_bytes: 245678, actor: "[email protected]" },
|
|
},
|
|
{
|
|
id: "log-2",
|
|
created_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(),
|
|
action: "api_key.created",
|
|
resource_type: "api_key",
|
|
resource_id: "key-2",
|
|
request_id: "req-def456",
|
|
metadata: { name: "CI deploy", actor: "[email protected]" },
|
|
},
|
|
{
|
|
id: "log-3",
|
|
created_at: ago(1),
|
|
action: "member.invited",
|
|
resource_type: "invitation",
|
|
resource_id: "inv-1",
|
|
request_id: "req-ghi789",
|
|
metadata: { email: "[email protected]", role: "member", actor: "[email protected]" },
|
|
},
|
|
{
|
|
id: "log-4",
|
|
created_at: ago(2),
|
|
action: "bucket.created",
|
|
resource_type: "bucket",
|
|
resource_id: "bucket-3",
|
|
request_id: "req-jkl012",
|
|
metadata: { slug: "media", visibility: "public", actor: "[email protected]" },
|
|
},
|
|
{
|
|
id: "log-5",
|
|
created_at: ago(3),
|
|
action: "api_key.revoked",
|
|
resource_type: "api_key",
|
|
resource_id: "key-3",
|
|
request_id: "req-mno345",
|
|
metadata: { name: "Old integration", actor: "[email protected]" },
|
|
},
|
|
];
|
|
|
|
let collections: Collection[] = [
|
|
{
|
|
id: "col-1",
|
|
project_id: "proj-1",
|
|
name: "Posts",
|
|
slug: "posts",
|
|
description: "Blog posts and articles",
|
|
schema: {},
|
|
created_at: ago(40),
|
|
updated_at: ago(2),
|
|
},
|
|
{
|
|
id: "col-2",
|
|
project_id: "proj-1",
|
|
name: "Feature flags",
|
|
slug: "feature-flags",
|
|
description: null,
|
|
schema: {},
|
|
created_at: ago(20),
|
|
updated_at: ago(1),
|
|
},
|
|
];
|
|
|
|
let documents: Document[] = [
|
|
{
|
|
id: "doc-1",
|
|
collection_id: "col-1",
|
|
data: { title: "Hello Primora", status: "published", views: 1240 },
|
|
created_at: ago(10),
|
|
updated_at: ago(1),
|
|
},
|
|
{
|
|
id: "doc-2",
|
|
collection_id: "col-1",
|
|
data: { title: "Draft: storage guide", status: "draft", views: 0 },
|
|
created_at: ago(5),
|
|
updated_at: ago(5),
|
|
},
|
|
];
|
|
|
|
let dbConnections: DBConnection[] = [
|
|
{
|
|
id: "dbc-1",
|
|
project_id: "proj-1",
|
|
name: "platform-postgres",
|
|
db_type: "postgres",
|
|
host: "postgres",
|
|
port: 5432,
|
|
database: "primora",
|
|
username: "primora",
|
|
ssl: null,
|
|
is_managed: true,
|
|
has_password: true,
|
|
},
|
|
{
|
|
id: "dbc-2",
|
|
project_id: "proj-1",
|
|
name: "platform-dragonfly",
|
|
db_type: "redis",
|
|
host: "dragonfly",
|
|
port: 6379,
|
|
database: "",
|
|
username: "",
|
|
ssl: null,
|
|
is_managed: true,
|
|
has_password: false,
|
|
},
|
|
];
|
|
|
|
const demoComponents: TelemetryComponent[] = [
|
|
{ id: "cmp-1", name: "web", kind: "frontend", last_seen_at: ago(0.02), last_status: "up", errors_24h: 0, events_24h: 88, meta: {} },
|
|
{ id: "cmp-2", name: "api", kind: "backend", last_seen_at: ago(0.014), last_status: "degraded", errors_24h: 4, events_24h: 120, meta: {} },
|
|
{ id: "cmp-3", name: "worker", kind: "backend", last_seen_at: ago(0.03), last_status: "up", errors_24h: 0, events_24h: 60, meta: {} },
|
|
];
|
|
|
|
const demoEventsSeed: TelemetryEvent[] = [
|
|
{ id: 42, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "error", severity: "error", message: "TypeError: Cannot read properties of undefined (reading 'id')", payload: { stack: "at getUser (api/src/users.ts:41)" }, fingerprint: "a1b2c3d4e5f60718", ts: ago(0.013) },
|
|
{ id: 41, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "metric", severity: "info", message: "", payload: { name: "http.req.ms", value: 132, route: "/projects" }, fingerprint: "", ts: ago(0.02) },
|
|
{ id: 40, project_id: "proj-1", component_id: "cmp-1", component_name: "web", type: "log", severity: "warn", message: "Slow route render: /settings", payload: { ms: 940 }, fingerprint: "", ts: ago(0.03) },
|
|
{ id: 39, project_id: "proj-1", component_id: "cmp-3", component_name: "worker", type: "heartbeat", severity: "info", message: "", payload: { status: "up" }, fingerprint: "", ts: ago(0.04) },
|
|
{ id: 38, project_id: "proj-1", component_id: "cmp-1", component_name: "web", type: "event", severity: "info", message: "checkout.completed", payload: { total: 129 }, fingerprint: "", ts: ago(0.06) },
|
|
{ id: 37, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "error", severity: "error", message: "TypeError: Cannot read properties of undefined (reading 'id')", payload: { stack: "at getUser (api/src/users.ts:41)" }, fingerprint: "a1b2c3d4e5f60718", ts: ago(3.5) },
|
|
];
|
|
|
|
let demoEvents: TelemetryEvent[] = [...demoEventsSeed];
|
|
|
|
const demoTables = [
|
|
"users",
|
|
"organizations",
|
|
"projects",
|
|
"api_keys",
|
|
"audit_logs",
|
|
"buckets",
|
|
"bucket_objects",
|
|
"collections",
|
|
"documents",
|
|
];
|
|
|
|
const demoColumns: Record<string, string[][]> = {
|
|
users: [
|
|
["id", "uuid", "NO", "PK"],
|
|
["email", "text", "NO", "UNIQUE"],
|
|
["name", "text", "YES", ""],
|
|
["created_at", "timestamptz", "NO", ""],
|
|
],
|
|
projects: [
|
|
["id", "uuid", "NO", "PK"],
|
|
["organization_id", "uuid", "NO", "FK"],
|
|
["name", "text", "NO", ""],
|
|
["slug", "text", "NO", "UNIQUE"],
|
|
],
|
|
};
|
|
|
|
const pushAudit = (action: string, resourceType: string, resourceId: string, metadata: Record<string, unknown> = {}) => {
|
|
auditLogs = [
|
|
{
|
|
id: `log-${Date.now()}`,
|
|
created_at: new Date().toISOString(),
|
|
action,
|
|
resource_type: resourceType,
|
|
resource_id: resourceId,
|
|
request_id: `req-demo-${Math.random().toString(36).slice(2, 8)}`,
|
|
metadata: { actor: "[email protected]", ...metadata },
|
|
},
|
|
...auditLogs,
|
|
];
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* demo service — same call shapes the app expects from the real API */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
class DemoService {
|
|
private delay(ms = 250) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async getMe(): Promise<MeResponse> {
|
|
await this.delay();
|
|
return { user: demoSession.user, organizations };
|
|
}
|
|
|
|
async getProjectOverview(): Promise<ProjectOverview> {
|
|
await this.delay();
|
|
const project = organizations[0].projects[0];
|
|
return {
|
|
project_id: project.id,
|
|
organization_id: organizations[0].id,
|
|
project_slug: project.slug,
|
|
project_name: project.name,
|
|
member_count: projectMembers.length,
|
|
active_api_key_count: apiKeys.filter((k) => !k.revoked_at).length,
|
|
bucket_count: buckets.length,
|
|
object_count: objects.length,
|
|
object_bytes_total: objects.reduce((sum, o) => sum + o.size_bytes, 0),
|
|
pending_invitation_count: invitations.filter((i) => i.status === OrganizationInvitation.status.PENDING).length,
|
|
audit_events_24h: auditLogs.length,
|
|
last_audit_at: auditLogs[0]?.created_at ?? null,
|
|
};
|
|
}
|
|
|
|
/* list endpoints return the unwrapped items array — mirrors `.then(r => r.items)` */
|
|
async listOrganizationMembers() { await this.delay(); return orgMembers; }
|
|
async listOrganizationInvitations() { await this.delay(); return invitations; }
|
|
async listProjectMembers() { await this.delay(); return projectMembers; }
|
|
async listApiKeys() { await this.delay(); return apiKeys; }
|
|
async listBuckets() { await this.delay(); return buckets; }
|
|
async listProjects() {
|
|
await this.delay();
|
|
return organizations[0].projects.map((p) => ({
|
|
id: p.id,
|
|
organization_id: organizations[0].id,
|
|
slug: p.slug,
|
|
name: p.name,
|
|
description: p.description ?? null,
|
|
membership_role: p.membershipRole ?? null,
|
|
}));
|
|
}
|
|
async listAuthUsers() { await this.delay(); return authUsers; }
|
|
async listCollections() { await this.delay(); return collections; }
|
|
async listDocuments() { await this.delay(); return documents; }
|
|
|
|
/* paged endpoints return the full page envelope */
|
|
async listBucketObjects() {
|
|
await this.delay();
|
|
return { items: objects, total: objects.length, limit: 25, offset: 0, has_more: false };
|
|
}
|
|
|
|
async listAuditLogs() {
|
|
await this.delay();
|
|
return { items: auditLogs, total: auditLogs.length, limit: 25, offset: 0, has_more: false };
|
|
}
|
|
|
|
async downloadBucketObject() {
|
|
await this.delay();
|
|
return new Blob(
|
|
[`# Demo object\n\nThis file was served by Primora demo mode.\n`],
|
|
{ type: "text/markdown" },
|
|
);
|
|
}
|
|
|
|
/* ---------------- mutations — update in-memory state ------------- */
|
|
|
|
async setAuthUserRole(id: string, role: string) {
|
|
await this.delay();
|
|
const user = authUsers.find((u) => u.id === id);
|
|
if (user) user.role = role;
|
|
return user;
|
|
}
|
|
|
|
async setAuthUserBanned(id: string, banned: boolean) {
|
|
await this.delay();
|
|
const user = authUsers.find((u) => u.id === id);
|
|
if (user) user.banned = banned;
|
|
return user;
|
|
}
|
|
|
|
async removeAuthUser(id: string) {
|
|
await this.delay();
|
|
authUsers = authUsers.filter((u) => u.id !== id);
|
|
return {};
|
|
}
|
|
|
|
|
|
async createProject(data: { requestBody?: { name?: string; slug?: string; description?: string } }) {
|
|
await this.delay();
|
|
const project: ProjectSummary = {
|
|
id: `proj-${Date.now()}`,
|
|
name: data.requestBody?.name ?? "New project",
|
|
slug: data.requestBody?.slug ?? "new-project",
|
|
description: data.requestBody?.description ?? null,
|
|
membershipRole: "admin",
|
|
};
|
|
organizations[0].projects.push(project);
|
|
pushAudit("project.created", "project", project.id, { name: project.name });
|
|
return project;
|
|
}
|
|
|
|
async updateProject(data: { requestBody?: { name?: string; slug?: string; description?: string | null } }) {
|
|
await this.delay();
|
|
const project = organizations[0].projects[0];
|
|
if (data.requestBody?.name) project.name = data.requestBody.name;
|
|
if (data.requestBody?.slug) project.slug = data.requestBody.slug;
|
|
if (data.requestBody?.description !== undefined) project.description = data.requestBody.description;
|
|
pushAudit("project.updated", "project", project.id);
|
|
return project;
|
|
}
|
|
|
|
async deleteProject() {
|
|
await this.delay();
|
|
const removed = organizations[0].projects.pop();
|
|
if (removed) pushAudit("project.deleted", "project", removed.id, { name: removed.name });
|
|
return {};
|
|
}
|
|
|
|
async createOrganization(data: { requestBody?: { name?: string; slug?: string } }) {
|
|
await this.delay();
|
|
const org: OrganizationSummary = {
|
|
id: `org-${Date.now()}`,
|
|
name: data.requestBody?.name ?? "New organization",
|
|
slug: data.requestBody?.slug ?? "new-org",
|
|
membershipRole: "owner",
|
|
projects: [],
|
|
};
|
|
organizations.push(org);
|
|
return org;
|
|
}
|
|
|
|
async updateOrganization(data: { organizationId?: string; requestBody?: { name?: string; slug?: string } }) {
|
|
await this.delay();
|
|
const org = organizations.find((o) => o.id === data.organizationId) ?? organizations[0];
|
|
if (data.requestBody?.name) org.name = data.requestBody.name;
|
|
if (data.requestBody?.slug) org.slug = data.requestBody.slug;
|
|
return org;
|
|
}
|
|
|
|
async deleteOrganization(data: { organizationId?: string }) {
|
|
await this.delay();
|
|
organizations = organizations.filter((o) => o.id !== data.organizationId);
|
|
return {};
|
|
}
|
|
|
|
async createInvitation(data: { requestBody?: { email?: string; orgRole?: OrganizationInvitation["org_role"]; projectId?: string | null; projectRole?: string | null } }) {
|
|
await this.delay();
|
|
const inv: OrganizationInvitation = {
|
|
id: `inv-${Date.now()}`,
|
|
organization_id: organizations[0].id,
|
|
email: data.requestBody?.email ?? "[email protected]",
|
|
org_role: data.requestBody?.orgRole ?? OrganizationInvitation.org_role.MEMBER,
|
|
project_id: data.requestBody?.projectId ?? null,
|
|
project_name: null,
|
|
project_role: (data.requestBody?.projectRole as OrganizationInvitation["project_role"]) ?? null,
|
|
status: OrganizationInvitation.status.PENDING,
|
|
expires_at: inDays(7),
|
|
created_at: new Date().toISOString(),
|
|
invited_by_user_id: "demo-user-1",
|
|
accepted_at: null,
|
|
};
|
|
invitations = [inv, ...invitations];
|
|
pushAudit("member.invited", "invitation", inv.id, { email: inv.email });
|
|
return { ...inv, expiresAt: inv.expires_at };
|
|
}
|
|
|
|
async revokeInvitation(data: { invitationId?: string }) {
|
|
await this.delay();
|
|
invitations = invitations.map((i) =>
|
|
i.id === data.invitationId ? { ...i, status: OrganizationInvitation.status.EXPIRED } : i,
|
|
);
|
|
return {};
|
|
}
|
|
|
|
async acceptInvitation() { await this.delay(); return {}; }
|
|
|
|
async updateOrganizationMemberRole(data: { userId?: string; requestBody?: { role?: OrganizationMember["role"] } }) {
|
|
await this.delay();
|
|
orgMembers = orgMembers.map((m) =>
|
|
m.user_id === data.userId ? { ...m, role: data.requestBody?.role ?? m.role } : m,
|
|
);
|
|
pushAudit("member.role_updated", "member", data.userId ?? "");
|
|
return {};
|
|
}
|
|
|
|
async removeOrganizationMember(data: { userId?: string }) {
|
|
await this.delay();
|
|
orgMembers = orgMembers.filter((m) => m.user_id !== data.userId);
|
|
pushAudit("member.removed", "member", data.userId ?? "");
|
|
return {};
|
|
}
|
|
|
|
async updateProjectMemberRole(data: { userId?: string; requestBody?: { role?: ProjectMember["role"] } }) {
|
|
await this.delay();
|
|
projectMembers = projectMembers.map((m) =>
|
|
m.user_id === data.userId ? { ...m, role: data.requestBody?.role ?? m.role } : m,
|
|
);
|
|
pushAudit("member.role_updated", "member", data.userId ?? "");
|
|
return {};
|
|
}
|
|
|
|
async removeProjectMember(data: { userId?: string }) {
|
|
await this.delay();
|
|
projectMembers = projectMembers.filter((m) => m.user_id !== data.userId);
|
|
pushAudit("member.removed", "member", data.userId ?? "");
|
|
return {};
|
|
}
|
|
|
|
async createApiKey(data: { requestBody?: { name?: string } }) {
|
|
await this.delay();
|
|
const key: ApiKey = {
|
|
id: `key-${Date.now()}`,
|
|
project_id: "proj-1",
|
|
name: data.requestBody?.name ?? "New key",
|
|
prefix: `pk_demo_${Math.random().toString(36).slice(2, 6)}`,
|
|
last_used_at: null,
|
|
revoked_at: null,
|
|
};
|
|
apiKeys = [key, ...apiKeys];
|
|
pushAudit("api_key.created", "api_key", key.id, { name: key.name });
|
|
return { ...key, secret: `sk_demo_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}` };
|
|
}
|
|
|
|
async revokeApiKey(data: { apiKeyId?: string }) {
|
|
await this.delay();
|
|
apiKeys = apiKeys.map((k) =>
|
|
k.id === data.apiKeyId ? { ...k, revoked_at: new Date().toISOString() } : k,
|
|
);
|
|
pushAudit("api_key.revoked", "api_key", data.apiKeyId ?? "");
|
|
return {};
|
|
}
|
|
|
|
async createBucket(data: { requestBody?: { name?: string; slug?: string; visibility?: string } }) {
|
|
await this.delay();
|
|
const bucket: Bucket = {
|
|
id: `bucket-${Date.now()}`,
|
|
project_id: "proj-1",
|
|
name: data.requestBody?.name ?? "New bucket",
|
|
slug: data.requestBody?.slug ?? "new-bucket",
|
|
visibility: data.requestBody?.visibility ?? "private",
|
|
};
|
|
buckets = [...buckets, bucket];
|
|
pushAudit("bucket.created", "bucket", bucket.id, { slug: bucket.slug });
|
|
return bucket;
|
|
}
|
|
|
|
async updateBucket(data: { bucketId?: string; requestBody?: Partial<Bucket> }) {
|
|
await this.delay();
|
|
buckets = buckets.map((b) =>
|
|
b.id === data.bucketId ? { ...b, ...data.requestBody } : b,
|
|
);
|
|
pushAudit("bucket.updated", "bucket", data.bucketId ?? "");
|
|
return buckets.find((b) => b.id === data.bucketId);
|
|
}
|
|
|
|
async deleteBucket(data: { bucketId?: string }) {
|
|
await this.delay();
|
|
buckets = buckets.filter((b) => b.id !== data.bucketId);
|
|
objects = objects.filter((o) => o.bucket_id !== data.bucketId);
|
|
pushAudit("bucket.deleted", "bucket", data.bucketId ?? "");
|
|
return {};
|
|
}
|
|
|
|
async uploadBucketObject(data: { bucketId?: string; formData?: { objectKey?: string; file?: File } }) {
|
|
await this.delay();
|
|
const object: BucketObject = {
|
|
id: `obj-${Date.now()}`,
|
|
bucket_id: data.bucketId ?? buckets[0].id,
|
|
object_key: data.formData?.objectKey ?? data.formData?.file?.name ?? "file",
|
|
content_type: data.formData?.file?.type || "application/octet-stream",
|
|
size_bytes: data.formData?.file?.size ?? 0,
|
|
checksum_sha256: Math.random().toString(16).slice(2, 8),
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
objects = [object, ...objects];
|
|
pushAudit("object.uploaded", "object", object.object_key, { size_bytes: object.size_bytes });
|
|
return object;
|
|
}
|
|
|
|
async updateBucketObject(data: { objectKey?: string; requestBody?: { newObjectKey?: string } }) {
|
|
await this.delay();
|
|
objects = objects.map((o) =>
|
|
o.object_key === data.objectKey
|
|
? { ...o, object_key: data.requestBody?.newObjectKey ?? o.object_key }
|
|
: o,
|
|
);
|
|
pushAudit("object.updated", "object", data.requestBody?.newObjectKey ?? data.objectKey ?? "");
|
|
return {};
|
|
}
|
|
|
|
async copyBucketObject(data: { requestBody?: { objectKey?: string; newObjectKey?: string } }) {
|
|
await this.delay();
|
|
const source = objects.find((o) => o.object_key === data.requestBody?.objectKey);
|
|
if (source) {
|
|
objects = [
|
|
{ ...source, id: `obj-${Date.now()}`, object_key: data.requestBody?.newObjectKey ?? `${source.object_key}.copy`, created_at: new Date().toISOString() },
|
|
...objects,
|
|
];
|
|
}
|
|
pushAudit("object.uploaded", "object", data.requestBody?.newObjectKey ?? "");
|
|
return {};
|
|
}
|
|
|
|
async deleteBucketObject(data: { objectKey?: string }) {
|
|
await this.delay();
|
|
objects = objects.filter((o) => o.object_key !== data.objectKey);
|
|
pushAudit("object.deleted", "object", data.objectKey ?? "");
|
|
return {};
|
|
}
|
|
|
|
async createCollection(data: { requestBody?: { name?: string; slug?: string; description?: string } }) {
|
|
await this.delay();
|
|
const collection: Collection = {
|
|
id: `col-${Date.now()}`,
|
|
project_id: "proj-1",
|
|
name: data.requestBody?.name ?? "New collection",
|
|
slug: data.requestBody?.slug ?? "new-collection",
|
|
description: data.requestBody?.description ?? null,
|
|
schema: {},
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
collections = [...collections, collection];
|
|
pushAudit("collection.created", "collection", collection.id, { slug: collection.slug });
|
|
return collection;
|
|
}
|
|
|
|
async deleteCollection(data: { collectionId?: string }) {
|
|
await this.delay();
|
|
collections = collections.filter((c) => c.id !== data.collectionId);
|
|
documents = documents.filter((d) => d.collection_id !== data.collectionId);
|
|
pushAudit("collection.deleted", "collection", data.collectionId ?? "");
|
|
return {};
|
|
}
|
|
|
|
async createDocument(data: { collectionId?: string; requestBody?: { data?: Record<string, unknown> } }) {
|
|
await this.delay();
|
|
const doc: Document = {
|
|
id: `doc-${Date.now()}`,
|
|
collection_id: data.collectionId ?? collections[0]?.id ?? "",
|
|
data: data.requestBody?.data ?? {},
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
documents = [doc, ...documents];
|
|
pushAudit("document.created", "document", doc.id);
|
|
return doc;
|
|
}
|
|
|
|
async updateDocument(data: { documentId?: string; requestBody?: { data?: Record<string, unknown> } }) {
|
|
await this.delay();
|
|
documents = documents.map((d) =>
|
|
d.id === data.documentId
|
|
? { ...d, data: data.requestBody?.data ?? d.data, updated_at: new Date().toISOString() }
|
|
: d,
|
|
);
|
|
pushAudit("document.updated", "document", data.documentId ?? "");
|
|
return documents.find((d) => d.id === data.documentId);
|
|
}
|
|
|
|
async deleteDocument(data: { documentId?: string }) {
|
|
await this.delay();
|
|
documents = documents.filter((d) => d.id !== data.documentId);
|
|
pushAudit("document.deleted", "document", data.documentId ?? "");
|
|
return {};
|
|
}
|
|
|
|
async bootstrapPlatform() { await this.delay(); return {}; }
|
|
|
|
/* ---- databases (DBX) ---- */
|
|
|
|
async getDbxStatus() {
|
|
await this.delay();
|
|
return { available: true, command: "dbx-mcp" };
|
|
}
|
|
|
|
async listDbConnections() {
|
|
await this.delay();
|
|
return { items: dbConnections };
|
|
}
|
|
|
|
async createDbConnection(data: { requestBody?: { name?: string; db_type?: string; host?: string; port?: number; database?: string; username?: string; password?: string; ssl?: boolean } }) {
|
|
await this.delay();
|
|
const req = data.requestBody ?? {};
|
|
const conn: DBConnection = {
|
|
id: `dbc-${Date.now()}`,
|
|
project_id: "proj-1",
|
|
name: req.name ?? "connection",
|
|
db_type: req.db_type ?? "postgres",
|
|
host: req.host ?? "",
|
|
port: req.port ?? null,
|
|
database: req.database ?? "",
|
|
username: req.username ?? "",
|
|
ssl: req.ssl ?? null,
|
|
is_managed: false,
|
|
has_password: !!req.password,
|
|
};
|
|
dbConnections = [...dbConnections, conn];
|
|
pushAudit("db_connection.created", "db_connection", conn.id);
|
|
return conn;
|
|
}
|
|
|
|
async deleteDbConnection(data: { connectionId?: string }) {
|
|
await this.delay();
|
|
dbConnections = dbConnections.filter((c) => c.id !== data.connectionId);
|
|
pushAudit("db_connection.deleted", "db_connection", data.connectionId ?? "");
|
|
return {};
|
|
}
|
|
|
|
async testDbConnection() {
|
|
await this.delay();
|
|
return { ok: true };
|
|
}
|
|
|
|
async listDbDatabases() {
|
|
await this.delay();
|
|
return { databases: ["primora", "postgres"] };
|
|
}
|
|
|
|
async listDbTables() {
|
|
await this.delay();
|
|
return {
|
|
tables: demoTables.map((t) => ({ name: t, kind: "BASE TABLE" })),
|
|
};
|
|
}
|
|
|
|
async describeDbTable(data: { table?: string }) {
|
|
await this.delay();
|
|
return {
|
|
table: {
|
|
columns: ["Column", "Type", "Nullable", "Key"],
|
|
rows: demoColumns[data.table ?? ""] ?? [
|
|
["id", "uuid", "NO", "PK"],
|
|
["created_at", "timestamptz", "NO", ""],
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
async listDbForeignKeys() {
|
|
await this.delay();
|
|
return {
|
|
edges: [
|
|
{ schema: "core", table: "projects", column: "organization_id", ref_schema: "core", ref_table: "organizations", ref_column: "id" },
|
|
{ schema: "core", table: "api_keys", column: "project_id", ref_schema: "core", ref_table: "projects", ref_column: "id" },
|
|
{ schema: "core", table: "audit_logs", column: "project_id", ref_schema: "core", ref_table: "projects", ref_column: "id" },
|
|
{ schema: "core", table: "audit_logs", column: "organization_id", ref_schema: "core", ref_table: "organizations", ref_column: "id" },
|
|
{ schema: "core", table: "bucket_objects", column: "bucket_id", ref_schema: "core", ref_table: "buckets", ref_column: "id" },
|
|
{ schema: "core", table: "documents", column: "collection_id", ref_schema: "core", ref_table: "collections", ref_column: "id" },
|
|
],
|
|
};
|
|
}
|
|
|
|
async getDbSchemaContext() {
|
|
await this.delay();
|
|
return { context: "## users\nType: BASE TABLE\n- id uuid NOT NULL PK\n- email text NOT NULL" };
|
|
}
|
|
|
|
async executeDbQuery() {
|
|
await this.delay();
|
|
return {
|
|
table: {
|
|
columns: ["id", "email", "created_at"],
|
|
rows: [
|
|
["a4f3…", "[email protected]", "2024-01-01 00:00:00"],
|
|
["b8c1…", "[email protected]", "2024-01-02 00:00:00"],
|
|
],
|
|
note: "2 rows in 4ms (demo)",
|
|
},
|
|
};
|
|
}
|
|
|
|
async executeDbRedisCommand(data: { requestBody?: { command?: string } }) {
|
|
await this.delay();
|
|
const cmd = data.requestBody?.command ?? "";
|
|
return { output: cmd.trim().toUpperCase() === "PING" ? "PONG" : "OK" };
|
|
}
|
|
|
|
/* ---- telemetry ---- */
|
|
|
|
async ingestTelemetry(data: { requestBody?: { events?: Array<Record<string, unknown>> } }) {
|
|
await this.delay();
|
|
const events = data.requestBody?.events ?? [];
|
|
events.forEach((e) => {
|
|
demoEvents = [
|
|
{
|
|
id: (demoEvents[0]?.id ?? 0) + 1,
|
|
project_id: "proj-1",
|
|
component_id: demoComponents.find((c) => c.name === e.component)?.id ?? null,
|
|
component_name: String(e.component ?? ""),
|
|
type: String(e.type ?? "event"),
|
|
severity: String(e.severity ?? "info"),
|
|
message: String(e.message ?? ""),
|
|
payload: (e.payload as Record<string, unknown>) ?? {},
|
|
fingerprint: String(e.fingerprint ?? ""),
|
|
ts: String(e.ts ?? new Date().toISOString()),
|
|
},
|
|
...demoEvents,
|
|
];
|
|
});
|
|
return { accepted: events.length };
|
|
}
|
|
|
|
async listTelemetryEvents(data: { type?: string; component?: string; fingerprint?: string; limit?: number }) {
|
|
await this.delay();
|
|
let items = demoEvents;
|
|
if (data.type) items = items.filter((e) => e.type === data.type);
|
|
if (data.component) items = items.filter((e) => e.component_name === data.component);
|
|
if (data.fingerprint) items = items.filter((e) => e.fingerprint === data.fingerprint);
|
|
return { items: items.slice(0, data.limit ?? 100) };
|
|
}
|
|
|
|
async listTelemetryIssues() {
|
|
await this.delay();
|
|
const groups = new Map<string, TelemetryIssue>();
|
|
for (const e of demoEvents.filter((e) => e.type === "error" && e.fingerprint)) {
|
|
const fp = e.fingerprint!;
|
|
const g = groups.get(fp);
|
|
if (g) {
|
|
g.count += 1;
|
|
if (e.ts < g.first_seen) g.first_seen = e.ts;
|
|
if (e.ts > g.last_seen) g.last_seen = e.ts;
|
|
} else {
|
|
groups.set(fp, {
|
|
fingerprint: fp,
|
|
message: e.message,
|
|
component_id: e.component_id,
|
|
component_name: e.component_name,
|
|
count: 1,
|
|
first_seen: e.ts,
|
|
last_seen: e.ts,
|
|
severity: e.severity,
|
|
});
|
|
}
|
|
}
|
|
return { items: [...groups.values()] };
|
|
}
|
|
|
|
async listTelemetryComponents() {
|
|
await this.delay();
|
|
return { items: demoComponents };
|
|
}
|
|
|
|
async deleteTelemetryComponent(data: { componentId?: string }) {
|
|
await this.delay();
|
|
return {};
|
|
}
|
|
|
|
async getTelemetryStats(data: { window?: string }): Promise<TelemetryStats> {
|
|
await this.delay();
|
|
const now = Date.now();
|
|
const bucket = 900_000;
|
|
const series = Array.from({ length: 12 }, (_, i) => ({
|
|
ts: new Date(now - (11 - i) * bucket).toISOString(),
|
|
counts: {
|
|
error: i === 9 ? 2 : i % 5 === 0 ? 1 : 0,
|
|
event: i % 3,
|
|
metric: i * 2 + 4,
|
|
log: i % 4,
|
|
heartbeat: 4,
|
|
},
|
|
}));
|
|
return {
|
|
window: 86400,
|
|
bucket_sec: 900,
|
|
series,
|
|
components: demoComponents,
|
|
errors: demoEvents.filter((e) => e.type === "error").length,
|
|
events: demoEvents.length,
|
|
metrics: 46,
|
|
metric_names: ["http.req.ms", "db.query.ms"],
|
|
};
|
|
}
|
|
|
|
async getTelemetryMetricSeries(): Promise<{ items: MetricPoint[] }> {
|
|
await this.delay();
|
|
const now = Date.now();
|
|
return {
|
|
items: Array.from({ length: 12 }, (_, i) => {
|
|
const avg = 110 + Math.sin(i / 2) * 40;
|
|
return {
|
|
ts: new Date(now - (11 - i) * 900_000).toISOString(),
|
|
avg,
|
|
p50: avg * 0.9,
|
|
p95: avg * 1.8,
|
|
max: avg * 2.2,
|
|
count: 12 + i,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
|
|
export const demoService = new DemoService();
|