chore(dev): persist pocketbase data volume in dev compose and docs

This commit is contained in:
Pradipta Mahardika
2025-10-16 23:43:31 +07:00
parent c33328f977
commit 435694f694
10 changed files with 269 additions and 6 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
server/
**/node_modules
**/dist
**/.next
**/.turbo
**/.cache
**/coverage
**/*.log
@@ -0,0 +1,59 @@
name: Build and Publish Frontend Image
on:
push:
branches: [ "develop", "feature/**" ]
paths:
- 'application/**'
- '.github/workflows/build-frontend-image.yml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/checkcle-frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: application
file: application/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
+21
View File
@@ -0,0 +1,21 @@
FROM node:20-alpine AS build
WORKDIR /app
# Install dependencies
COPY application/package.json application/package-lock.json ./
RUN npm ci
# Copy source and build
COPY application/. .
RUN npm run build
FROM nginx:1.27-alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=build /app/dist /usr/share/nginx/html
# SPA fallback + static asset caching
RUN printf 'server {\n listen 80;\n server_name _;\n root /usr/share/nginx/html;\n index index.html;\n location / { try_files $uri $uri/ /index.html; }\n location ~* \\.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { expires 7d; add_header Cache-Control "public, max-age=604800, immutable"; try_files $uri =404; }\n}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx","-g","daemon off;"]
+6
View File
@@ -0,0 +1,6 @@
.git
node_modules
dist
.cache
*.log
+18
View File
@@ -0,0 +1,18 @@
# syntax=docker/dockerfile:1.7
FROM node:20-bullseye-slim AS build
WORKDIR /app
ENV npm_config_fund=false \
npm_config_audit=false \
npm_config_progress=false
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --fund=false
COPY . .
RUN --mount=type=cache,target=/root/.npm npm run build
FROM nginx:1.27-alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=build /app/dist /usr/share/nginx/html
# SPA fallback + asset caching
RUN printf 'server {\n listen 80;\n server_name _;\n root /usr/share/nginx/html;\n index index.html;\n location / { try_files $uri $uri/ /index.html; }\n location ~* \\.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { expires 7d; add_header Cache-Control "public, max-age=604800, immutable"; try_files $uri =404; }\n}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx","-g","daemon off;"]
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -33,6 +34,9 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [pausingServers, setPausingServers] = useState<Set<string>>(new Set());
const [selectedServerIds, setSelectedServerIds] = useState<Set<string>>(new Set());
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const navigate = useNavigate();
const { toast } = useToast();
@@ -42,6 +46,25 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
server.ip_address.toLowerCase().includes(searchTerm.toLowerCase())
);
const allVisibleSelected = filteredServers.length > 0 && filteredServers.every(s => selectedServerIds.has(s.id));
const someVisibleSelected = filteredServers.some(s => selectedServerIds.has(s.id)) && !allVisibleSelected;
const toggleSelectAllVisible = (checked: boolean) => {
const newSet = new Set(selectedServerIds);
if (checked) {
filteredServers.forEach(s => newSet.add(s.id));
} else {
filteredServers.forEach(s => newSet.delete(s.id));
}
setSelectedServerIds(newSet);
};
const toggleSelectOne = (serverId: string, checked: boolean) => {
const newSet = new Set(selectedServerIds);
if (checked) newSet.add(serverId); else newSet.delete(serverId);
setSelectedServerIds(newSet);
};
const handleViewDetails = (serverId: string) => {
navigate(`/server-detail/${serverId}`);
};
@@ -138,6 +161,33 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
}
};
const confirmBulkDelete = async () => {
if (selectedServerIds.size === 0 || isBulkDeleting) return;
try {
setIsBulkDeleting(true);
const ids = Array.from(selectedServerIds);
const deletions = ids.map(id => pb.collection('servers').delete(id));
const results = await Promise.allSettled(deletions);
const failed = results.filter(r => r.status === 'rejected').length;
if (failed === 0) {
toast({ title: "Servers deleted", description: `${ids.length} server(s) have been deleted.` });
} else if (failed === ids.length) {
toast({ variant: "destructive", title: "Error", description: "Failed to delete selected servers. Please try again." });
} else {
toast({ variant: "destructive", title: "Partial success", description: `Deleted ${ids.length - failed}, failed ${failed}.` });
}
onRefresh();
setSelectedServerIds(new Set());
setBulkDeleteDialogOpen(false);
} catch (_e) {
toast({ variant: "destructive", title: "Error", description: "Failed to delete selected servers. Please try again." });
} finally {
setIsBulkDeleting(false);
}
};
const CustomProgressBar = ({
value,
label,
@@ -234,6 +284,18 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
className="pl-8"
/>
</div>
{selectedServerIds.size > 0 && (
<div className="hidden sm:block text-sm text-muted-foreground mr-2">
{selectedServerIds.size} selected
</div>
)}
<Button
onClick={() => setBulkDeleteDialogOpen(true)}
variant="destructive"
disabled={selectedServerIds.size === 0}
>
Delete Selected
</Button>
<Button onClick={onRefresh} variant="outline" size="icon">
<RefreshCw className="h-4 w-4" />
</Button>
@@ -250,6 +312,16 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
<Table>
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} w-10`}>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={allVisibleSelected}
onCheckedChange={(v) => toggleSelectAllVisible(Boolean(v))}
aria-label="Select all"
indeterminate={someVisibleSelected}
/>
</div>
</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('name')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('status')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('OS')}</TableHead>
@@ -270,12 +342,20 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
const isPaused = server.status === "paused";
const isProcessing = pausingServers.has(server.id);
const isSelected = selectedServerIds.has(server.id);
return (
<TableRow
key={server.id}
className="hover:bg-muted/50 cursor-pointer"
className={`hover:bg-muted/50 cursor-pointer ${isSelected ? 'bg-muted/30' : ''}`}
onClick={() => handleViewDetails(server.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={(v) => toggleSelectOne(server.id, Boolean(v))}
aria-label={`Select ${server.name}`}
/>
</TableCell>
<TableCell className="font-medium">
<div className="truncate" title={server.name}>
{server.name}
@@ -427,6 +507,30 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Bulk Delete Confirmation Dialog */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete selected servers?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete {selectedServerIds.size} server(s) and all of their monitoring data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBulkDeleting}>
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmBulkDelete}
disabled={isBulkDeleting}
className="bg-red-600 text-white hover:bg-red-700"
>
{isBulkDeleting ? t('deleting') : 'Delete Selected'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
@@ -18,6 +18,13 @@ export const instanceTranslations: InstanceTranslations = {
loadingServers: "Loading servers...",
searchServersPlaceholder: "Search servers...",
noServersFound: "No servers found",
deleteSelected: "Delete Selected",
deleteSelectedConfirmTitle: "Delete selected servers?",
deleteSelectedConfirmDesc: "This action cannot be undone. This will permanently delete {count} server(s) and all of their monitoring data.",
selectedCount: "{count} selected",
serversDeleted: "Servers deleted",
serversDeletedDesc: "{count} server(s) have been deleted.",
partialSuccess: "Partial success",
name: "Name",
status: "Status",
OS: "OS",
@@ -14,6 +14,13 @@ export interface InstanceTranslations {
loadingServers: string;
searchServersPlaceholder: string;
noServersFound: string;
deleteSelected: string;
deleteSelectedConfirmTitle: string;
deleteSelectedConfirmDesc: string;
selectedCount: string;
serversDeleted: string;
serversDeletedDesc: string;
partialSuccess: string;
name: string;
status: string;
OS: string;
+9
View File
@@ -3,6 +3,15 @@ import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
safelist: [
"text-purple-400",
"text-blue-400",
"text-cyan-400",
"text-emerald-400",
"text-amber-400",
"text-indigo-400",
"text-rose-400"
],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
+27 -5
View File
@@ -2,16 +2,38 @@ version: '3.9'
services:
checkcle:
build:
context: .
dockerfile: Dockerfile
container_name: checkcle
image: operacle/checkcle:latest
container_name: checkcle-dev
restart: unless-stopped
ports:
- "8090:8090"
volumes:
- /var/pb_data:/mnt/pb_data # Updated mount target to match CMD in Dockerfile
- pb_data:/mnt/pb_data # Persist PocketBase data across rebuilds
ulimits:
nofile:
soft: 4096
hard: 8192
frontend:
image: ghcr.io/diptamahardhika/checkcle-frontend:sha-b2ae075
container_name: checkcle-frontend
ports:
- "8990:80"
environment:
- NODE_ENV=development
depends_on:
- checkcle
# Fall back to dev server when FRONTEND_IMAGE is not set
deploy:
replicas: 1
# Compose doesn't support conditional configs; document usage:
# - To use prebuilt image: export FRONTEND_IMAGE=ghcr.io/<owner>/checkcle-frontend:<tag>
# - To use live dev server instead, comment 'image' above and uncomment the block below:
# working_dir: /app
# command: sh -c "npm ci && npm run dev -- --host 0.0.0.0 --port 8990"
# volumes:
# - ../application:/app
# - /app/node_modules
volumes:
pb_data: