add: admin cli

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-12 14:51:08 +01:00
parent 9cafd61b25
commit 723ba3ef6c
18 changed files with 1641 additions and 47 deletions

161
admin/README.md Normal file
View File

@@ -0,0 +1,161 @@
# OpenPanel Admin CLI
An interactive CLI tool to help manage and lookup OpenPanel organizations, projects, and clients.
## Setup
First, install dependencies:
```bash
cd admin
pnpm install
```
## Usage
Run the CLI from the admin directory:
```bash
pnpm start
```
Or use the convenient shell script from anywhere:
```bash
./admin/cli
```
## Features
The CLI provides 4 focused lookup commands for easier navigation:
### 🏢 Lookup by Organization
Search and view detailed information about an organization.
- Fuzzy search across all organizations by name or ID
- Shows full organization details with all projects, clients, and members
### 📊 Lookup by Project
Search for a specific project and view its organization context.
- Fuzzy search across all projects by name or ID
- Highlights the selected project in the organization view
- Displays: `org → project`
### 🔑 Lookup by Client ID
Search for a specific client and view its full context.
- Fuzzy search across all clients by name or ID
- Highlights the selected client and its project
- Displays: `org → project → client`
### 📧 Lookup by Email
Search for a member by email address.
- Fuzzy search across all member emails
- Shows which organization(s) the member belongs to
- Displays member role (👑 owner, ⭐ admin, 👤 member)
**All lookups display:**
- Organization information (ID, name, subscription status, timezone, event usage)
- Organization members and their roles
- All projects with their settings (domain, CORS, event counts)
- All clients for each project (ID, name, type, credentials)
- Deletion warnings if scheduled
---
### 🗑️ Clear Cache
Clear cache for an organization and all its projects.
- Fuzzy search to find the organization
- Shows organization details and all projects
- Confirms before clearing cache
- Provides organization ID and all project IDs for cache clearing logic
**Use when:**
- You need to invalidate cache after data changes
- Troubleshooting caching issues
- After manual database updates
**Note:** The cache clearing logic needs to be implemented. The command provides the organization and project data structure for you to add your cache clearing calls.
---
### 🔴 Delete Organization
Permanently delete an organization and all its data.
- Fuzzy search to find the organization
- Shows detailed preview of what will be deleted (projects, members, events)
- Requires **3 confirmations**:
1. Initial confirmation
2. Type organization name to confirm
3. Final warning confirmation
- Deletes from both PostgreSQL and ClickHouse
**Use when:**
- Removing organizations that are no longer needed
- Cleaning up test/demo organizations
- Handling deletion requests
**⚠️ WARNING:** This action is PERMANENT and cannot be undone!
**What gets deleted:**
- Organization record
- All projects and their settings
- All clients and credentials
- All events and analytics data (from ClickHouse)
- All member associations
- All dashboards and reports
---
### 🔴 Delete User
Permanently delete a user account and remove them from all organizations.
- Fuzzy search by email or name
- Shows which organizations the user belongs to
- Shows if user created any organizations (won't delete those orgs)
- Requires **3 confirmations**:
1. Initial confirmation
2. Type user email to confirm
3. Final warning confirmation
**Use when:**
- Removing user accounts at user request
- Cleaning up inactive accounts
- Handling GDPR/data deletion requests
**⚠️ WARNING:** This action is PERMANENT and cannot be undone!
**What gets deleted:**
- User account
- All auth sessions and tokens
- All memberships (removed from all orgs)
- All personal data
**What is NOT deleted:**
- Organizations created by the user (only the creator reference is removed)
## Environment Variables
Make sure you have the proper environment variables set up:
- `DATABASE_URL` - PostgreSQL connection string
- `DATABASE_URL_REPLICA` (optional) - Read replica connection string
## Development
The CLI uses:
- **jiti** - Direct TypeScript execution without build step
- **inquirer** - Interactive prompts
- **inquirer-autocomplete-prompt** - Fuzzy search functionality
- **chalk** - Colored terminal output
- **@openpanel/db** - Direct Prisma database access

25
admin/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@openpanel/admin",
"version": "0.0.1",
"type": "module",
"private": true,
"scripts": {
"start": "dotenv -e .env -c -- jiti src/cli.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/common": "workspace:*",
"@openpanel/db": "workspace:*",
"chalk": "^5.3.0",
"fuzzy": "^0.1.3",
"inquirer": "^9.3.5",
"inquirer-autocomplete-prompt": "^3.0.1",
"jiti": "^2.4.2"
},
"devDependencies": {
"@types/inquirer": "^9.0.7",
"@types/inquirer-autocomplete-prompt": "^3.0.3",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

118
admin/src/cli.ts Normal file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env node
import inquirer from 'inquirer';
import { clearCache } from './commands/clear-cache';
import { deleteOrganization } from './commands/delete-organization';
import { deleteUser } from './commands/delete-user';
import { lookupByClient } from './commands/lookup-client';
import { lookupByEmail } from './commands/lookup-email';
import { lookupByOrg } from './commands/lookup-org';
import { lookupByProject } from './commands/lookup-project';
const secureEnv = (url: string) => {
const parsed = new URL(url);
if (parsed.username && parsed.password) {
return `${parsed.protocol}//${parsed.username}:${parsed.password.slice(0, 1)}...${parsed.password.slice(-1)}@${parsed.hostname}:${parsed.port}`;
}
return url;
};
async function main() {
console.log('\n🔧 OpenPanel Admin CLI\n');
const DATABASE_URL = process.env.DATABASE_URL;
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
const REDIS_URL = process.env.REDIS_URL;
if (!DATABASE_URL || !CLICKHOUSE_URL || !REDIS_URL) {
console.error('Environment variables are not set');
process.exit(1);
}
// Log environment variables for debugging
console.log('Environment:', {
NODE_ENV: process.env.NODE_ENV,
SELF_HOSTED: process.env.SELF_HOSTED ? 'Yes' : 'No',
DATABASE_URL: secureEnv(DATABASE_URL),
CLICKHOUSE_URL: secureEnv(CLICKHOUSE_URL),
REDIS_URL: secureEnv(REDIS_URL),
});
console.log('');
const { command } = await inquirer.prompt([
{
type: 'list',
name: 'command',
message: 'What would you like to do?',
pageSize: 20,
choices: [
{
name: '🏢 Lookup by Organization',
value: 'lookup-org',
},
{
name: '📊 Lookup by Project',
value: 'lookup-project',
},
{
name: '🔑 Lookup by Client ID',
value: 'lookup-client',
},
{
name: '📧 Lookup by Email',
value: 'lookup-email',
},
{
name: '🗑️ Clear Cache',
value: 'clear-cache',
},
{ name: '─────────────────────', value: 'separator', disabled: true },
{
name: '🔴 Delete Organization',
value: 'delete-org',
},
{
name: '🔴 Delete User',
value: 'delete-user',
},
{ name: '─────────────────────', value: 'separator', disabled: true },
{ name: '❌ Exit', value: 'exit' },
],
},
]);
switch (command) {
case 'lookup-org':
await lookupByOrg();
break;
case 'lookup-project':
await lookupByProject();
break;
case 'lookup-client':
await lookupByClient();
break;
case 'lookup-email':
await lookupByEmail();
break;
case 'clear-cache':
await clearCache();
break;
case 'delete-org':
await deleteOrganization();
break;
case 'delete-user':
await deleteUser();
break;
case 'exit':
console.log('Goodbye! 👋');
process.exit(0);
}
// Loop back to main menu
await main();
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,162 @@
import {
db,
getOrganizationAccess,
getOrganizationByProjectIdCached,
getProjectAccess,
getProjectByIdCached,
} from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface OrgSearchItem {
id: string;
name: string;
displayText: string;
}
export async function clearCache() {
console.log(chalk.blue('\n🗑 Clear Cache\n'));
console.log('Loading organizations...\n');
const organizations = await db.organization.findMany({
orderBy: {
name: 'asc',
},
});
if (organizations.length === 0) {
console.log(chalk.red('No organizations found.'));
return;
}
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
id: org.id,
name: org.name,
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedOrg } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedOrg',
message: 'Search for an organization:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedOrg: OrgSearchItem };
// Fetch organization with all projects
const organization = await db.organization.findUnique({
where: {
id: selectedOrg.id,
},
include: {
projects: {
orderBy: {
name: 'asc',
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
console.log(chalk.yellow('\n📋 Organization Details:\n'));
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`);
if (organization.projects.length > 0) {
console.log(chalk.yellow('\n📊 Projects:\n'));
for (const project of organization.projects) {
console.log(
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`,
);
}
}
// Confirm before clearing cache
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Clear cache for organization "${organization.name}" and all ${organization.projects.length} projects?`,
default: false,
},
]);
if (!confirm) {
console.log(chalk.yellow('\nCache clear cancelled.'));
return;
}
console.log(chalk.blue('\n🔄 Clearing cache...\n'));
for (const project of organization.projects) {
// Clear project access cache for each member
for (const member of organization.members) {
if (!member.user?.id) continue;
console.log(
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`,
);
await getProjectAccess.clear({
userId: member.user?.id,
projectId: project.id,
});
await getOrganizationAccess.clear({
userId: member.user?.id,
organizationId: organization.id,
});
}
console.log(`Clearing cache for project: ${project.name}`);
await getOrganizationByProjectIdCached.clear(project.id);
await getProjectByIdCached.clear(project.id);
}
console.log(chalk.gray(`Organization ID: ${organization.id}`));
console.log(
chalk.gray(
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`,
),
);
// Example of what you might do:
/*
for (const project of organization.projects) {
console.log(`Clearing cache for project: ${project.name}...`);
// await clearProjectCache(project.id);
// await redis.del(`project:${project.id}:*`);
}
// Clear organization-level cache
// await clearOrganizationCache(organization.id);
// await redis.del(`organization:${organization.id}:*`);
console.log(chalk.green('\n✅ Cache cleared successfully!'));
*/
}

View File

@@ -0,0 +1,215 @@
import {
db,
deleteFromClickhouse,
deleteOrganization as deleteOrg,
} from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface OrgSearchItem {
id: string;
name: string;
displayText: string;
}
export async function deleteOrganization() {
console.log(chalk.red('\n🗑 Delete Organization\n'));
console.log(
chalk.yellow(
'⚠️ WARNING: This will permanently delete the organization and all its data!\n',
),
);
console.log('Loading organizations...\n');
const organizations = await db.organization.findMany({
include: {
projects: true,
members: {
include: {
user: true,
},
},
},
orderBy: {
name: 'asc',
},
});
if (organizations.length === 0) {
console.log(chalk.red('No organizations found.'));
return;
}
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
id: org.id,
name: org.name,
displayText: `${org.name} ${chalk.gray(`(${org.id})`)} ${chalk.cyan(`- ${org.projects.length} projects, ${org.members.length} members`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedOrg } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedOrg',
message: 'Search for an organization to delete:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedOrg: OrgSearchItem };
// Fetch full organization details
const organization = await db.organization.findUnique({
where: {
id: selectedOrg.id,
},
include: {
projects: {
include: {
clients: true,
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
// Display what will be deleted
console.log(chalk.red('\n⚠ YOU ARE ABOUT TO DELETE:\n'));
console.log(` ${chalk.bold('Organization:')} ${organization.name}`);
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`);
console.log(` ${chalk.gray('Members:')} ${organization.members.length}`);
if (organization.projects.length > 0) {
console.log(chalk.red('\n Projects that will be deleted:'));
for (const project of organization.projects) {
console.log(
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`,
);
}
}
if (organization.members.length > 0) {
console.log(chalk.red('\n Members who will lose access:'));
for (const member of organization.members) {
const email = member.user?.email || member.email || 'Unknown';
console.log(` - ${email} ${chalk.gray(`(${member.role})`)}`);
}
}
console.log(
chalk.red(
'\n⚠ This will delete ALL projects, clients, events, and data associated with this organization!',
),
);
// First confirmation
const { confirmFirst } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmFirst',
message: chalk.red(
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`,
),
default: false,
},
]);
if (!confirmFirst) {
console.log(chalk.yellow('\nDeletion cancelled.'));
return;
}
// Second confirmation - type organization name
const { confirmName } = await inquirer.prompt([
{
type: 'input',
name: 'confirmName',
message: `Type the organization name "${organization.name}" to confirm deletion:`,
},
]);
if (confirmName !== organization.name) {
console.log(
chalk.red('\n❌ Organization name does not match. Deletion cancelled.'),
);
return;
}
// Final confirmation
const { confirmFinal } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmFinal',
message: chalk.red(
'FINAL WARNING: This action CANNOT be undone. Delete now?',
),
default: false,
},
]);
if (!confirmFinal) {
console.log(chalk.yellow('\nDeletion cancelled.'));
return;
}
console.log(chalk.red('\n🗑 Deleting organization...\n'));
try {
const projectIds = organization.projects.map((p) => p.id);
// Step 1: Delete from ClickHouse (events, profiles, etc.)
if (projectIds.length > 0) {
console.log(
chalk.yellow(
`Deleting data from ClickHouse for ${projectIds.length} projects...`,
),
);
await deleteFromClickhouse(projectIds);
console.log(chalk.green('✓ ClickHouse data deletion initiated'));
}
// Step 2: Delete the organization from PostgreSQL (cascade will handle related records)
console.log(chalk.yellow('Deleting organization from database...'));
await deleteOrg(organization.id);
console.log(chalk.green('✓ Organization deleted from database'));
console.log(chalk.green('\n✅ Organization deleted successfully!'));
console.log(
chalk.gray(
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`,
),
);
console.log(
chalk.gray(
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.',
),
);
} catch (error) {
console.error(chalk.red('\n❌ Error deleting organization:'), error);
throw error;
}
}

View File

@@ -0,0 +1,220 @@
import { db } from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface UserSearchItem {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
displayText: string;
}
export async function deleteUser() {
console.log(chalk.red('\n🗑 Delete User\n'));
console.log(
chalk.yellow(
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n',
),
);
console.log('Loading users...\n');
const users = await db.user.findMany({
include: {
membership: {
include: {
organization: true,
},
},
accounts: true,
},
orderBy: {
email: 'asc',
},
});
if (users.length === 0) {
console.log(chalk.red('No users found.'));
return;
}
const searchItems: UserSearchItem[] = users.map((user) => {
const fullName =
user.firstName || user.lastName
? `${user.firstName || ''} ${user.lastName || ''}`.trim()
: '';
const orgCount = user.membership.length;
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
displayText: `${user.email} ${fullName ? chalk.gray(`(${fullName})`) : ''} ${chalk.cyan(`- ${orgCount} orgs`)}`,
};
});
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: UserSearchItem) =>
`${item.email} ${item.firstName || ''} ${item.lastName || ''}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<UserSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedUser } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedUser',
message: 'Search for a user to delete:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedUser: UserSearchItem };
// Fetch full user details
const user = await db.user.findUnique({
where: {
id: selectedUser.id,
},
include: {
membership: {
include: {
organization: true,
},
},
accounts: true,
createdOrganizations: true,
},
});
if (!user) {
console.log(chalk.red('User not found.'));
return;
}
// Display what will be deleted
console.log(chalk.red('\n⚠ YOU ARE ABOUT TO DELETE:\n'));
console.log(` ${chalk.bold('User:')} ${user.email}`);
if (user.firstName || user.lastName) {
console.log(
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`,
);
}
console.log(` ${chalk.gray('ID:')} ${user.id}`);
console.log(
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`,
);
console.log(` ${chalk.gray('Auth accounts:')} ${user.accounts.length}`);
if (user.createdOrganizations.length > 0) {
console.log(
chalk.red(
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`,
),
);
for (const org of user.createdOrganizations) {
console.log(` - ${org.name} ${chalk.gray(`(${org.id})`)}`);
}
console.log(
chalk.yellow(
' Note: These organizations will NOT be deleted, only the user reference.',
),
);
}
if (user.membership.length > 0) {
console.log(
chalk.red('\n Organizations where user will be removed from:'),
);
for (const member of user.membership) {
console.log(
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`,
);
}
}
console.log(
chalk.red(
'\n⚠ This will delete the user account, all sessions, and remove them from all organizations!',
),
);
// First confirmation
const { confirmFirst } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmFirst',
message: chalk.red(
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`,
),
default: false,
},
]);
if (!confirmFirst) {
console.log(chalk.yellow('\nDeletion cancelled.'));
return;
}
// Second confirmation - type email
const { confirmEmail } = await inquirer.prompt([
{
type: 'input',
name: 'confirmEmail',
message: `Type the user email "${user.email}" to confirm deletion:`,
},
]);
if (confirmEmail !== user.email) {
console.log(chalk.red('\n❌ Email does not match. Deletion cancelled.'));
return;
}
// Final confirmation
const { confirmFinal } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmFinal',
message: chalk.red(
'FINAL WARNING: This action CANNOT be undone. Delete now?',
),
default: false,
},
]);
if (!confirmFinal) {
console.log(chalk.yellow('\nDeletion cancelled.'));
return;
}
console.log(chalk.red('\n🗑 Deleting user...\n'));
try {
// Delete the user (cascade will handle related records like sessions, accounts, memberships)
await db.user.delete({
where: {
id: user.id,
},
});
console.log(chalk.green('\n✅ User deleted successfully!'));
console.log(
chalk.gray(
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`,
),
);
} catch (error) {
console.error(chalk.red('\n❌ Error deleting user:'), error);
throw error;
}
}

View File

@@ -0,0 +1,104 @@
import { db } from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
import { displayOrganizationDetails } from '../utils/display';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface ClientSearchItem {
id: string;
name: string;
organizationId: string;
organizationName: string;
projectId: string | null;
projectName: string | null;
displayText: string;
}
export async function lookupByClient() {
console.log(chalk.blue('\n🔑 Lookup by Client ID\n'));
console.log('Loading clients...\n');
const clients = await db.client.findMany({
include: {
organization: true,
project: true,
},
orderBy: {
name: 'asc',
},
});
if (clients.length === 0) {
console.log(chalk.red('No clients found.'));
return;
}
const searchItems: ClientSearchItem[] = clients.map((client) => ({
id: client.id,
name: client.name,
organizationId: client.organizationId,
organizationName: client.organization.name,
projectId: client.projectId,
projectName: client.project?.name || null,
displayText: `${client.organization.name}${client.project?.name || '[No Project]'}${client.name} ${chalk.gray(`(${client.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: ClientSearchItem) =>
`${item.organizationName} ${item.projectName || ''} ${item.name} ${item.id}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<ClientSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedClient } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedClient',
message: 'Search for a client:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedClient: ClientSearchItem };
// Fetch full organization details
const organization = await db.organization.findUnique({
where: {
id: selectedClient.organizationId,
},
include: {
projects: {
include: {
clients: true,
},
orderBy: {
name: 'asc',
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
displayOrganizationDetails(organization, {
highlightProjectId: selectedClient.projectId || undefined,
highlightClientId: selectedClient.id,
});
}

View File

@@ -0,0 +1,112 @@
import { db } from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
import { displayOrganizationDetails } from '../utils/display';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface EmailSearchItem {
email: string;
organizationId: string;
organizationName: string;
role: string;
userId: string | null;
displayText: string;
}
export async function lookupByEmail() {
console.log(chalk.blue('\n📧 Lookup by Email\n'));
console.log('Loading members...\n');
const members = await db.member.findMany({
include: {
organization: true,
user: true,
},
orderBy: {
email: 'asc',
},
});
if (members.length === 0) {
console.log(chalk.red('No members found.'));
return;
}
// Group by email (in case same email is in multiple orgs)
const searchItems: EmailSearchItem[] = members.map((member) => {
const email = member.user?.email || member.email || 'Unknown';
const roleBadge =
member.role === 'owner' ? '👑' : member.role === 'admin' ? '⭐' : '👤';
return {
email,
organizationId: member.organizationId,
organizationName: member.organization.name,
role: member.role,
userId: member.userId,
displayText: `${email} ${chalk.gray(`${member.organization.name}`)} ${roleBadge}`,
};
});
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: EmailSearchItem) =>
`${item.email} ${item.organizationName}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<EmailSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedMember } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedMember',
message: 'Search for a member by email:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedMember: EmailSearchItem };
// Fetch full organization details
const organization = await db.organization.findUnique({
where: {
id: selectedMember.organizationId,
},
include: {
projects: {
include: {
clients: true,
},
orderBy: {
name: 'asc',
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
console.log(
chalk.yellow(
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`,
),
);
displayOrganizationDetails(organization);
}

View File

@@ -0,0 +1,88 @@
import { db } from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
import { displayOrganizationDetails } from '../utils/display';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface OrgSearchItem {
id: string;
name: string;
displayText: string;
}
export async function lookupByOrg() {
console.log(chalk.blue('\n🏢 Lookup by Organization\n'));
console.log('Loading organizations...\n');
const organizations = await db.organization.findMany({
orderBy: {
name: 'asc',
},
});
if (organizations.length === 0) {
console.log(chalk.red('No organizations found.'));
return;
}
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
id: org.id,
name: org.name,
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedOrg } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedOrg',
message: 'Search for an organization:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedOrg: OrgSearchItem };
// Fetch full organization details
const organization = await db.organization.findUnique({
where: {
id: selectedOrg.id,
},
include: {
projects: {
include: {
clients: true,
},
orderBy: {
name: 'asc',
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
displayOrganizationDetails(organization);
}

View File

@@ -0,0 +1,98 @@
import { db } from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
import { displayOrganizationDetails } from '../utils/display';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface ProjectSearchItem {
id: string;
name: string;
organizationId: string;
organizationName: string;
displayText: string;
}
export async function lookupByProject() {
console.log(chalk.blue('\n📊 Lookup by Project\n'));
console.log('Loading projects...\n');
const projects = await db.project.findMany({
include: {
organization: true,
},
orderBy: {
name: 'asc',
},
});
if (projects.length === 0) {
console.log(chalk.red('No projects found.'));
return;
}
const searchItems: ProjectSearchItem[] = projects.map((project) => ({
id: project.id,
name: project.name,
organizationId: project.organizationId,
organizationName: project.organization.name,
displayText: `${project.organization.name}${project.name} ${chalk.gray(`(${project.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: ProjectSearchItem) =>
`${item.organizationName} ${item.name} ${item.id}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<ProjectSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedProject } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedProject',
message: 'Search for a project:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedProject: ProjectSearchItem };
// Fetch full organization details
const organization = await db.organization.findUnique({
where: {
id: selectedProject.organizationId,
},
include: {
projects: {
include: {
clients: true,
},
orderBy: {
name: 'asc',
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
displayOrganizationDetails(organization, {
highlightProjectId: selectedProject.id,
});
}

206
admin/src/utils/display.ts Normal file
View File

@@ -0,0 +1,206 @@
import type {
Client,
Member,
Organization,
Project,
User,
} from '@openpanel/db';
import chalk from 'chalk';
type OrganizationWithDetails = Organization & {
projects: (Project & {
clients: Client[];
})[];
members: (Member & {
user: User | null;
})[];
};
interface DisplayOptions {
highlightProjectId?: string;
highlightClientId?: string;
}
export function displayOrganizationDetails(
organization: OrganizationWithDetails,
options: DisplayOptions = {},
) {
console.log(`\n${'='.repeat(80)}`);
console.log(chalk.bold.yellow(`\n📊 ORGANIZATION: ${organization.name}`));
console.log(`${'='.repeat(80)}\n`);
// Organization Details
console.log(chalk.bold('Organization Details:'));
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
console.log(
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`,
);
console.log(` ${chalk.gray('Timezone:')} ${organization.timezone || 'UTC'}`);
// Subscription info
if (organization.subscriptionStatus) {
console.log(
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`,
);
if (organization.subscriptionPriceId) {
console.log(
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`,
);
}
if (organization.subscriptionPeriodEventsLimit) {
const usage = `${organization.subscriptionPeriodEventsCount}/${organization.subscriptionPeriodEventsLimit}`;
const percentage =
(organization.subscriptionPeriodEventsCount /
organization.subscriptionPeriodEventsLimit) *
100;
const color =
percentage > 90
? chalk.red
: percentage > 70
? chalk.yellow
: chalk.green;
console.log(
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`,
);
}
if (organization.subscriptionStartsAt) {
console.log(
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`,
);
}
if (organization.subscriptionEndsAt) {
console.log(
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`,
);
}
}
if (organization.deleteAt) {
console.log(
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`,
);
}
// Members
console.log(`\n${chalk.bold('Members:')}`);
if (organization.members.length === 0) {
console.log(' No members');
} else {
for (const member of organization.members) {
const roleBadge = getRoleBadge(member.role);
console.log(
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`,
);
}
}
// Projects
console.log(`\n${chalk.bold(`Projects (${organization.projects.length}):`)}`);
if (organization.projects.length === 0) {
console.log(' No projects');
} else {
for (const project of organization.projects) {
const isHighlighted = project.id === options.highlightProjectId;
const projectPrefix = isHighlighted ? chalk.yellow.bold('→ ') : ' ';
console.log(`\n${projectPrefix}${chalk.bold.green(project.name)}`);
console.log(` ${chalk.gray('ID:')} ${project.id}`);
console.log(
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`,
);
if (project.domain) {
console.log(` ${chalk.gray('Domain:')} ${project.domain}`);
}
if (project.cors.length > 0) {
console.log(` ${chalk.gray('CORS:')} ${project.cors.join(', ')}`);
}
console.log(
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`,
);
console.log(
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`,
);
if (project.deleteAt) {
console.log(
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`,
);
}
// Clients for this project
if (project.clients.length > 0) {
console.log(` ${chalk.gray('Clients:')}`);
for (const client of project.clients) {
const isClientHighlighted = client.id === options.highlightClientId;
const clientPrefix = isClientHighlighted
? chalk.yellow.bold(' → ')
: ' ';
const typeBadge = getClientTypeBadge(client.type);
console.log(`${clientPrefix}${typeBadge} ${chalk.cyan(client.name)}`);
console.log(` ${chalk.gray('ID:')} ${client.id}`);
console.log(` ${chalk.gray('Type:')} ${client.type}`);
console.log(
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`,
);
console.log(
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`,
);
}
} else {
console.log(` ${chalk.gray('Clients:')} None`);
}
}
}
// Clients without projects (organization-level clients)
const orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
console.log(`\n${'='.repeat(80)}\n`);
}
function getSubscriptionStatusColor(status: string): string {
switch (status) {
case 'active':
return chalk.green(status);
case 'trialing':
return chalk.blue(status);
case 'canceled':
return chalk.red(status);
case 'past_due':
return chalk.yellow(status);
default:
return chalk.gray(status);
}
}
function getRoleBadge(role: string): string {
switch (role) {
case 'owner':
return chalk.red.bold('👑');
case 'admin':
return chalk.yellow.bold('⭐');
case 'member':
return chalk.blue('👤');
default:
return chalk.gray('•');
}
}
function getClientTypeBadge(type: string): string {
switch (type) {
case 'root':
return chalk.red.bold('[ROOT]');
case 'write':
return chalk.green('[WRITE]');
case 'read':
return chalk.blue('[READ]');
default:
return chalk.gray('[UNKNOWN]');
}
}

12
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "../tooling/typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src"]
}

View File

@@ -1,10 +1,9 @@
import { logger } from '@/utils/logger';
import { TABLE_NAMES, ch, db, getReplicatedTableName } from '@openpanel/db';
import { db, deleteFromClickhouse, deleteProjects } from '@openpanel/db';
import type { CronQueuePayload } from '@openpanel/queue';
import type { Job } from 'bullmq';
import sqlstring from 'sqlstring';
export async function deleteProjects(job: Job<CronQueuePayload>) {
export async function jobdeleteProjects(job: Job<CronQueuePayload>) {
const projects = await db.project.findMany({
where: {
deleteAt: {
@@ -17,44 +16,13 @@ export async function deleteProjects(job: Job<CronQueuePayload>) {
return;
}
for (const project of projects) {
await db.project.delete({
where: {
id: project.id,
},
});
}
await deleteProjects(projects.map((project) => project.id));
logger.info('Deleting projects', {
projects,
});
projects.forEach((project) => {
job.log(`Delete project: "${project.id}"`);
});
const where = `project_id IN (${projects.map((project) => sqlstring.escape(project.id)).join(',')})`;
const tables = [
TABLE_NAMES.events,
TABLE_NAMES.profiles,
TABLE_NAMES.events_bots,
TABLE_NAMES.sessions,
TABLE_NAMES.cohort_events_mv,
TABLE_NAMES.dau_mv,
TABLE_NAMES.event_names_mv,
TABLE_NAMES.event_property_values_mv,
];
for (const table of tables) {
const query = `ALTER TABLE ${getReplicatedTableName(table)} DELETE WHERE ${where};`;
await ch.command({
query,
clickhouse_settings: {
lightweight_deletes_sync: '0',
},
});
}
await deleteFromClickhouse(projects.map((project) => project.id));
logger.info(`Deleted ${projects.length} projects`, {
projects,

View File

@@ -3,7 +3,7 @@ import type { Job } from 'bullmq';
import { eventBuffer, profileBuffer, sessionBuffer } from '@openpanel/db';
import type { CronQueuePayload } from '@openpanel/queue';
import { deleteProjects } from './cron.delete-projects';
import { jobdeleteProjects } from './cron.delete-projects';
import { ping } from './cron.ping';
import { salt } from './cron.salt';
@@ -25,7 +25,7 @@ export async function cronJob(job: Job<CronQueuePayload>) {
return await ping();
}
case 'deleteProjects': {
return await deleteProjects(job);
return await jobdeleteProjects(job);
}
}
}

View File

@@ -21,6 +21,7 @@ export * from './src/services/id.service';
export * from './src/services/retention.service';
export * from './src/services/notification.service';
export * from './src/services/access.service';
export * from './src/services/delete.service';
export * from './src/buffers';
export * from './src/types';
export * from './src/clickhouse/query-builder';

View File

@@ -0,0 +1,66 @@
import { TABLE_NAMES, ch, getReplicatedTableName } from '../clickhouse/client';
import { logger } from '../logger';
import { db } from '../prisma-client';
import sqlstring from 'sqlstring';
export async function deleteOrganization(organizationId: string) {
return await db.organization.delete({
where: {
id: organizationId,
},
});
}
export async function deleteProjects(projectIds: string[]) {
const projects = await db.project.findMany({
where: {
id: {
in: projectIds,
},
},
});
if (projects.length === 0) {
return;
}
for (const project of projects) {
await db.project.delete({
where: {
id: project.id,
},
});
}
return projects;
}
export async function deleteFromClickhouse(projectIds: string[]) {
const where = `project_id IN (${projectIds.map((projectId) => sqlstring.escape(projectId)).join(',')})`;
const tables = [
TABLE_NAMES.events,
TABLE_NAMES.profiles,
TABLE_NAMES.events_bots,
TABLE_NAMES.sessions,
TABLE_NAMES.cohort_events_mv,
TABLE_NAMES.dau_mv,
TABLE_NAMES.event_names_mv,
TABLE_NAMES.event_property_values_mv,
];
for (const table of tables) {
// If materialized view, use ALTER TABLE since DELETE is not supported
const query = table.endsWith('_mv')
? `ALTER TABLE ${getReplicatedTableName(table)} DELETE WHERE ${where};`
: `DELETE FROM ${getReplicatedTableName(table)} WHERE ${where};`;
logger.info('Deleting from ClickHouse table:', { query });
await ch.command({
query,
clickhouse_settings: {
lightweight_deletes_sync: '0',
},
});
}
}

55
pnpm-lock.yaml generated
View File

@@ -69,6 +69,43 @@ importers:
specifier: ^3.0.4
version: 3.1.3(@types/debug@4.1.12)(@types/node@24.7.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.27.1)(tsx@4.20.5)
admin:
dependencies:
'@openpanel/common':
specifier: workspace:*
version: link:../packages/common
'@openpanel/db':
specifier: workspace:*
version: link:../packages/db
chalk:
specifier: ^5.3.0
version: 5.4.1
fuzzy:
specifier: ^0.1.3
version: 0.1.3
inquirer:
specifier: ^9.3.5
version: 9.3.6
inquirer-autocomplete-prompt:
specifier: ^3.0.1
version: 3.0.1(inquirer@9.3.6)
jiti:
specifier: ^2.4.2
version: 2.6.1
devDependencies:
'@types/inquirer':
specifier: ^9.0.7
version: 9.0.7
'@types/inquirer-autocomplete-prompt':
specifier: ^3.0.3
version: 3.0.3
'@types/node':
specifier: 'catalog:'
version: 24.7.1
typescript:
specifier: 'catalog:'
version: 5.9.3
apps/api:
dependencies:
'@ai-sdk/anthropic':
@@ -11272,6 +11309,10 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
fuzzy@0.1.3:
resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==}
engines: {node: '>= 0.6.0'}
gaxios@6.7.1:
resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
engines: {node: '>=14'}
@@ -12143,10 +12184,6 @@ packages:
resolution: {integrity: sha512-yPBThwecp1wS9DmoA4x4KR2h3QoslacnDR8ypuFM962kI4/456Iy1oHx2RAgh4jfZNdn0bctsdadceiBUgpU1g==}
hasBin: true
jiti@2.5.1:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -23512,7 +23549,7 @@ snapshots:
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.18.3
jiti: 2.5.1
jiti: 2.6.1
lightningcss: 1.30.1
magic-string: 0.30.17
source-map-js: 1.2.1
@@ -25463,7 +25500,7 @@ snapshots:
dotenv: 16.6.1
exsolve: 1.0.7
giget: 2.0.0
jiti: 2.5.1
jiti: 2.6.1
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 1.0.0
@@ -27813,6 +27850,8 @@ snapshots:
functions-have-names@1.2.3: {}
fuzzy@0.1.3: {}
gaxios@6.7.1:
dependencies:
extend: 3.0.2
@@ -28834,8 +28873,6 @@ snapshots:
jiti@2.4.1: {}
jiti@2.5.1: {}
jiti@2.6.1: {}
joi@17.12.1:
@@ -33445,7 +33482,7 @@ snapshots:
dependencies:
'@quansync/fs': 0.1.5
defu: 6.1.4
jiti: 2.5.1
jiti: 2.6.1
quansync: 0.2.11
uncrypto@0.1.3: {}

View File

@@ -2,6 +2,7 @@ packages:
- "apps/*"
- "packages/**"
- "tooling/*"
- "admin"
# Define a catalog of version ranges.
catalog: