Compare commits

...

2 Commits

Author SHA1 Message Date
gustice 81ddf048ca proj: switch to secure connection 2026-06-29 21:01:19 +02:00
gustice 2bb5125254 step implementing maintanance logic 2026-06-28 17:39:55 +02:00
18 changed files with 1450 additions and 38 deletions
+15 -3
View File
@@ -1,3 +1,15 @@
docker build -t kingArthurApp:latest . # docker build -t kingArthurApp:latest .
docker save kingArthurApp:latest | gzip > kingArthurApp.tar.gz # docker save kingArthurApp:latest | gzip > kingArthurApp.tar.gz
scp kingArthurApp.tar.gz youruser@your_server_ip:~/ # scp kingArthurApp.tar.gz youruser@your_server_ip:~/
# add login key first
# > docker login git.nerdyssey.de
# > Username: dockerLogin
# > Passwrord: appkey
# [DOMAIN]/[OWNER]/[IMAGE_NAME]:[TAG]
docker build -t git.nerdyssey.de/exodus/burg-stett-king-arth-web:latest .
docker push git.nerdyssey.de/exodus/burg-stett-king-arth-web:latest
#
# docker compose pull
# docker compose up -d
+46
View File
@@ -0,0 +1,46 @@
version: '3.8'
services:
gitea-server:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
# Database connection
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=###SomePassword###
# Web / Domain settings
- GITEA__server__DOMAIN=git.nerdyssey.de
- GITEA__server__ROOT_URL=https://git.nerdyssey.de/
- GITEA__server__SSH_DOMAIN=git.nerdyssey.de
- GITEA__server__SSH_PORT=2222
# Security
- GITEA__service__DISABLE_REGISTRATION=true
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
volumes:
- ./data:/data
ports:
- "2222:22" # This allows Git over SSH
depends_on:
- gitea-db
gitea-db:
image: postgres:15-alpine
container_name: gitea-db
restart: unless-stopped
environment:
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=###SomePassword###
- POSTGRES_DB=gitea
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
default:
name: proxy_net
external: true
+18
View File
@@ -0,0 +1,18 @@
services:
mosquitto:
image: eclipse-mosquitto:latest
container_name: mosquitto
restart: unless-stopped
# Power are managed via private subnet "proxy_net"
# ports:
# - "1883:1883"
# - "9001:9001"
volumes:
- ./config:/mosquitto/config
- ./data:/mosquitto/data
- ./log:/mosquitto/log
networks:
default:
name: proxy_net
external: true
+20
View File
@@ -0,0 +1,20 @@
version: '3.8'
services:
nginx-proxy-manager:
image: 'jc21/nginx-proxy-manager:latest'
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- '80:80' # Public HTTP Port (Required for Certbot challenges)
- '443:443' # Public HTTPS Port
- '81:81' # Admin Web Dashboard
- '8883:8883' # Added for Secure MQTT (MQTTS)
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
default:
name: proxy_net
external: true
+59
View File
@@ -0,0 +1,59 @@
# MQTT Commands
setup password
```sh
docker exec --user 1883 -it mosquitto mosquitto_passwd /mosquitto/config/passwd nextuser
awesomeexodusoutdooradventures
```
## simple
```sh
mosquitto_sub -h 91.99.29.8 -t outdoor/king-arthur/sword/run
```
## safe
```sh
mosquitto_sub -L mqtts://nerdyssey.de:8883/outdoor/king-arthur/sword/run -d
mosquitto_sub -L mqtts://nerdyssey.de:8883/outdoor/king-arthur/sword/int -d
mosquitto_pub -L mqtts://nerdyssey.de:8883 -t outdoor/king-arthur/sword/run -m "SetMaintanance"
mosquitto_sub -h mqtts://nerdyssey.de:8883 -t "outdoor/king-arthur/sword/int" -u "exodususer" -P "awesomeexodusoutdooradventures"
mosquitto_pub -h <your-server-ip-or-proxy> -t "outdoor/king-arthur/sword/int" -m "Hello Secure World!" -u "exodususer" -P "awesomeexodusoutdooradventures"
mosquitto_sub -h nerdyssey.de -p 8883 -t "outdoor/king-arthur/sword/int" -u "exodususer" -P "awesomeexodusoutdooradventures" --capath /etc/ssl/certs/ -d
```
- `+` Single-level wildcard: `t1/+/sub2/sub3`
- `#` Multi-level wildcard: `t1/#/sub3`, `t1/#`
```sh
# Publishing topic with message
# -h: Host (optional)
# -t: Topic
# -m: Message
# -q: Quality of service
# -r: Retain (keep this after failed connection)
# -h: Print help info
mosquitto_pub -t someTopic -m 123 -q 1
mosquitto_pub -r -t someTopic -m "on"
mosquitto_pub -h 91.99.29.8 -t someTopic -m 123 -q 1
# Subscribing to
# -h: Host (optional)
# -t: Topic s
# + is wildcard
# # is wildcard for full subtree
# -T: Exclude topic
# -q: Quality of service
mosquitto_sub -t someTopic -q 1
mosquitto_sub -t someTopic/+/subTopic
mosquitto_sub -t bbc/# -T bbc/radio3
mosquitto_sub -h 91.99.29.8 -t someTopic -q 1
```
+60
View File
@@ -0,0 +1,60 @@
```sh
# install docker
sudo apt install ca-certificates curl -y
# Add Docker's official GPG key:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository to Apt sources:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
# install and test
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl status docker
sudo docker run hello-world
sudo usermod -aG docker $USER
newgrp docker
docker run hello-world
```
```sh
sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw enable
sudo ufw allow ssh
sudo ufw status verbose
sudo nano /etc/ssh/sshd_config
sudo systemctl restart ssh
sudo ufw allow 80/tcp
sudo ufw allow 8080/tcp
sudo ufw allow 1883/tcp # mqtt
sudo ufw allow 9001/tcp # mqtt ws
sudo ufw allow 8883/tcp # secure mqtt
sudo ufw allow 2222/tcp # secure mqtt
```
```sh
mkdir -p ~/mosquitto/config
mkdir -p ~/mosquitto/data
mkdir -p ~/mosquitto/log
nano ~/mosquitto/config/mosquitto.conf
nano ~/mosquitto/docker-compose.yml
docker compose up -d
```
todo add also config files
```sh
docker load <~/my-vite-app.tar.gz
docker images
```
+5
View File
@@ -0,0 +1,5 @@
# Secure Shell
```sh
ssh -o PubkeyAuthentication=no gustice@91.99.29.8
```
+17
View File
@@ -0,0 +1,17 @@
version: '3.8'
services:
exodus-burg-stett-king-arth-web:
image: git.nerdyssey.de/exodus/burg-stett-king-arth-web:latest
container_name: exodus-burg-stett-king-arth-web
restart: unless-stopped
# ports:
# - "80:80"
# - "443:443"
# environment:
# - VITE_APP_ENV=production
networks:
default:
name: proxy_net
external: true
+7 -3
View File
@@ -7,7 +7,11 @@ COPY . .
RUN npm run build RUN npm run build
# Serve stage # Serve stage
FROM nginx:alpine FROM node:20-alpine
COPY --from=builder /app/dist /usr/share/nginx/html WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm install --production
COPY server.js .
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["npm", "start"]
+836 -6
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -6,15 +6,17 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"start": "node server.js"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^6.0.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.0", "vite": "^8.1.0",
"vue-tsc": "^3.3.5" "vue-tsc": "^3.3.5"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2",
"mqtt": "^5.15.1", "mqtt": "^5.15.1",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.3.12", "vue": "^3.3.12",
+17
View File
@@ -0,0 +1,17 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.static(path.join(__dirname, 'dist')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
const PORT = process.env.PORT || 80;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
+1 -2
View File
@@ -5,5 +5,4 @@
<script setup lang="ts"> <script setup lang="ts">
</script> </script>
<style scoped> <style scoped></style>
</style>
+2
View File
@@ -5,6 +5,8 @@ import Players from '../views/Players.vue'
import Start from '../views/Start.vue' import Start from '../views/Start.vue'
import Text from '../views/Text.vue' import Text from '../views/Text.vue'
const routeBase: string = "nerdyssey.de/exodus/king-arthur"
const routes: any[] = [ const routes: any[] = [
{ {
path: '/', path: '/',
+150 -8
View File
@@ -1,26 +1,40 @@
import mqtt from 'mqtt' import mqtt from 'mqtt'
const mqttBrokerHost = '91.99.29.8' const mqttBrokerHost = 'mqtt.nerdyssey.de'
const mqttBrokerPort = 9001 const mqttBrokerPort = 443 // 9001 if not secure
const mqttBrokerPath = '/mqtt' const mqttBrokerPath = '/mqtt'
const mqttUseTls = false const mqttUseTls = true
const mqttTopic = 'king-arthur/page/startup' const mqttTopicRoot = 'outdoor/king-arthur/sword/'
const mqttMessage = 'start-hit' const mqttMessage = 'start-hit'
const mqttUser = "exodususer";
const mqttPassword = "awesomeexodusoutdooradventures";
let client: any = null let client: any = null
function getClient(): mqtt.MqttClient {
const protocol = mqttUseTls ? 'wss' : 'ws'
const brokerUrl = `${protocol}://${mqttBrokerHost}:${mqttBrokerPort}${mqttBrokerPath}`
const tempClient = mqtt.connect(brokerUrl, {
username: mqttUser,
password: mqttPassword
})
console.log(`MQTT Connection string ${brokerUrl}`)
return tempClient
}
export function getMqttClient(): any { export function getMqttClient(): any {
return client return client
} }
export function publishStartHit(): void { export function publishStartHit(): void {
const protocol = mqttUseTls ? 'wss' : 'ws' const tempClient = getClient()
const brokerUrl = `${protocol}://${mqttBrokerHost}:${mqttBrokerPort}${mqttBrokerPath}`
const tempClient = mqtt.connect(brokerUrl) const mqttTopic = mqttTopicRoot + 'run'
tempClient.on('connect', () => { tempClient.on('connect', () => {
tempClient.publish(mqttTopic, mqttMessage, { qos: 1 }, () => { tempClient.publish(mqttTopic, mqttMessage, { qos: 1 }, () => {
tempClient.end()
}) })
}) })
@@ -29,3 +43,131 @@ export function publishStartHit(): void {
tempClient.end(true) tempClient.end(true)
}) })
} }
export function subscribeToIntTopic(callback: (response: IntTopicResponse) => void): mqtt.MqttClient {
const tempClient = getClient()
const mqttTopic = mqttTopicRoot + 'int'
tempClient.on('connect', () => {
tempClient.subscribe(mqttTopic, { qos: 1 }, (error) => {
if (error) {
console.error('MQTT subscribe failed:', error)
}
})
})
tempClient.on('message', (topic: string, message: Buffer) => {
if (topic === mqttTopic) {
const parsedMessage = parseIntMessage(message.toString())
callback(parsedMessage)
}
})
tempClient.on('error', (error: any) => {
console.error('MQTT connection error:', error)
})
return tempClient
}
interface IntTopicResponse {
version: string
mode: number
sE: boolean
sS: boolean
sP: boolean
lE: boolean
}
export function parseIntMessage(message: string): IntTopicResponse {
const result: IntTopicResponse = {
version: '',
mode: 0,
sE: false, // EndPoint
sS: false, // StartPoint
sP: false, // PullSensor
lE: false // LatchEnable
}
const versionMatch = message.match(/T([\d.]+)/)
if (versionMatch) {
result.version = versionMatch[1]
}
const modeMatch = message.match(/Mode=(\d+)/)
if (modeMatch) {
result.mode = parseInt(modeMatch[1], 10)
}
const sEMatch = message.match(/sE:(true|false)/)
if (sEMatch) {
result.sE = sEMatch[1] === 'true'
}
const sSMatch = message.match(/sS:(true|false)/)
if (sSMatch) {
result.sS = sSMatch[1] === 'true'
}
const sPMatch = message.match(/sP:(true|false)/)
if (sPMatch) {
result.sP = sPMatch[1] === 'true'
}
const lEMatch = message.match(/lE:(true|false)/)
if (lEMatch) {
result.lE = lEMatch[1] === 'true'
}
return result
}
export function enterMaintananceMode(
callback: (sE: number, sS: number, sP: number, lE: number) => void)
: mqtt.MqttClient {
const client = getClient()
const mqttTopic = mqttTopicRoot + 'stat'
client.on('connect', () => {
publicMaintananceCmd(client, "SetMaintanance")
client.subscribe(mqttTopic, { qos: 1 }, (error) => {
if (error) {
console.error('MQTT subscribe failed:', error)
}
})
})
client.on('error', (error: any) => {
console.error('MQTT operation failed:', error)
client.end(true)
})
client.on('message', (topic: string, message: Buffer) => {
if (topic === mqttTopic) {
const messageStr = message.toString()
const status = parseIntMessage(message.toString())
const sE = status.sE ? 1 : 0
const sS = status.sS ? 1 : 0
const sP = status.sP ? 1 : 0
const lE = status.lE ? 1 : 0
callback(sE, sS, sP, lE)
}
})
return client
}
export function exitMaintananceMode(client: mqtt.MqttClient): void {
publicMaintananceCmd(client, "Exit")
const mqttTopic = mqttTopicRoot + 'stat'
client.unsubscribe(mqttTopic, (error) => {
if (error) {
console.error('MQTT unsubscribe failed:', error)
}
})
client.end()
}
export function publicMaintananceCmd(client: mqtt.MqttClient, message : string): void {
const mqttTopic = mqttTopicRoot + 'run'
console.log(`MQTT: Publish ${mqttTopic}: ${message}`)
client.publish(mqttTopic, message, { qos: 1 }, (error) => {
if (error) {
console.error('MQTT publish failed:', error)
}
})
}
+6 -3
View File
@@ -2,9 +2,12 @@
<main class="app"> <main class="app">
<nav class="nav"> <nav class="nav">
<router-link to="/" class="nav-link">Home</router-link> <router-link to="/" class="nav-link">Home</router-link>
<router-link to="/players" class="nav-link" :class="{ active: route.path === '/players' }">Page 1: Players</router-link> <router-link to="/players" class="nav-link" :class="{ active: route.path === '/players' }">
<router-link to="/start" class="nav-link" :class="{ active: route.path === '/start' }">Page 2: Start</router-link> Page 1: Players</router-link>
<router-link to="/text" class="nav-link" :class="{ active: route.path === '/text' }">Page 3: Text</router-link> <router-link to="/start" class="nav-link" :class="{ active: route.path === '/start' }">
Page 2: Start</router-link>
<router-link to="/text" class="nav-link" :class="{ active: route.path === '/text' }">
Page 3: Text</router-link>
</nav> </nav>
<section class="page-content"> <section class="page-content">
<Players v-if="route.path === '/players'" /> <Players v-if="route.path === '/players'" />
+184 -4
View File
@@ -1,10 +1,52 @@
<template> <template>
<main class="app"> <main class="app">
<h1>Simple Player Web App</h1> <h1>Maintanance View</h1>
<h2>Sword Status</h2>
<div class="status-container">
<h3>Inputs</h3>
<div class="status-frame">
<div class="status-item" :class="{ active: endSensStatus === 1 }">
<span class="status-label">EndSens:</span>
<span class="status-value">{{ endSensStatus }}</span>
</div>
<div class="status-item" :class="{ active: startSensStatus === 1 }">
<span class="status-label">StartSens:</span>
<span class="status-value">{{ startSensStatus }}</span>
</div>
<div class="status-item" :class="{ active: pullSensStatus === 1 }">
<span class="status-label">PullSens:</span>
<span class="status-value">{{ pullSensStatus }}</span>
</div>
<div class="status-item" :class="{ active: lockStatus === 1 }">
<span class="status-label">Lock:</span>
<span class="status-value">{{ lockStatus }}</span>
</div>
</div>
<h3>Outputs</h3>
<div class="status-frame">
<div class="control-buttons">
<button @click="handleLock" class="btn-lock">Lock</button>
<button @click="handleUnlock" class="btn-unlock">Unlock</button>
<button @click="handlePlayX(1)" class="btn-unlock">Play sound 1</button>
<button @click="handlePlayX(2)" class="btn-unlock">Play sound 2</button>
<button @click="handlePlayX(3)" class="btn-unlock">Play sound 3</button>
</div>
</div>
<h3>Status</h3>
</div>
<h2>Game Views</h2>
<nav class="nav"> <nav class="nav">
<router-link to="/maintenance/players" class="nav-link" :class="{ active: isActive('/maintenance/players') }">Page 1: Players</router-link> <router-link to="/maintenance/players" class="nav-link" :class="{ active: isActive('/maintenance/players') }">Page
<router-link to="/maintenance/start" class="nav-link" :class="{ active: isActive('/maintenance/start') }">Page 2: Start</router-link> 1: Players</router-link>
<router-link to="/maintenance/text" class="nav-link" :class="{ active: isActive('/maintenance/text') }">Page 3: Text</router-link> <router-link to="/maintenance/start" class="nav-link" :class="{ active: isActive('/maintenance/start') }">Page 2:
Start</router-link>
<router-link to="/maintenance/text" class="nav-link" :class="{ active: isActive('/maintenance/text') }">Page 3:
Text</router-link>
</nav> </nav>
<section class="page-content"> <section class="page-content">
<router-view></router-view> <router-view></router-view>
@@ -13,11 +55,149 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { enterMaintananceMode, exitMaintananceMode, publicMaintananceCmd } from '../services/mqtt'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ref, onMounted, onUnmounted } from 'vue'
import type mqtt from 'mqtt'
const route = useRoute() const route = useRoute()
const endSensStatus = ref<number>(0)
const startSensStatus = ref<number>(0)
const pullSensStatus = ref<number>(0)
const lockStatus = ref<number>(0)
let statTopicClient: mqtt.MqttClient | null = null
function isActive(path: string): boolean { function isActive(path: string): boolean {
return route.path === path return route.path === path
} }
function cleanup(): void {
if (statTopicClient) {
exitMaintananceMode(statTopicClient)
}
}
function handleLock(): void {
if (statTopicClient) {
publicMaintananceCmd(statTopicClient, "ServiceClose")
}
}
function handleUnlock(): void {
if (statTopicClient) {
publicMaintananceCmd(statTopicClient, "ServiceOpen")
}
}
function handlePlayX(sound: number): void {
if (statTopicClient) {
if (sound === 1) {
publicMaintananceCmd(statTopicClient, "ServicePlay1")
} else if (sound === 2) {
publicMaintananceCmd(statTopicClient, "ServicePlay2")
} else if (sound === 3) {
publicMaintananceCmd(statTopicClient, "ServicePlay3")
}
}
}
onMounted(() => {
statTopicClient = enterMaintananceMode((endSens, startSens, pullSens, lock) => {
endSensStatus.value = endSens
startSensStatus.value = startSens
pullSensStatus.value = pullSens
lockStatus.value = lock
})
window.addEventListener('beforeunload', cleanup)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', cleanup)
cleanup()
})
</script> </script>
<style scoped>
.status-container {
margin-bottom: 20px;
}
.status-frame {
border: 2px solid #d1d5db;
border-radius: 8px;
padding: 16px;
background: #f9fafb;
margin-bottom: 16px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.status-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px;
border-radius: 6px;
background: #e5e7eb;
color: #6b7280;
transition: background 0.3s, color 0.3s;
font-weight: 500;
}
.status-item.active {
background: #10b981;
color: #ffffff;
}
.status-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.status-value {
font-size: 20px;
font-weight: 600;
margin-top: 4px;
}
.control-buttons {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.btn-lock,
.btn-unlock {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.btn-lock {
background: #111827;
color: #ffffff;
}
.btn-lock:hover {
background: #1f2937;
}
.btn-unlock {
background: #e5e7eb;
color: #111827;
border: 1px solid #d1d5db;
}
.btn-unlock:hover {
background: #d1d5db;
}
</style>
+3 -7
View File
@@ -2,12 +2,7 @@
<div> <div>
<h2>Add players</h2> <h2>Add players</h2>
<div class="row"> <div class="row">
<input <input v-model="playerInput" type="text" placeholder="Enter player name" @keydown.enter="addPlayer" />
v-model="playerInput"
type="text"
placeholder="Enter player name"
@keydown.enter="addPlayer"
/>
<button @click="addPlayer" type="button">Add</button> <button @click="addPlayer" type="button">Add</button>
</div> </div>
<ul class="player-list"> <ul class="player-list">
@@ -15,7 +10,8 @@
<span class="player-name">{{ player }}</span> <span class="player-name">{{ player }}</span>
<button class="delete-btn" @click="deletePlayer(index)" title="Delete player"> <button class="delete-btn" @click="deletePlayer(index)" title="Delete player">
<svg class="trash-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="trash-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-9l-1 1H5v2h14V4z" fill="currentColor"/> <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-9l-1 1H5v2h14V4z"
fill="currentColor" />
</svg> </svg>
</button> </button>
</li> </li>