Skip to content

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:

yml
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

yml
---
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

yml
# 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

yml
---
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

yml
---
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):

conf
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:

docker
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
<?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

js
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

vue
<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>

Last updated:

IPLeiria | ESTG | EI | DAD 2023/24