Compare commits

..

1 Commits

Author SHA1 Message Date
e5f9da6c26 feat(ui): add sortable column headers to domains table 2026-03-06 06:58:29 +00:00
7 changed files with 252 additions and 161 deletions

View File

@@ -1,13 +0,0 @@
node_modules
.next
.git
*.log
logs
.env
.env.*
!.env.example
.env.local
.env.*.local
.vscode
.idea
.DS_Store

View File

@@ -1,22 +1,14 @@
NODE_ENV=production
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_APP_URL=http://localhost:3000
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
PLESK_CRED_ENC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
CRON_SHARED_SECRET=
# Existing runtime keys used by current codebase
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
STRIPE_PRICE_ID=

View File

@@ -1,32 +0,0 @@
FROM cgr.dev/chainguard/node:20-dev AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM cgr.dev/chainguard/node:20-dev AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM cgr.dev/chainguard/node:20-dev AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM cgr.dev/chainguard/node:20 AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.mjs ./next.config.mjs
COPY --from=builder /app/.next ./.next
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-H", "0.0.0.0", "-p", "3000"]

View File

@@ -1,13 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json(
{
ok: true,
service: "plesk-agency-portal",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV ?? "development",
},
{ status: 200 },
);
}

View File

@@ -55,6 +55,15 @@ type Domain = {
updated_at: string;
};
type DomainSortKey =
| "domain"
| "status"
| "hosting"
| "created"
| "aliases"
| "last_seen"
| "actions";
type Props = {
instances: Instance[];
subscriptions: Subscription[];
@@ -198,6 +207,10 @@ export function DashboardControls({
);
const [domainSearch, setDomainSearch] = useState("");
const [domainStatus, setDomainStatus] = useState("all");
const [domainSort, setDomainSort] = useState<{
key: DomainSortKey;
direction: "asc" | "desc";
}>({ key: "domain", direction: "asc" });
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [isConnectPanelOpen, setIsConnectPanelOpen] = useState(!hasInstances);
@@ -295,6 +308,112 @@ export function DashboardControls({
return statusMatches && searchMatches;
});
const sortedDomains = filteredDomains
.map((domain, index) => ({ domain, index }))
.sort((a, b) => {
function compareNullable<T>(
left: T | null | undefined,
right: T | null | undefined,
compare: (leftValue: T, rightValue: T) => number,
) {
const leftMissing = left == null;
const rightMissing = right == null;
if (leftMissing && rightMissing) return 0;
if (leftMissing) return 1;
if (rightMissing) return -1;
return compare(left, right);
}
function compareStrings(
left: string | null | undefined,
right: string | null | undefined,
) {
return compareNullable(left, right, (l, r) =>
l.localeCompare(r, undefined, { sensitivity: "base" }),
);
}
function compareNumbers(
left: number | null | undefined,
right: number | null | undefined,
) {
return compareNullable(left, right, (l, r) => l - r);
}
function toTime(value: string | null | undefined) {
if (!value) return null;
const time = new Date(value).getTime();
return Number.isNaN(time) ? null : time;
}
const leftDomain = a.domain;
const rightDomain = b.domain;
let comparison = 0;
if (domainSort.key === "domain") {
comparison = compareStrings(
leftDomain.domain_name,
rightDomain.domain_name,
);
} else if (domainSort.key === "status") {
comparison = compareStrings(
formatDomainStatusLabel(leftDomain.status),
formatDomainStatusLabel(rightDomain.status),
);
} else if (domainSort.key === "hosting") {
comparison = compareStrings(
leftDomain.hosting_type,
rightDomain.hosting_type,
);
} else if (domainSort.key === "created") {
comparison = compareNumbers(
toTime(leftDomain.source_created_at),
toTime(rightDomain.source_created_at),
);
} else if (domainSort.key === "aliases") {
comparison = compareNumbers(
leftDomain.aliases_count,
rightDomain.aliases_count,
);
} else if (domainSort.key === "last_seen") {
comparison = compareNumbers(
toTime(leftDomain.updated_at),
toTime(rightDomain.updated_at),
);
} else if (domainSort.key === "actions") {
comparison = compareNumbers(
leftDomain.subscription_id ? 1 : 0,
rightDomain.subscription_id ? 1 : 0,
);
}
if (comparison === 0) {
comparison = a.index - b.index;
}
return domainSort.direction === "asc" ? comparison : comparison * -1;
})
.map((entry) => entry.domain);
function toggleDomainSort(key: DomainSortKey) {
setDomainSort((current) => {
if (current.key === key) {
return {
key,
direction: current.direction === "asc" ? "desc" : "asc",
};
}
return { key, direction: "asc" };
});
}
function getSortIndicator(key: DomainSortKey) {
if (domainSort.key !== key) return "↕";
return domainSort.direction === "asc" ? "↑" : "↓";
}
const instanceHealthSummary = instances.reduce(
(acc, instance) => {
const status = (instance.health_status ?? "unknown").toLowerCase();
@@ -1094,24 +1213,143 @@ export function DashboardControls({
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-slate-200 text-xs uppercase text-slate-500">
<th className="px-2 py-2">Domain</th>
<th className="px-2 py-2">Subscription Status</th>
<th className="px-2 py-2">Hosting</th>
<th className="px-2 py-2">Created</th>
<th className="px-2 py-2">Aliases</th>
<th className="px-2 py-2">Last seen</th>
<th className="px-2 py-2">Actions</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("domain")}
className="inline-flex items-center gap-1"
>
Domain
<span
className={
domainSort.key === "domain"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("domain")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("status")}
className="inline-flex items-center gap-1"
>
Subscription Status
<span
className={
domainSort.key === "status"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("status")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("hosting")}
className="inline-flex items-center gap-1"
>
Hosting
<span
className={
domainSort.key === "hosting"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("hosting")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("created")}
className="inline-flex items-center gap-1"
>
Created
<span
className={
domainSort.key === "created"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("created")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("aliases")}
className="inline-flex items-center gap-1"
>
Aliases
<span
className={
domainSort.key === "aliases"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("aliases")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("last_seen")}
className="inline-flex items-center gap-1"
>
Last seen
<span
className={
domainSort.key === "last_seen"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("last_seen")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("actions")}
className="inline-flex items-center gap-1"
>
Actions
<span
className={
domainSort.key === "actions"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("actions")}
</span>
</button>
</th>
</tr>
</thead>
<tbody>
{filteredDomains.length === 0 ? (
{sortedDomains.length === 0 ? (
<tr>
<td className="px-2 py-3 text-slate-500" colSpan={7}>
No domains found.
</td>
</tr>
) : (
filteredDomains.map((domain) => (
sortedDomains.map((domain) => (
<tr key={domain.id} className="border-b border-slate-100">
<td className="px-2 py-2 font-medium text-slate-900">
<a

View File

@@ -1,23 +0,0 @@
services:
web:
container_name: plesk-agency-portal
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- .env
ports:
- "3000:3000"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s

View File

@@ -1,58 +0,0 @@
# Production Deployment Runtime Foundation
This project can be deployed in a dedicated Proxmox container using Docker Compose. The files added here establish a minimal production runtime baseline without changing application business logic or Supabase security boundaries.
## Dockerfile purpose
The `Dockerfile` uses a multi-stage build on Node 20:
- installs dependencies
- runs `next build`
- prepares a lean runtime image with production dependencies
- runs the app with `next start` on port `3000`
This keeps the runtime image small and focused on serving the built Next.js app.
## docker-compose.prod.yml purpose
`docker-compose.prod.yml` defines one service:
- service name: `web`
- container name: `plesk-agency-portal`
- restart policy: `unless-stopped`
- environment from `.env`
- port mapping `3000:3000`
- health check against `/api/health`
## External Supabase requirement
Supabase remains external to this application container. The app container must only connect to Supabase via environment variables. No local Supabase container is included in this production compose file.
## Health endpoint usage
`GET /api/health` provides a fast, dependency-light runtime check for container orchestration and uptime checks.
Example:
```bash
curl -s http://localhost:3000/api/health
```
Expected response shape:
- `ok: true`
- `service: "plesk-agency-portal"`
- `timestamp`
- `environment`
## Production environment expectations
Before deploying, set production-safe values in `.env` (using `.env.example` as the template), including:
- app URL and `NODE_ENV`
- Supabase public and server credentials
- Plesk credential encryption key
- Stripe secrets
- cron/job shared secrets
Keep secrets out of source control.