From 435694f69443e931127fb73b154d4ba5e64143c6 Mon Sep 17 00:00:00 2001 From: Pradipta Mahardika Date: Thu, 16 Oct 2025 23:43:31 +0700 Subject: [PATCH] chore(dev): persist pocketbase data volume in dev compose and docs --- .dockerignore | 10 ++ .github/workflows/build-frontend-image.yml | 59 ++++++++++ Dockerfile.frontend | 21 ++++ application/.dockerignore | 6 + application/Dockerfile | 18 +++ .../src/components/servers/ServerTable.tsx | 106 +++++++++++++++++- application/src/translations/en/instance.ts | 7 ++ .../src/translations/types/instance.ts | 7 ++ application/tailwind.config.ts | 9 ++ docker/docker-compose-dev.yml | 32 +++++- 10 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/build-frontend-image.yml create mode 100644 Dockerfile.frontend create mode 100644 application/.dockerignore create mode 100644 application/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b9caf00 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +server/ +**/node_modules +**/dist +**/.next +**/.turbo +**/.cache +**/coverage +**/*.log + diff --git a/.github/workflows/build-frontend-image.yml b/.github/workflows/build-frontend-image.yml new file mode 100644 index 0000000..93d3d4d --- /dev/null +++ b/.github/workflows/build-frontend-image.yml @@ -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 + diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..07e0855 --- /dev/null +++ b/Dockerfile.frontend @@ -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;"] + diff --git a/application/.dockerignore b/application/.dockerignore new file mode 100644 index 0000000..0bacb13 --- /dev/null +++ b/application/.dockerignore @@ -0,0 +1,6 @@ +.git +node_modules +dist +.cache +*.log + diff --git a/application/Dockerfile b/application/Dockerfile new file mode 100644 index 0000000..71b4294 --- /dev/null +++ b/application/Dockerfile @@ -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;"] diff --git a/application/src/components/servers/ServerTable.tsx b/application/src/components/servers/ServerTable.tsx index 4a3ea01..aa9ab03 100644 --- a/application/src/components/servers/ServerTable.tsx +++ b/application/src/components/servers/ServerTable.tsx @@ -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(null); const [isDeleting, setIsDeleting] = useState(false); const [pausingServers, setPausingServers] = useState>(new Set()); + const [selectedServerIds, setSelectedServerIds] = useState>(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" /> + {selectedServerIds.size > 0 && ( +
+ {selectedServerIds.size} selected +
+ )} + @@ -250,6 +312,16 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) + +
e.stopPropagation()}> + toggleSelectAllVisible(Boolean(v))} + aria-label="Select all" + indeterminate={someVisibleSelected} + /> +
+
{t('name')} {t('status')} {t('OS')} @@ -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 ( handleViewDetails(server.id)} > + e.stopPropagation()}> + toggleSelectOne(server.id, Boolean(v))} + aria-label={`Select ${server.name}`} + /> +
{server.name} @@ -427,6 +507,30 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) + + {/* Bulk Delete Confirmation Dialog */} + + + + Delete selected servers? + + This action cannot be undone. This will permanently delete {selectedServerIds.size} server(s) and all of their monitoring data. + + + + + {t('cancel')} + + + {isBulkDeleting ? t('deleting') : 'Delete Selected'} + + + + ); }; diff --git a/application/src/translations/en/instance.ts b/application/src/translations/en/instance.ts index 06ca2a6..739bca9 100644 --- a/application/src/translations/en/instance.ts +++ b/application/src/translations/en/instance.ts @@ -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", diff --git a/application/src/translations/types/instance.ts b/application/src/translations/types/instance.ts index 37aca4e..d5ef40f 100644 --- a/application/src/translations/types/instance.ts +++ b/application/src/translations/types/instance.ts @@ -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; diff --git a/application/tailwind.config.ts b/application/tailwind.config.ts index 70ce0f5..23835c8 100644 --- a/application/tailwind.config.ts +++ b/application/tailwind.config.ts @@ -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}", diff --git a/docker/docker-compose-dev.yml b/docker/docker-compose-dev.yml index 41cab01..5825693 100644 --- a/docker/docker-compose-dev.yml +++ b/docker/docker-compose-dev.yml @@ -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//checkcle-frontend: + # - 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: