Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Obiente Cloud is a distributed Platform-as-a-Service (PaaS) built as an Nx monorepo with 14 Go microservices and a Nuxt 4 dashboard. Services communicate via ConnectRPC (protocol buffers). Deployed on Docker Swarm.

## Build & Development Commands

### Package Management
```bash
pnpm install # Install all JS/TS dependencies
```

### Dashboard (Nuxt 4 frontend)
```bash
nx serve dashboard # Dev server on port 3000
nx nuxt:build dashboard # Production build
nx lint dashboard # ESLint
nx typecheck dashboard # Type checking
```

### Go Services
```bash
cd apps/<service-name>
go run main.go # Run locally
go build # Build binary
go test ./... # Run tests
```

### Protocol Buffers
```bash
cd packages/proto
pnpm build # Regenerate all proto code (buf generate)
```
Generated Go code goes to `apps/shared/proto/`, TypeScript to `packages/proto/src/generated/`.

### Docker
```bash
docker compose up -d # Local dev (all services)
docker build -f apps/<svc>/Dockerfile -t ghcr.io/obiente/cloud-<svc>:latest . # Build image
./scripts/deploy-swarm-dev.sh # Swarm dev deploy
./scripts/deploy-swarm-dev.sh -b # Build + deploy
```

### Nx
Always prefer running tasks through `nx` rather than underlying tooling directly. Use `nx run`, `nx run-many`, `nx affected`.

## Architecture

### Service Ports
| Service | Port |
|---------|------|
| Dashboard | 3000 |
| API Gateway | 3001 |
| Auth | 3002 |
| Organizations | 3003 |
| Billing | 3004 |
| Deployments | 3005 |
| GameServers | 3006 |
| Orchestrator | 3007 |
| VPS | 3008 |
| Support | 3009 |
| Audit | 3010 |
| Superadmin | 3011 |
| Notifications | 3012 |
| DNS | 8053 |

### Key Architectural Patterns

- **API Gateway** routes all external requests to backend services. Supports both direct service routing and Traefik-based routing.
- **ConnectRPC** is used for all inter-service communication. Proto definitions live in `packages/proto/proto/obiente/cloud/`. Buf generates both Go and TypeScript clients.
- **Go workspace** (`go.work`) links all 15 Go modules. Shared code is in `apps/shared/` with packages for auth, database, docker, middleware, orchestrator, quota, etc.
- **Auth** is handled via Zitadel integration with RBAC. The auth-service validates tokens and manages roles/permissions.
- **Orchestrator** handles intelligent node selection and load balancing across the Docker Swarm cluster.
- **Database**: PostgreSQL (primary), TimescaleDB (metrics/audit), Redis (cache, build logs).
- **Dashboard** uses Nuxt 4, Vue 3, Tailwind CSS v4, Pinia for state, Ark UI for components, and `@connectrpc/connect-web` for API calls.

### Monorepo Structure
- `apps/` - All microservices + dashboard
- `packages/proto/` - Protobuf definitions and generated code
- `packages/database/` - Drizzle ORM schemas and migrations
- `packages/config/` - Shared ESLint, Prettier, TypeScript configs
- `packages/types/` - Shared TypeScript types
- `tools/nxsh/` - Custom Nx shell executor
- `monitoring/` - Prometheus & Grafana configs
- `scripts/` - Deployment and operational scripts

### Docker Compose Files
- `docker-compose.yml` - Local development
- `docker-compose.base.yml` - Shared env vars (YAML anchors)
- `docker-compose.swarm.yml` - Production swarm
- `docker-compose.swarm.dev.yml` - Dev swarm (must use `docker stack deploy`, not `docker compose`)
- `docker-compose.swarm.ha.yml` - HA production with PostgreSQL cluster
Comment on lines +1 to +95
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire CLAUDE.md file addition appears unrelated to this PR's stated purpose of adding superadmin set-plan functionality. This is a comprehensive documentation file about the project structure and development practices.

While this documentation may be valuable, it should be added in a separate PR dedicated to documentation improvements, not bundled with a feature implementation PR. This makes the PR harder to review and mixes unrelated concerns.

Copilot uses AI. Check for mistakes.
138 changes: 131 additions & 7 deletions apps/dashboard/app/pages/superadmin/organizations/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,61 @@
</OuiFlex>
</OuiStack>
</OuiDialog>

<!-- Set Plan Dialog -->
<OuiDialog v-model:open="setPlanDialogOpen" title="Set Plan">
<OuiStack gap="lg">
<OuiStack gap="xs">
<OuiText size="sm" color="muted">Organization</OuiText>
<OuiText size="sm" weight="medium">{{ selectedOrgName }}</OuiText>
</OuiStack>

<OuiStack gap="xs">
<OuiText size="sm" color="muted">Current Plan</OuiText>
<OuiBadge :variant="selectedOrgCurrentPlan ? 'primary' : 'secondary'" size="sm">
{{ prettyPlan(selectedOrgCurrentPlan) || 'None' }}
</OuiBadge>
</OuiStack>

<OuiSelect
v-model="selectedPlanId"
label="New Plan"
:items="planSelectItems"
placeholder="Choose a plan..."
clearable
/>

<template v-if="selectedPlanInfo">
<OuiStack gap="sm" class="rounded-lg border border-border-muted p-3 bg-surface-muted/50">
<OuiText size="xs" weight="medium" color="muted" class="uppercase tracking-wide">Plan Resources</OuiText>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
<OuiText size="sm" color="secondary">CPU</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.cpuCores || '∞' }} cores</OuiText>
<OuiText size="sm" color="secondary">Memory</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.memoryBytes ? formatBytes(Number(selectedPlanInfo.memoryBytes)) : '∞' }}</OuiText>
<OuiText size="sm" color="secondary">Deployments</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.deploymentsMax || '∞' }}</OuiText>
<OuiText size="sm" color="secondary">VPS Instances</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.maxVpsInstances || '∞' }}</OuiText>
Comment on lines +162 to +168
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential ambiguity in zero value display: The code displays 0 values as '∞' (infinity) for CPU cores, deployments, and VPS instances (e.g., line 162: selectedPlanInfo.cpuCores || '∞'). However, in the database schema, 0 is used to represent "unlimited" for some fields (e.g., MaxVpsInstances in OrganizationPlan model), but for other fields like CPUCores, 0 likely means "none" rather than "unlimited".

This could confuse users if a plan actually has 0 CPU cores (which would be invalid) vs unlimited. Consider:

  1. Using a more explicit check like selectedPlanInfo.cpuCores === 0 || selectedPlanInfo.cpuCores === null ? '∞' : selectedPlanInfo.cpuCores
  2. Or displaying 0 as 0 and using -1 or null to represent unlimited
  3. Adding validation to ensure critical resources like CPU can't be 0
Suggested change
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.cpuCores || '∞' }} cores</OuiText>
<OuiText size="sm" color="secondary">Memory</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.memoryBytes ? formatBytes(Number(selectedPlanInfo.memoryBytes)) : '∞' }}</OuiText>
<OuiText size="sm" color="secondary">Deployments</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.deploymentsMax || '∞' }}</OuiText>
<OuiText size="sm" color="secondary">VPS Instances</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.maxVpsInstances || '∞' }}</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.cpuCores === 0 || selectedPlanInfo.cpuCores == null ? '∞' : selectedPlanInfo.cpuCores }} cores</OuiText>
<OuiText size="sm" color="secondary">Memory</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.memoryBytes === 0 || selectedPlanInfo.memoryBytes == null ? '∞' : formatBytes(Number(selectedPlanInfo.memoryBytes)) }}</OuiText>
<OuiText size="sm" color="secondary">Deployments</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.deploymentsMax === 0 || selectedPlanInfo.deploymentsMax == null ? '∞' : selectedPlanInfo.deploymentsMax }}</OuiText>
<OuiText size="sm" color="secondary">VPS Instances</OuiText>
<OuiText size="sm" class="font-mono text-right">{{ selectedPlanInfo.maxVpsInstances === 0 || selectedPlanInfo.maxVpsInstances == null ? '∞' : selectedPlanInfo.maxVpsInstances }}</OuiText>

Copilot uses AI. Check for mistakes.
<OuiText size="sm" color="secondary">Monthly Credits</OuiText>
<OuiText size="sm" class="font-mono text-right"><OuiCurrency :value="Number(selectedPlanInfo.monthlyFreeCreditsCents || 0)" /></OuiText>
</div>
</OuiStack>
</template>
</OuiStack>

<template #footer>
<OuiFlex justify="end" gap="sm">
<OuiButton variant="ghost" @click="setPlanDialogOpen = false">Cancel</OuiButton>
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing cleanup of selectedOrgId on dialog close: When the dialog is closed via the Cancel button (line 178), selectedOrgId is not reset to null. This could cause issues if the dialog is reopened for a different organization - the selectedOrgName and selectedOrgCurrentPlan computed properties would still reference the old organization until a new action is clicked.

Consider adding cleanup when the dialog closes:

<OuiButton variant="ghost" @click="setPlanDialogOpen = false; selectedOrgId.value = null; selectedPlanId.value = null">Cancel</OuiButton>

Or better yet, watch the setPlanDialogOpen value and reset on close.

Suggested change
<OuiButton variant="ghost" @click="setPlanDialogOpen = false">Cancel</OuiButton>
<OuiButton variant="ghost" @click="setPlanDialogOpen = false; selectedOrgId = null; selectedPlanId = null">Cancel</OuiButton>

Copilot uses AI. Check for mistakes.
<OuiButton
@click="setPlan"
:disabled="!selectedPlanId || setPlanLoading"
>
{{ setPlanLoading ? 'Updating...' : 'Set Plan' }}
</OuiButton>
</OuiFlex>
</template>
</OuiDialog>
</template>

<script setup lang="ts">
Expand All @@ -140,7 +195,7 @@ definePageMeta({
import { PlusIcon, MinusIcon } from "@heroicons/vue/24/outline";
import { computed, ref } from "vue";
import { useOrganizationsStore } from "~/stores/organizations";
import { OrganizationService } from "@obiente/proto";
import { OrganizationService, SuperadminService } from "@obiente/proto";
import { useConnectClient } from "~/lib/connect-client";
import { useToast } from "~/composables/useToast";
import SuperadminPageLayout from "~/components/superadmin/SuperadminPageLayout.vue";
Expand All @@ -156,7 +211,36 @@ const statusFilter = ref<string>("all");
const router = useRouter();
const organizationsStore = useOrganizationsStore();
const orgClient = useConnectClient(OrganizationService);
const saClient = useConnectClient(SuperadminService);
const isLoading = ref(false);
const setPlanDialogOpen = ref(false);
const selectedPlanId = ref(null);
const setPlanLoading = ref(false);
const selectedOrgId = ref(null);

const selectedOrgName = computed(() => {
if (!selectedOrgId.value) return '';
const org = organizations.value.find(o => o.id === selectedOrgId.value);
return org?.name || org?.slug || selectedOrgId.value;
});

const selectedOrgCurrentPlan = computed(() => {
if (!selectedOrgId.value) return null;
const org = organizations.value.find(o => o.id === selectedOrgId.value);
return org?.plan || null;
});

const planSelectItems = computed(() =>
(availablePlans.value || []).map(plan => ({
label: plan.name || prettyPlan(plan.id),
value: plan.id,
}))
);

const selectedPlanInfo = computed(() => {
if (!selectedPlanId.value) return null;
return (availablePlans.value || []).find(p => p.id === selectedPlanId.value) || null;
});

const planOptions = computed(() => {
const plans = new Set<string>();
Expand Down Expand Up @@ -203,8 +287,8 @@ function handleFilterChange(key: string, value: string) {
}
}

const manageOrgId = ref<string | null>(null);
const manageCreditsDialogOpen = ref(false);
const manageCreditsOrgId = ref<string | null>(null);
const manageCreditsCurrentBalance = ref<number>(0);
const manageCreditsAmount = ref("");
const manageCreditsAction = ref<"add" | "remove">("add");
Expand All @@ -221,6 +305,13 @@ const { data: organizationsData, refresh: refreshOrganizations } = await useClie
return response.organizations || [];
}
);
const { data: availablePlans, refresh: refreshPlans } = await useClientFetch(
"superadmin-plans-list",
async () => {
const response = await saClient.listPlans({});
return response.plans || [];
}
);

const organizations = computed(() => organizationsData.value || []);

Expand Down Expand Up @@ -269,7 +360,7 @@ watch(organizations, () => {
}, { immediate: true });

const openManageCredits = (orgId: string, currentBalance: number | bigint) => {
manageCreditsOrgId.value = orgId;
manageOrgId.value = orgId;
const balance = typeof currentBalance === 'bigint' ? currentBalance : BigInt(Number(currentBalance) || 0);
manageCreditsCurrentBalance.value = Number(balance);
manageCreditsAmount.value = "";
Expand Down Expand Up @@ -362,7 +453,7 @@ function viewOrganization(orgId: string) {
}

const numberFormatter = new Intl.NumberFormat();
const { formatDate, formatCurrency } = useUtils();
const { formatDate, formatCurrency, formatBytes } = useUtils();

function formatNumber(value?: number | bigint | null) {
if (value === undefined || value === null) return "0";
Expand Down Expand Up @@ -397,6 +488,15 @@ const getOrgActions = (row: any): Action[] => {
label: "Deployments",
onClick: () => openDeployments(row.id),
},
{
key: "setPlan",
label: "Set Plan",
onClick: () => {
selectedOrgId.value = row.id;
selectedPlanId.value = null;
setPlanDialogOpen.value = true;
},
},
{
key: "manage",
label: "Manage",
Expand All @@ -407,7 +507,7 @@ const getOrgActions = (row: any): Action[] => {
};

async function manageCredits() {
if (!manageCreditsOrgId.value || !manageCreditsAmount.value) return;
if (!manageOrgId.value || !manageCreditsAmount.value) return;
const amount = parseFloat(manageCreditsAmount.value);
if (isNaN(amount) || amount <= 0) {
return;
Expand All @@ -420,14 +520,14 @@ async function manageCredits() {
let response;
if (manageCreditsAction.value === "add") {
response = await orgClient.adminAddCredits({
organizationId: manageCreditsOrgId.value,
organizationId: manageOrgId.value,
amountCents,
note: manageCreditsNote.value || undefined,
});
toast.success(`Successfully added ${formatCurrency(Number(amountCents))} in credits`);
} else {
response = await orgClient.adminRemoveCredits({
organizationId: manageCreditsOrgId.value,
organizationId: manageOrgId.value,
amountCents,
note: manageCreditsNote.value || undefined,
});
Expand All @@ -445,5 +545,29 @@ async function manageCredits() {
manageCreditsLoading.value = false;
}
}

async function setPlan() {
if (!selectedOrgId.value || !selectedPlanId.value) return;

setPlanLoading.value = true;
const { toast } = useToast();
try {
await orgClient.adminSetPlan({
organizationId: selectedOrgId.value,
planId: selectedPlanId.value,
});
const planName = selectedPlanInfo.value?.name || prettyPlan(selectedPlanId.value);
toast.success(`Plan updated to ${planName}`);
setPlanDialogOpen.value = false;
selectedPlanId.value = null;
await refresh();
} catch (err: any) {
toast.error(err?.message || 'Failed to update plan');
} finally {
setPlanLoading.value = false;
}
}


</script>

5 changes: 4 additions & 1 deletion apps/dashboard/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export default defineNuxtConfig({
// Use API Gateway for all requests (routes to microservices)
// When running locally (not in Docker), use localhost with Traefik port
// When running in Docker, use api-gateway service name
apiHostInternal: process.env.NUXT_API_HOST_INTERNAL || process.env.NUXT_PUBLIC_API_HOST || "http://localhost:80",
apiHostInternal: process.env.NUXT_API_HOST_INTERNAL || process.env.NUXT_PUBLIC_API_HOST || "http://api.localhost",
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This configuration change appears unrelated to the stated purpose of this PR (adding superadmin set-plan functionality). The apiHostInternal URL is changed from http://localhost:80 to http://api.localhost, which is a significant infrastructure change that could affect local development routing.

If this change is intentional and necessary, it should be explained in the PR description or split into a separate PR. Otherwise, it should be reverted to keep this PR focused on the set-plan feature.

Copilot uses AI. Check for mistakes.
githubClientSecret: process.env.GITHUB_CLIENT_SECRET || "", // Server-side only - never expose to client
session: {
password: "changeme_" + crypto.randomUUID(), // CHANGE THIS IN PRODUCTION, should be at least 32 characters
Expand Down Expand Up @@ -196,6 +196,9 @@ export default defineNuxtConfig({
port: 3000,
host: "0.0.0.0",
},
future: {
compatibilityVersion: 4
},
Comment on lines +199 to +201
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This configuration change (setting compatibilityVersion: 4) appears unrelated to the stated purpose of this PR. Changing the Nuxt compatibility version is a significant framework upgrade that could have wide-ranging effects on the application behavior and should be:

  1. Thoroughly tested across the entire application
  2. Documented in the PR description
  3. Ideally handled in a separate PR focused on the framework upgrade

If this change is intentional and necessary for the set-plan feature, please explain why in the PR description. Otherwise, it should be reverted.

Copilot uses AI. Check for mistakes.

app: {
head: {
Expand Down
Loading
Loading