Skip to content

Commit 04f125a

Browse files
authored
Merge pull request #188 from diptamahardhika/chore/dev-compose-persist-pb-data
feat(instance): multi-select & bulk delete; dev compose; persist pocketbase data volume in dev compose
2 parents c33328f + 435694f commit 04f125a

10 files changed

Lines changed: 269 additions & 6 deletions

File tree

.dockerignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.git
2+
server/
3+
**/node_modules
4+
**/dist
5+
**/.next
6+
**/.turbo
7+
**/.cache
8+
**/coverage
9+
**/*.log
10+
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Build and Publish Frontend Image
2+
3+
on:
4+
push:
5+
branches: [ "develop", "feature/**" ]
6+
paths:
7+
- 'application/**'
8+
- '.github/workflows/build-frontend-image.yml'
9+
workflow_dispatch:
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: read
16+
packages: write
17+
18+
env:
19+
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/checkcle-frontend
20+
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Set up QEMU
26+
uses: docker/setup-qemu-action@v3
27+
28+
- name: Set up Docker Buildx
29+
uses: docker/setup-buildx-action@v3
30+
31+
- name: Log in to GHCR
32+
uses: docker/login-action@v3
33+
with:
34+
registry: ghcr.io
35+
username: ${{ github.actor }}
36+
password: ${{ secrets.GITHUB_TOKEN }}
37+
38+
- name: Extract metadata (tags, labels)
39+
id: meta
40+
uses: docker/metadata-action@v5
41+
with:
42+
images: ${{ env.IMAGE_NAME }}
43+
tags: |
44+
type=ref,event=branch
45+
type=sha
46+
47+
- name: Build and push
48+
uses: docker/build-push-action@v6
49+
with:
50+
context: application
51+
file: application/Dockerfile
52+
push: true
53+
platforms: linux/amd64,linux/arm64
54+
tags: ${{ steps.meta.outputs.tags }}
55+
labels: ${{ steps.meta.outputs.labels }}
56+
cache-from: type=gha
57+
cache-to: type=gha,mode=max
58+
provenance: false
59+

Dockerfile.frontend

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
FROM node:20-alpine AS build
2+
WORKDIR /app
3+
4+
# Install dependencies
5+
COPY application/package.json application/package-lock.json ./
6+
RUN npm ci
7+
8+
# Copy source and build
9+
COPY application/. .
10+
RUN npm run build
11+
12+
FROM nginx:1.27-alpine
13+
RUN rm -rf /usr/share/nginx/html/*
14+
COPY --from=build /app/dist /usr/share/nginx/html
15+
16+
# SPA fallback + static asset caching
17+
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
18+
19+
EXPOSE 80
20+
CMD ["nginx","-g","daemon off;"]
21+

application/.dockerignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.git
2+
node_modules
3+
dist
4+
.cache
5+
*.log
6+

application/Dockerfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# syntax=docker/dockerfile:1.7
2+
FROM node:20-bullseye-slim AS build
3+
WORKDIR /app
4+
ENV npm_config_fund=false \
5+
npm_config_audit=false \
6+
npm_config_progress=false
7+
COPY package.json package-lock.json ./
8+
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --fund=false
9+
COPY . .
10+
RUN --mount=type=cache,target=/root/.npm npm run build
11+
12+
FROM nginx:1.27-alpine
13+
RUN rm -rf /usr/share/nginx/html/*
14+
COPY --from=build /app/dist /usr/share/nginx/html
15+
# SPA fallback + asset caching
16+
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
17+
EXPOSE 80
18+
CMD ["nginx","-g","daemon off;"]

application/src/components/servers/ServerTable.tsx

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
33
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
44
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
55
import { Button } from "@/components/ui/button";
6+
import { Checkbox } from "@/components/ui/checkbox";
67
import { Input } from "@/components/ui/input";
78
import { Badge } from "@/components/ui/badge";
89
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -33,6 +34,9 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
3334
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
3435
const [isDeleting, setIsDeleting] = useState(false);
3536
const [pausingServers, setPausingServers] = useState<Set<string>>(new Set());
37+
const [selectedServerIds, setSelectedServerIds] = useState<Set<string>>(new Set());
38+
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
39+
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
3640
const navigate = useNavigate();
3741
const { toast } = useToast();
3842

@@ -42,6 +46,25 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
4246
server.ip_address.toLowerCase().includes(searchTerm.toLowerCase())
4347
);
4448

49+
const allVisibleSelected = filteredServers.length > 0 && filteredServers.every(s => selectedServerIds.has(s.id));
50+
const someVisibleSelected = filteredServers.some(s => selectedServerIds.has(s.id)) && !allVisibleSelected;
51+
52+
const toggleSelectAllVisible = (checked: boolean) => {
53+
const newSet = new Set(selectedServerIds);
54+
if (checked) {
55+
filteredServers.forEach(s => newSet.add(s.id));
56+
} else {
57+
filteredServers.forEach(s => newSet.delete(s.id));
58+
}
59+
setSelectedServerIds(newSet);
60+
};
61+
62+
const toggleSelectOne = (serverId: string, checked: boolean) => {
63+
const newSet = new Set(selectedServerIds);
64+
if (checked) newSet.add(serverId); else newSet.delete(serverId);
65+
setSelectedServerIds(newSet);
66+
};
67+
4568
const handleViewDetails = (serverId: string) => {
4669
navigate(`/server-detail/${serverId}`);
4770
};
@@ -138,6 +161,33 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
138161
}
139162
};
140163

164+
const confirmBulkDelete = async () => {
165+
if (selectedServerIds.size === 0 || isBulkDeleting) return;
166+
try {
167+
setIsBulkDeleting(true);
168+
const ids = Array.from(selectedServerIds);
169+
const deletions = ids.map(id => pb.collection('servers').delete(id));
170+
const results = await Promise.allSettled(deletions);
171+
const failed = results.filter(r => r.status === 'rejected').length;
172+
173+
if (failed === 0) {
174+
toast({ title: "Servers deleted", description: `${ids.length} server(s) have been deleted.` });
175+
} else if (failed === ids.length) {
176+
toast({ variant: "destructive", title: "Error", description: "Failed to delete selected servers. Please try again." });
177+
} else {
178+
toast({ variant: "destructive", title: "Partial success", description: `Deleted ${ids.length - failed}, failed ${failed}.` });
179+
}
180+
181+
onRefresh();
182+
setSelectedServerIds(new Set());
183+
setBulkDeleteDialogOpen(false);
184+
} catch (_e) {
185+
toast({ variant: "destructive", title: "Error", description: "Failed to delete selected servers. Please try again." });
186+
} finally {
187+
setIsBulkDeleting(false);
188+
}
189+
};
190+
141191
const CustomProgressBar = ({
142192
value,
143193
label,
@@ -234,6 +284,18 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
234284
className="pl-8"
235285
/>
236286
</div>
287+
{selectedServerIds.size > 0 && (
288+
<div className="hidden sm:block text-sm text-muted-foreground mr-2">
289+
{selectedServerIds.size} selected
290+
</div>
291+
)}
292+
<Button
293+
onClick={() => setBulkDeleteDialogOpen(true)}
294+
variant="destructive"
295+
disabled={selectedServerIds.size === 0}
296+
>
297+
Delete Selected
298+
</Button>
237299
<Button onClick={onRefresh} variant="outline" size="icon">
238300
<RefreshCw className="h-4 w-4" />
239301
</Button>
@@ -250,6 +312,16 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
250312
<Table>
251313
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
252314
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
315+
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} w-10`}>
316+
<div onClick={(e) => e.stopPropagation()}>
317+
<Checkbox
318+
checked={allVisibleSelected}
319+
onCheckedChange={(v) => toggleSelectAllVisible(Boolean(v))}
320+
aria-label="Select all"
321+
indeterminate={someVisibleSelected}
322+
/>
323+
</div>
324+
</TableHead>
253325
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('name')}</TableHead>
254326
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('status')}</TableHead>
255327
<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)
270342
const isPaused = server.status === "paused";
271343
const isProcessing = pausingServers.has(server.id);
272344

345+
const isSelected = selectedServerIds.has(server.id);
273346
return (
274347
<TableRow
275348
key={server.id}
276-
className="hover:bg-muted/50 cursor-pointer"
349+
className={`hover:bg-muted/50 cursor-pointer ${isSelected ? 'bg-muted/30' : ''}`}
277350
onClick={() => handleViewDetails(server.id)}
278351
>
352+
<TableCell onClick={(e) => e.stopPropagation()}>
353+
<Checkbox
354+
checked={isSelected}
355+
onCheckedChange={(v) => toggleSelectOne(server.id, Boolean(v))}
356+
aria-label={`Select ${server.name}`}
357+
/>
358+
</TableCell>
279359
<TableCell className="font-medium">
280360
<div className="truncate" title={server.name}>
281361
{server.name}
@@ -427,6 +507,30 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
427507
</AlertDialogFooter>
428508
</AlertDialogContent>
429509
</AlertDialog>
510+
511+
{/* Bulk Delete Confirmation Dialog */}
512+
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
513+
<AlertDialogContent>
514+
<AlertDialogHeader>
515+
<AlertDialogTitle>Delete selected servers?</AlertDialogTitle>
516+
<AlertDialogDescription>
517+
This action cannot be undone. This will permanently delete {selectedServerIds.size} server(s) and all of their monitoring data.
518+
</AlertDialogDescription>
519+
</AlertDialogHeader>
520+
<AlertDialogFooter>
521+
<AlertDialogCancel disabled={isBulkDeleting}>
522+
{t('cancel')}
523+
</AlertDialogCancel>
524+
<AlertDialogAction
525+
onClick={confirmBulkDelete}
526+
disabled={isBulkDeleting}
527+
className="bg-red-600 text-white hover:bg-red-700"
528+
>
529+
{isBulkDeleting ? t('deleting') : 'Delete Selected'}
530+
</AlertDialogAction>
531+
</AlertDialogFooter>
532+
</AlertDialogContent>
533+
</AlertDialog>
430534
</>
431535
);
432536
};

application/src/translations/en/instance.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export const instanceTranslations: InstanceTranslations = {
1818
loadingServers: "Loading servers...",
1919
searchServersPlaceholder: "Search servers...",
2020
noServersFound: "No servers found",
21+
deleteSelected: "Delete Selected",
22+
deleteSelectedConfirmTitle: "Delete selected servers?",
23+
deleteSelectedConfirmDesc: "This action cannot be undone. This will permanently delete {count} server(s) and all of their monitoring data.",
24+
selectedCount: "{count} selected",
25+
serversDeleted: "Servers deleted",
26+
serversDeletedDesc: "{count} server(s) have been deleted.",
27+
partialSuccess: "Partial success",
2128
name: "Name",
2229
status: "Status",
2330
OS: "OS",

application/src/translations/types/instance.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ export interface InstanceTranslations {
1414
loadingServers: string;
1515
searchServersPlaceholder: string;
1616
noServersFound: string;
17+
deleteSelected: string;
18+
deleteSelectedConfirmTitle: string;
19+
deleteSelectedConfirmDesc: string;
20+
selectedCount: string;
21+
serversDeleted: string;
22+
serversDeletedDesc: string;
23+
partialSuccess: string;
1724
name: string;
1825
status: string;
1926
OS: string;

application/tailwind.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import type { Config } from "tailwindcss";
33

44
export default {
55
darkMode: ["class"],
6+
safelist: [
7+
"text-purple-400",
8+
"text-blue-400",
9+
"text-cyan-400",
10+
"text-emerald-400",
11+
"text-amber-400",
12+
"text-indigo-400",
13+
"text-rose-400"
14+
],
615
content: [
716
"./pages/**/*.{ts,tsx}",
817
"./components/**/*.{ts,tsx}",

docker/docker-compose-dev.yml

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,38 @@ version: '3.9'
22

33
services:
44
checkcle:
5-
build:
6-
context: .
7-
dockerfile: Dockerfile
8-
container_name: checkcle
5+
image: operacle/checkcle:latest
6+
container_name: checkcle-dev
97
restart: unless-stopped
108
ports:
119
- "8090:8090"
1210
volumes:
13-
- /var/pb_data:/mnt/pb_data # Updated mount target to match CMD in Dockerfile
11+
- pb_data:/mnt/pb_data # Persist PocketBase data across rebuilds
1412
ulimits:
1513
nofile:
1614
soft: 4096
1715
hard: 8192
16+
17+
frontend:
18+
image: ghcr.io/diptamahardhika/checkcle-frontend:sha-b2ae075
19+
container_name: checkcle-frontend
20+
ports:
21+
- "8990:80"
22+
environment:
23+
- NODE_ENV=development
24+
depends_on:
25+
- checkcle
26+
# Fall back to dev server when FRONTEND_IMAGE is not set
27+
deploy:
28+
replicas: 1
29+
# Compose doesn't support conditional configs; document usage:
30+
# - To use prebuilt image: export FRONTEND_IMAGE=ghcr.io/<owner>/checkcle-frontend:<tag>
31+
# - To use live dev server instead, comment 'image' above and uncomment the block below:
32+
# working_dir: /app
33+
# command: sh -c "npm ci && npm run dev -- --host 0.0.0.0 --port 8990"
34+
# volumes:
35+
# - ../application:/app
36+
# - /app/node_modules
37+
38+
volumes:
39+
pb_data:

0 commit comments

Comments
 (0)