Changes to the DAD Project
This tutorial addresses some mandatory changes to our deployments, as well as some suggestions.
Updates to the deployments
In order to keep the cluster running we implemented a few quotas and limits. They will be enforced directly by the cluster but we can help by defining the resources our deployments need.
Another implementation was a definition of priorities that tries to keep the pods that run Laravel and MySQl up. This is useful because both of these pods hold state and if they are the last to go in the case of resource shortage we don't need to run commands like php artisan migrate
.
The changes are simple and they involve all our deployments:
- deployment\kubernetes-laravel.yml
- deployment\kubernetes-mysql.yml
- deployment\kubernetes-vue.yml
- deployment\kubernetes-ws.yml
The relevant code is:
priorityClassName: high-priority | low-priority
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "300m"
priorityClassName: high-priority | low-priority
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "300m"
Bellow are the files (also present in the repository ) that we need to change and the values for these properties.
DANGER
NOTE: we need to keep our group namespace, so either copy the relevant lines or replace the files and change the groups
Laravel
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: laravel-app
namespace: dad-group-x
spec:
replicas: 1
selector:
matchLabels:
app: laravel-app
template:
metadata:
labels:
app: laravel-app
spec:
priorityClassName: high-priority
containers:
- name: api
image: registry.172.22.21.107.sslip.io/dad-group-x/api:v1.0.0
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "300m"
---
apiVersion: v1
kind: Service
metadata:
name: laravel-app
namespace: dad-group-x
spec:
ports:
- port: 80
selector:
app: laravel-app
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: laravel-app
namespace: dad-group-x
spec:
replicas: 1
selector:
matchLabels:
app: laravel-app
template:
metadata:
labels:
app: laravel-app
spec:
priorityClassName: high-priority
containers:
- name: api
image: registry.172.22.21.107.sslip.io/dad-group-x/api:v1.0.0
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "300m"
---
apiVersion: v1
kind: Service
metadata:
name: laravel-app
namespace: dad-group-x
spec:
ports:
- port: 80
selector:
app: laravel-app
MySQL
# mysql-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
namespace: dad-group-x
spec:
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
priorityClassName: high-priority
containers:
- name: mysql
image: mysql:8.0
env:
- name: MYSQL_ALLOW_EMPTY_PASSWORD
value: "yes"
- name: MYSQL_DATABASE
value: "project"
ports:
- containerPort: 3306
resources:
requests:
memory: "384Mi"
cpu: "200m"
limits:
memory: "1Gi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: dad-group-x
spec:
ports:
- port: 3306
selector:
app: mysql
# mysql-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
namespace: dad-group-x
spec:
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
priorityClassName: high-priority
containers:
- name: mysql
image: mysql:8.0
env:
- name: MYSQL_ALLOW_EMPTY_PASSWORD
value: "yes"
- name: MYSQL_DATABASE
value: "project"
ports:
- containerPort: 3306
resources:
requests:
memory: "384Mi"
cpu: "200m"
limits:
memory: "1Gi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: dad-group-x
spec:
ports:
- port: 3306
selector:
app: mysql
Vue
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: vue-app
namespace: dad-group-x
spec:
replicas: 1
selector:
matchLabels:
app: vue-app
template:
metadata:
labels:
app: vue-app
spec:
priorityClassName: low-priority
containers:
- name: web
image: registry.172.22.21.107.sslip.io/dad-group-x/web:v1.0.0
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
---
apiVersion: v1
kind: Service
metadata:
name: vue-app
namespace: dad-group-x
spec:
ports:
- port: 80
selector:
app: vue-app
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: vue-app
namespace: dad-group-x
spec:
replicas: 1
selector:
matchLabels:
app: vue-app
template:
metadata:
labels:
app: vue-app
spec:
priorityClassName: low-priority
containers:
- name: web
image: registry.172.22.21.107.sslip.io/dad-group-x/web:v1.0.0
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
---
apiVersion: v1
kind: Service
metadata:
name: vue-app
namespace: dad-group-x
spec:
ports:
- port: 80
selector:
app: vue-app
WebSockets
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: websocket-server
namespace: dad-group-x
spec:
replicas: 1
selector:
matchLabels:
app: websocket-server
template:
metadata:
labels:
app: websocket-server
spec:
priorityClassName: low-priority
containers:
- name: web
image: registry.172.22.21.107.sslip.io/dad-group-x/ws:v1.0.0
resources:
requests:
memory: "128Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"
---
apiVersion: v1
kind: Service
metadata:
name: websocket-server
namespace: dad-group-x
spec:
ports:
- port: 8080
selector:
app: websocket-server
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: websocket-server
namespace: dad-group-x
spec:
replicas: 1
selector:
matchLabels:
app: websocket-server
template:
metadata:
labels:
app: websocket-server
spec:
priorityClassName: low-priority
containers:
- name: web
image: registry.172.22.21.107.sslip.io/dad-group-x/ws:v1.0.0
resources:
requests:
memory: "128Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"
---
apiVersion: v1
kind: Service
metadata:
name: websocket-server
namespace: dad-group-x
spec:
ports:
- port: 8080
selector:
app: websocket-server
Update to the Vue App - support for page refreshes
The way we deployed our projects in the intermediate submission didn't support page refreshes (besides the home page). This is because we are using Vue Router history mode, that keeps the navigation routes in the url, and the web server by default will try to find a file in the path we're trying to reach.
For example if we tried to refresh the /testers/laravel page, the web server would try to find a laravel.html file on the testers folder, which does not exist.
To solve this we need to instruct Nginx (our web server) to always point to the index.html at the base of the project, and let Vue Router handle the routing.
To do this we need to add a file called nginx.conf to our vue project folder with the following contents (file also in the repository):
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
expires max;
add_header Cache-Control "public, no-transform";
}
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
expires max;
add_header Cache-Control "public, no-transform";
}
}
The most relevant line is try_files $uri $uri/ /index.html;
that tells Nginx to redirect all requests that do not have a corresponding file to the /index.html
file.
We also need to add a line to our DockerfileVue
or simply replace it completely with:
FROM oven/bun:1 as build-stage
WORKDIR /app
COPY package.json ./
RUN bun install
COPY . .
RUN bun run build
FROM nginx:alpine
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
FROM oven/bun:1 as build-stage
WORKDIR /app
COPY package.json ./
RUN bun install
COPY . .
RUN bun run build
FROM nginx:alpine
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
The relevant line is:
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
Handling Large Data
One issue that some students are having is the requests to historical game or transaction data. In a production environment were we can't be sure of the size of our data it's a bad practice to call for all the data at once. Even more so if we want to do filtering or sorting. In the case of our Kubernetes cluster if a request demands too much from a particular pod it needs to get rescheduled to a node that has more resources and given that we don't have multiple replicas of our pods this means the pod goes down.
The simplest way to handle this is to use pagination on the server. The testing application as been updated with an example of this feature for the games history.
You can see it in action at our deployment: http://web-teachers-172.22.21.101.sslip.io/testers/laravel. The table only appears after the login test,
And you can se the code at the repository, where the more relevant files are:
GameController
<?php
namespace App\Http\Controllers;
use App\Models\Game;
use Illuminate\Http\Request;
class GameController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$query = Game::query()->with(['createdBy', 'winner']);
if ($request->has('type') && in_array($request->type, ['S', 'M'])) {
$query->where('type', $request->type);
}
if ($request->has('status') && in_array($request->status, ['PE', 'PL', 'E', 'I'])) {
$query->where('status', $request->status);
}
// Sorting
$sortField = $request->input('sort_by', 'created_at');
$sortDirection = $request->input('sort_direction', 'desc');
$allowedSortFields = [
'created_at',
'began_at',
'ended_at',
'total_time',
'type',
'status'
];
if (in_array($sortField, $allowedSortFields)) {
$query->orderBy($sortField, $sortDirection === 'asc' ? 'asc' : 'desc');
}
// Pagination
$perPage = $request->input('per_page', 15);
$games = $query->paginate($perPage);
return response()->json([
'data' => $games->items(),
'meta' => [
'current_page' => $games->currentPage(),
'last_page' => $games->lastPage(),
'per_page' => $games->perPage(),
'total' => $games->total()
]
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(string $id)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
//
}
}
<?php
namespace App\Http\Controllers;
use App\Models\Game;
use Illuminate\Http\Request;
class GameController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$query = Game::query()->with(['createdBy', 'winner']);
if ($request->has('type') && in_array($request->type, ['S', 'M'])) {
$query->where('type', $request->type);
}
if ($request->has('status') && in_array($request->status, ['PE', 'PL', 'E', 'I'])) {
$query->where('status', $request->status);
}
// Sorting
$sortField = $request->input('sort_by', 'created_at');
$sortDirection = $request->input('sort_direction', 'desc');
$allowedSortFields = [
'created_at',
'began_at',
'ended_at',
'total_time',
'type',
'status'
];
if (in_array($sortField, $allowedSortFields)) {
$query->orderBy($sortField, $sortDirection === 'asc' ? 'asc' : 'desc');
}
// Pagination
$perPage = $request->input('per_page', 15);
$games = $query->paginate($perPage);
return response()->json([
'data' => $games->items(),
'meta' => [
'current_page' => $games->currentPage(),
'last_page' => $games->lastPage(),
'per_page' => $games->perPage(),
'total' => $games->total()
]
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(string $id)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
//
}
}
Vue Game Pinia Store
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import axios from 'axios'
export const useGamesStore = defineStore('games', () => {
const games = ref(null)
const page = ref(1)
const filters = ref({
type: '',
status: '',
sort_by: 'created_at',
sort_direction: 'desc'
})
const totalGames = computed(() => games.value?.length)
const resetPage = () => {
page.value = 1
games.value = null
}
const fetchGames = async (resetPagination = false) => {
if (resetPagination) {
resetPage()
}
const queryParams = new URLSearchParams({
page: page.value,
...(filters.value.type && { type: filters.value.type }),
...(filters.value.status && { status: filters.value.status }),
sort_by: filters.value.sort_by,
sort_direction: filters.value.sort_direction
}).toString()
const response = await axios.get(`/games?${queryParams}`)
if (page.value === 1 || resetPagination) {
games.value = response.data.data
} else {
games.value = [...(games.value || []), ...response.data.data]
}
return response.data
}
const fetchGamesNextPage = async () => {
page.value++
await fetchGames()
}
return {
games,
totalGames,
filters,
page,
fetchGames,
fetchGamesNextPage,
resetPage
}
})
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import axios from 'axios'
export const useGamesStore = defineStore('games', () => {
const games = ref(null)
const page = ref(1)
const filters = ref({
type: '',
status: '',
sort_by: 'created_at',
sort_direction: 'desc'
})
const totalGames = computed(() => games.value?.length)
const resetPage = () => {
page.value = 1
games.value = null
}
const fetchGames = async (resetPagination = false) => {
if (resetPagination) {
resetPage()
}
const queryParams = new URLSearchParams({
page: page.value,
...(filters.value.type && { type: filters.value.type }),
...(filters.value.status && { status: filters.value.status }),
sort_by: filters.value.sort_by,
sort_direction: filters.value.sort_direction
}).toString()
const response = await axios.get(`/games?${queryParams}`)
if (page.value === 1 || resetPagination) {
games.value = response.data.data
} else {
games.value = [...(games.value || []), ...response.data.data]
}
return response.data
}
const fetchGamesNextPage = async () => {
page.value++
await fetchGames()
}
return {
games,
totalGames,
filters,
page,
fetchGames,
fetchGamesNextPage,
resetPage
}
})
Vue Laravel Tester Component
<script setup>
import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth';
import { Button } from '@/components/ui/button'
import { useGamesStore } from '@/stores/games'
import { format } from 'date-fns'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { ArrowUpDown, Loader2 } from 'lucide-vue-next'
const store = useGamesStore()
const authStore = useAuthStore()
const loading = ref(false)
const selectedType = ref('')
const selectedStatus = ref('')
const sortField = ref('created_at')
const sortDirection = ref('desc')
const email = ref('a1@mail.pt')
const password = ref('123')
const responseData = ref('')
const games = computed(() => store.games)
const fetchData = async (resetPagination = false) => {
loading.value = true
store.filters = {
type: selectedType.value,
status: selectedStatus.value,
sort_by: sortField.value,
sort_direction: sortDirection.value
}
await store.fetchGames(resetPagination)
loading.value = false
}
const handleFiltersChange = async () => {
await fetchData(true)
}
const toggleSort = (field) => {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
handleFiltersChange()
}
const loadMore = async () => {
loading.value = true
await store.fetchGamesNextPage()
loading.value = false
}
const getStatusVariant = (status) => {
const variants = {
PE: 'secondary',
PL: 'default',
E: 'success',
I: 'destructive'
}
return variants[status] || 'default'
}
const getStatusLabel = (status) => {
const labels = {
PE: 'Pending',
PL: 'Playing',
E: 'Ended',
I: 'Interrupted'
}
return labels[status] || status
}
const formatDate = (date) => {
return format(new Date(date), 'PPp')
}
const showGames = ref(false)
const submit = async () => {
const user = await authStore.login({
email: email.value,
password: password.value
})
responseData.value = user.name
showGames.value = true
await fetchData()
}
</script>
<template>
<div class="max-w-2xl mx-auto py-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Laravel Tester</h2>
<form class="space-y-6">
<div class="space-y-2">
<label for="email" class="block text-sm font-medium text-gray-700">
Email:
</label>
<input type="text" id="email" v-model="email"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
</div>
<div class="space-y-2">
<label for="password" class="block text-sm font-medium text-gray-700">
Password:
</label>
<input type="password" id="password" v-model="password"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
</div>
<Button @click.prevent="submit" type="submit">Submit </Button>
<div v-if="responseData" class="space-y-2 mt-8">
<label for="response" class="block text-sm font-medium text-gray-700">
Response
</label>
<textarea :value="responseData" id="response" rows="3"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
readonly></textarea>
</div>
</form>
<div class="space-y-4 mt-10" v-if="showGames">
<div class="flex gap-4">
<Select v-model="selectedType" @update:modelValue="handleFiltersChange">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="S">Single Player</SelectItem>
<SelectItem value="M">Multiplayer</SelectItem>
</SelectContent>
</Select>
<Select v-model="selectedStatus" @update:modelValue="handleFiltersChange">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="PE">Pending</SelectItem>
<SelectItem value="PL">Playing</SelectItem>
<SelectItem value="E">Ended</SelectItem>
<SelectItem value="I">Interrupted</SelectItem>
</SelectContent>
</Select>
</div>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>
<div class="flex items-center gap-2 cursor-pointer" @click="toggleSort('type')">
Type
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
<TableHead>
<div class="flex items-center gap-2 cursor-pointer" @click="toggleSort('status')">
Status
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
<TableHead>Created By</TableHead>
<TableHead>Winner</TableHead>
<TableHead>
<div class="flex items-center gap-2 cursor-pointer" @click="toggleSort('total_time')">
Total Time
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
<TableHead>
<div class="flex items-center gap-2 cursor-pointer" @click="toggleSort('created_at')">
Created At
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="game in games" :key="game.id">
<TableCell>{{ game.id }}</TableCell>
<TableCell>
<Badge :variant="game.type === 'S' ? 'default' : 'secondary'">
{{ game.type === 'S' ? 'Single' : 'Multi' }}
</Badge>
</TableCell>
<TableCell>
<Badge :variant="getStatusVariant(game.status)">
{{ getStatusLabel(game.status) }}
</Badge>
</TableCell>
<TableCell>{{ game.created_by?.name }}</TableCell>
<TableCell>{{ game.winner?.name || '-' }}</TableCell>
<TableCell>{{ game.total_time ? `${game.total_time}s` : '-' }}</TableCell>
<TableCell>{{ formatDate(game.created_at) }}</TableCell>
</TableRow>
<TableRow v-if="!games?.length">
<TableCell colspan="7" class="text-center h-24">No games found</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="flex justify-center">
<Button variant="outline" @click="loadMore" :disabled="loading">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
Load More
</Button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth';
import { Button } from '@/components/ui/button'
import { useGamesStore } from '@/stores/games'
import { format } from 'date-fns'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { ArrowUpDown, Loader2 } from 'lucide-vue-next'
const store = useGamesStore()
const authStore = useAuthStore()
const loading = ref(false)
const selectedType = ref('')
const selectedStatus = ref('')
const sortField = ref('created_at')
const sortDirection = ref('desc')
const email = ref('a1@mail.pt')
const password = ref('123')
const responseData = ref('')
const games = computed(() => store.games)
const fetchData = async (resetPagination = false) => {
loading.value = true
store.filters = {
type: selectedType.value,
status: selectedStatus.value,
sort_by: sortField.value,
sort_direction: sortDirection.value
}
await store.fetchGames(resetPagination)
loading.value = false
}
const handleFiltersChange = async () => {
await fetchData(true)
}
const toggleSort = (field) => {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
handleFiltersChange()
}
const loadMore = async () => {
loading.value = true
await store.fetchGamesNextPage()
loading.value = false
}
const getStatusVariant = (status) => {
const variants = {
PE: 'secondary',
PL: 'default',
E: 'success',
I: 'destructive'
}
return variants[status] || 'default'
}
const getStatusLabel = (status) => {
const labels = {
PE: 'Pending',
PL: 'Playing',
E: 'Ended',
I: 'Interrupted'
}
return labels[status] || status
}
const formatDate = (date) => {
return format(new Date(date), 'PPp')
}
const showGames = ref(false)
const submit = async () => {
const user = await authStore.login({
email: email.value,
password: password.value
})
responseData.value = user.name
showGames.value = true
await fetchData()
}
</script>
<template>
<div class="max-w-2xl mx-auto py-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Laravel Tester</h2>
<form class="space-y-6">
<div class="space-y-2">
<label for="email" class="block text-sm font-medium text-gray-700">
Email:
</label>
<input type="text" id="email" v-model="email"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
</div>
<div class="space-y-2">
<label for="password" class="block text-sm font-medium text-gray-700">
Password:
</label>
<input type="password" id="password" v-model="password"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
</div>
<Button @click.prevent="submit" type="submit">Submit </Button>
<div v-if="responseData" class="space-y-2 mt-8">
<label for="response" class="block text-sm font-medium text-gray-700">
Response
</label>
<textarea :value="responseData" id="response" rows="3"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
readonly></textarea>
</div>
</form>
<div class="space-y-4 mt-10" v-if="showGames">
<div class="flex gap-4">
<Select v-model="selectedType" @update:modelValue="handleFiltersChange">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="S">Single Player</SelectItem>
<SelectItem value="M">Multiplayer</SelectItem>
</SelectContent>
</Select>
<Select v-model="selectedStatus" @update:modelValue="handleFiltersChange">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="PE">Pending</SelectItem>
<SelectItem value="PL">Playing</SelectItem>
<SelectItem value="E">Ended</SelectItem>
<SelectItem value="I">Interrupted</SelectItem>
</SelectContent>
</Select>
</div>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>
<div class="flex items-center gap-2 cursor-pointer" @click="toggleSort('type')">
Type
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
<TableHead>
<div class="flex items-center gap-2 cursor-pointer" @click="toggleSort('status')">
Status
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
<TableHead>Created By</TableHead>
<TableHead>Winner</TableHead>
<TableHead>
<div class="flex items-center gap-2 cursor-pointer" @click="toggleSort('total_time')">
Total Time
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
<TableHead>
<div class="flex items-center gap-2 cursor-pointer" @click="toggleSort('created_at')">
Created At
<ArrowUpDown class="h-4 w-4" />
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="game in games" :key="game.id">
<TableCell>{{ game.id }}</TableCell>
<TableCell>
<Badge :variant="game.type === 'S' ? 'default' : 'secondary'">
{{ game.type === 'S' ? 'Single' : 'Multi' }}
</Badge>
</TableCell>
<TableCell>
<Badge :variant="getStatusVariant(game.status)">
{{ getStatusLabel(game.status) }}
</Badge>
</TableCell>
<TableCell>{{ game.created_by?.name }}</TableCell>
<TableCell>{{ game.winner?.name || '-' }}</TableCell>
<TableCell>{{ game.total_time ? `${game.total_time}s` : '-' }}</TableCell>
<TableCell>{{ formatDate(game.created_at) }}</TableCell>
</TableRow>
<TableRow v-if="!games?.length">
<TableCell colspan="7" class="text-center h-24">No games found</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="flex justify-center">
<Button variant="outline" @click="loadMore" :disabled="loading">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
Load More
</Button>
</div>
</div>
</div>
</template>