docs: move Antora site content into repo (#1012)
|
|
@ -0,0 +1,34 @@
|
|||
name: Antora docs
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'local-antora-playbook.yml'
|
||||
- 'local-antora-playbook-ci.yml'
|
||||
- '.github/workflows/docs-antora.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'local-antora-playbook.yml'
|
||||
- 'local-antora-playbook-ci.yml'
|
||||
- '.github/workflows/docs-antora.yml'
|
||||
|
||||
jobs:
|
||||
antora:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install Antora toolchain
|
||||
run: npm i antora@3.1.14 asciidoctor-kroki@0.18.1 @asciidoctor/tabs@1.0.0-beta.6
|
||||
|
||||
- name: Generate docs site (smoke)
|
||||
run: npx antora local-antora-playbook-ci.yml --log-level info
|
||||
|
|
@ -19,4 +19,6 @@ OliveTin
|
|||
integration-tests/configs/authRequireGuestsToLogin/sessions.yaml
|
||||
webui
|
||||
webui.dev
|
||||
sessions.yaml
|
||||
sessions.yaml
|
||||
docs/build/
|
||||
build/
|
||||
|
|
@ -58,6 +58,7 @@ make
|
|||
The project layout is reasonably straightforward;
|
||||
|
||||
* See the `Makefile` for common targets. This project was originally created on top of Fedora, but it should be usable on Debian/your faveourite distro with minor changes (if any).
|
||||
* End-user documentation (AsciiDoc for link:https://docs.olivetin.app[docs.olivetin.app]) lives in `docs/` as an Antora component; the published site is built from the separate link:https://github.com/OliveTin/docs.olivetin.app[docs.olivetin.app] repository.
|
||||
* The API is defined in protobuf+Connect RPC - you will need to `make proto`.
|
||||
* The Go daemon is built from the `cmd` and `internal` directories mostly.
|
||||
* The webui is just a single page application with a bit of Javascript in the `webui` directory. This can happily be hosted on another webserver.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
|
||||
All documentation can be found at [docs.olivetin.app](https://docs.olivetin.app). This includes installation and usage guide, etc.
|
||||
|
||||
The AsciiDoc sources for that site live in this repository under [`docs/`](docs/) (Antora component). The [docs.olivetin.app](https://github.com/OliveTin/docs.olivetin.app) repository contains the Antora playbook, theme supplemental files, and the workflow that publishes GitHub Pages.
|
||||
|
||||
## Use cases
|
||||
|
||||
**Safely** give access to commands, for less technical people;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
name: ROOT
|
||||
title: OliveTin
|
||||
version: ''
|
||||
display_version: 'Version 3k'
|
||||
start_page: index.adoc
|
||||
asciidoc:
|
||||
attributes:
|
||||
source-language: asciidoc@
|
||||
table-caption: false
|
||||
toclevels: 2
|
||||
nav:
|
||||
- modules/ROOT/nav.adoc
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import glob
|
||||
import re
|
||||
|
||||
nav_file = open('nav.adoc', 'r')
|
||||
nav_string = nav_file.read()
|
||||
|
||||
adoc_files = glob.glob('pages/**/*.adoc', recursive=True)
|
||||
|
||||
filelist = dict()
|
||||
|
||||
for file in adoc_files:
|
||||
with open(file, 'r') as handle:
|
||||
content = handle.read()
|
||||
|
||||
matches = re.findall(r'<<(.*?),?([\w\- ]+)>>', content)
|
||||
|
||||
for match in matches:
|
||||
m = match
|
||||
|
||||
if match[0] == "":
|
||||
m = match[1]
|
||||
else:
|
||||
m = match[0]
|
||||
|
||||
if content.count("#" + m) != 1:
|
||||
if file not in filelist:
|
||||
filelist[file] = list()
|
||||
|
||||
filelist[file].append(m)
|
||||
|
||||
|
||||
print("Files:", len(filelist))
|
||||
|
||||
for file in filelist.keys():
|
||||
print(file)
|
||||
|
||||
for match in filelist[file]:
|
||||
print("\t", match)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import glob
|
||||
import re
|
||||
|
||||
adoc_files = glob.glob('pages/**/*.adoc', recursive=True)
|
||||
|
||||
filelist = list()
|
||||
|
||||
for file in adoc_files:
|
||||
with open(file, 'r') as handle:
|
||||
content = handle.read()
|
||||
|
||||
matches = re.findall('^= ', content, re.MULTILINE)
|
||||
|
||||
if len(matches) == 0:
|
||||
filelist.append(file)
|
||||
|
||||
|
||||
print("Files:", len(filelist))
|
||||
|
||||
for file in filelist:
|
||||
print(file)
|
||||
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# find .adoc files that are not navigable from the nav.adoc file
|
||||
|
||||
import glob
|
||||
|
||||
nav_file = open('nav.adoc', 'r')
|
||||
nav_string = nav_file.read()
|
||||
|
||||
adoc_files = glob.glob('pages/**/*.adoc', recursive=True)
|
||||
|
||||
unnavigable_files = []
|
||||
|
||||
for file in adoc_files:
|
||||
filename = file.replace("pages/", "")
|
||||
|
||||
if filename not in nav_string:
|
||||
unnavigable_files.append(filename)
|
||||
|
||||
|
||||
unnavigable_files = sorted(unnavigable_files)
|
||||
|
||||
print("Unnavigable files:", len(unnavigable_files))
|
||||
for file in unnavigable_files:
|
||||
print(file)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
actions:
|
||||
- title: Unicode (emoji) alias icon
|
||||
shell: echo "Hello!"
|
||||
icon: smile
|
||||
|
||||
- title: Unicode (emoji) icon
|
||||
shell: echo "Hello!"
|
||||
icon: "😎"
|
||||
|
||||
- title: Iconify Icon
|
||||
icon: <iconify-icon icon="ant-design:bug-filled"></iconify-icon>
|
||||
|
||||
- title: HTML Image (jpg/png/gif/etc) icon
|
||||
shell: echo "Hello!"
|
||||
icon: '<img src = "custom-webui/icons/mrgreen.gif" style = "width: 1em;" />'
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: olivetin-config
|
||||
data:
|
||||
config.yaml: |
|
||||
actions:
|
||||
- title: "Hello world!"
|
||||
shell: echo 'Hello World!'
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: olivetin
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: olivetin
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: olivetin
|
||||
spec:
|
||||
containers:
|
||||
- name: olivetin
|
||||
image: docker.io/jamesread/olivetin:latest
|
||||
ports:
|
||||
- containerPort: 1337
|
||||
volumeMounts:
|
||||
- name: olivetin-config
|
||||
mountPath: "/config"
|
||||
readOnly: true
|
||||
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- curl
|
||||
- localhost:1337
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
|
||||
volumes:
|
||||
- name: olivetin-config
|
||||
configMap:
|
||||
name: olivetin-config
|
||||
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: olivetin-ingress
|
||||
spec:
|
||||
defaultBackend:
|
||||
service:
|
||||
name: olivetin
|
||||
port:
|
||||
number: 1337
|
||||
rules:
|
||||
- host: olivetin.apps.ocp.teratan.net
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: olivetin
|
||||
port:
|
||||
number: 1337
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
services:
|
||||
app:
|
||||
image: 'jc21/nginx-proxy-manager:latest'
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '80:80'
|
||||
- '81:81'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./letsencrypt:/etc/letsencrypt
|
||||
olivetin:
|
||||
container_name: olivetin
|
||||
image: jamesread/olivetin
|
||||
volumes:
|
||||
- ./OliveTin:/config # replace host path or volume as needed
|
||||
ports:
|
||||
- "1337:1337"
|
||||
restart: unless-stopped
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
server {
|
||||
listen 443 ssl;
|
||||
|
||||
ssl_certificate "/etc/nginx/conf.d/server.crt";
|
||||
ssl_certificate_key "/etc/nginx/conf.d/server.key";
|
||||
|
||||
access_log /var/log/nginx/ot.access.log main;
|
||||
error_log /var/log/nginx/ot.error.log notice;
|
||||
|
||||
server_name olivetin.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:1337/;
|
||||
proxy_redirect http://localhost:1337/ http://localhost/OliveTin/;
|
||||
}
|
||||
|
||||
location /websocket {
|
||||
proxy_set_header Upgrade "websocket";
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_pass http://localhost:1337/websocket;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# This config has two actions which are applied to all "container" entities
|
||||
# found in the entity file.
|
||||
#
|
||||
# Docs: http://localhost/docs.olivetin.app/docs/entities.html
|
||||
actions:
|
||||
- title: Start {{ container.Names }}
|
||||
icon: box
|
||||
shell: docker start {{ container.Names }}
|
||||
entity: container
|
||||
triggers:
|
||||
- Update container entity file
|
||||
|
||||
- title: Stop {{ container.Names }}
|
||||
icon: box
|
||||
shell: docker stop {{ container.Names }}
|
||||
entity: container
|
||||
triggers:
|
||||
- Update container entity file
|
||||
|
||||
# This is a hidden action, that is run on startup, and every 5 minutes, and
|
||||
# when the above start/stop commands are run (see the `triggers` property).
|
||||
|
||||
- title: Update container entity file
|
||||
shell: 'docker ps -a --format json > /etc/OliveTin/entities/containers.json'
|
||||
hidden: true
|
||||
execOnStartup: true
|
||||
execOnCron: '*/5 * * * *'
|
||||
|
||||
# Docs: http://docs.olivetin.app/entities.html
|
||||
entities:
|
||||
- file: /etc/OliveTin/entities/containers.json
|
||||
name: container
|
||||
|
||||
# The only way to properly use entities, are to use them with a `fieldset` on
|
||||
# a dashboard.
|
||||
dashboards:
|
||||
# This is the second dashboard.
|
||||
- title: My Containers
|
||||
contents:
|
||||
- title: 'Container {{ container.Names }} ({{ container.Image }})'
|
||||
entity: container
|
||||
type: fieldset
|
||||
contents:
|
||||
- type: display
|
||||
title: |
|
||||
{{ container.RunningFor }} <br /><br /><strong>{{ container.State }}</strong>
|
||||
- title: 'Start {{ container.Names }}'
|
||||
- title: 'Stop {{ container.Names }}'
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{"Command":"\"/bin/bash\"","CreatedAt":"2024-02-28 22:33:35 +0000 GMT","ID":"fcf468e18a0e","Image":"fedora","Labels":"maintainer=Clement Verna \u003ccverna@fedoraproject.org\u003e","LocalVolumes":"0","Mounts":"","Names":"minecraft","Networks":"bridge","Ports":"","RunningFor":"3 minutes ago","Size":"0B","State":"created","Status":"Created"}
|
||||
{"Command":"\"/bin/bash\"","CreatedAt":"2024-02-23 23:18:57 +0000 GMT","ID":"442dd6fe316a","Image":"fedora","Labels":"maintainer=Clement Verna \u003ccverna@fedoraproject.org\u003e","LocalVolumes":"0","Mounts":"","Names":"brave_shirley","Networks":"bridge","Ports":"","RunningFor":"4 days ago","Size":"0B","State":"created","Status":"Created"}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
actions:
|
||||
- title: check log directory
|
||||
hidden: true
|
||||
shell: |
|
||||
function addDirectory {
|
||||
COUNT=$(ls -l $1 | wc -l)
|
||||
echo "- directory: $1" >> /etc/OliveTin/entities/directories.yaml
|
||||
echo " count: $COUNT" >> /etc/OliveTin/entities/directories.yaml
|
||||
}
|
||||
|
||||
truncate -s 0 /etc/OliveTin/entities/directories.yaml
|
||||
addDirectory /var/log/
|
||||
addDirectory /home/xconspirisist/logs
|
||||
execOnStartup: true
|
||||
execOnCron: "* * * * *"
|
||||
|
||||
- title: clean {{ log_directory.directory }} ({{log_directory.count }} files)
|
||||
shell: |
|
||||
echo "Removing all files in {{ log_directory.directory }}"
|
||||
entity: log_directory
|
||||
|
||||
entities:
|
||||
- name: log_directory
|
||||
file: /etc/OliveTin/entities/directories.yaml
|
||||
|
||||
dashboards:
|
||||
- title: Log Actions
|
||||
contents:
|
||||
- entity: log_directory
|
||||
type: fieldset
|
||||
contents:
|
||||
- title: clean {{ log_directory.directory }} ({{log_directory.count }} files)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
logLevel: "INFO"
|
||||
|
||||
actions:
|
||||
- title: Turn heating up
|
||||
icon: '🔼'
|
||||
shell: /opt/heating.sh up
|
||||
|
||||
- title: Turn heating down
|
||||
icon: '🔽'
|
||||
shell: /opt/heating.sh down
|
||||
|
||||
entities:
|
||||
- file: /etc/OliveTin/entities/heating.yaml
|
||||
name: heating
|
||||
|
||||
dashboards:
|
||||
- title: Heating Control Panel
|
||||
contents:
|
||||
- title: "{{ heater.title }}"
|
||||
entity: heating
|
||||
type: fieldset
|
||||
contents:
|
||||
- type: display
|
||||
title: |
|
||||
<span class = "icon">🌡</span> <br />{{ heating.temperature }}
|
||||
|
||||
- title: Turn heating up
|
||||
- title: Turn heating down
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
- title: Main heater
|
||||
temperature: 20 degrees
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
const myPassword = 'sekrit'
|
||||
|
||||
const domMain = document.getElementsByTagName('main')[0]
|
||||
domMain.style.display = 'none'
|
||||
|
||||
const domPassword = document.createElement('input')
|
||||
const domLogin = document.createElement('button')
|
||||
|
||||
function checkPassword () {
|
||||
if (domPassword.value === myPassword) {
|
||||
domMain.style.display = 'block'
|
||||
domPassword.remove()
|
||||
domLogin.remove()
|
||||
} else {
|
||||
window.alert('Incorrect password. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
function setupPasswordForm () {
|
||||
domPassword.setAttribute('type', 'password')
|
||||
domPassword.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
checkPassword()
|
||||
}
|
||||
})
|
||||
|
||||
domLogin.innerText = 'Login'
|
||||
domLogin.onclick = checkPassword
|
||||
|
||||
const domHeader = document.querySelector('header')
|
||||
domHeader.appendChild(domPassword)
|
||||
domHeader.appendChild(domLogin)
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', setupPasswordForm)
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
actions:
|
||||
- title: Stop {{ systemd_unit.unit }}
|
||||
shell: systemctl stop {{ systemd_unit.unit }}
|
||||
icon: <iconify-icon icon="zondicons:hand-stop"></iconify-icon>
|
||||
entity: systemd_unit
|
||||
triggers:
|
||||
- Update services file
|
||||
|
||||
- title: Start {{ systemd_unit.unit }}
|
||||
shell: systemctl start {{ systemd_unit.unit }}
|
||||
icon: <iconify-icon icon="ic:round-directions-run"></iconify-icon>
|
||||
entity: systemd_unit
|
||||
triggers:
|
||||
- Update services file
|
||||
|
||||
- title: Update services file
|
||||
shell: systemctl list-units -a -o json --no-pager | jq -c 'map(select (.unit | contains ("upsilon", "podman", "boot.mount"))) | .[]' > /etc/OliveTin/entities/systemd_units.json
|
||||
hidden: true
|
||||
execOnStartup: true
|
||||
|
||||
entities:
|
||||
- file: /etc/OliveTin/entities/systemd_units.json
|
||||
name: systemd_unit
|
||||
|
||||
dashboards:
|
||||
- title: My Services
|
||||
contents:
|
||||
- title: '{{ systemd_unit.description }}'
|
||||
type: fieldset
|
||||
entity: systemd_unit
|
||||
contents:
|
||||
- title: 'Status: {{ systemd_unit.sub }}'
|
||||
type: display
|
||||
|
||||
- title: Start {{ systemd_unit.unit }}
|
||||
- title: Stop {{ systemd_unit.unit }}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{"unit":"boot.mount","load":"loaded","active":"active","sub":"mounted","description":"/boot"}
|
||||
{"unit":"podman.service","load":"loaded","active":"inactive","sub":"dead","description":"Podman API Service"}
|
||||
{"unit":"upsilon-drone.service","load":"loaded","active":"active","sub":"running","description":"upsilon-drone"}
|
||||
{"unit":"podman.socket","load":"loaded","active":"active","sub":"listening","description":"Podman API Socket"}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
actions:
|
||||
- title: WakeOnLan Server1
|
||||
shell: ether-wake A8:5E:45:E4:FF:2A
|
||||
icon: ping
|
||||
|
||||
- title: Install ether-wake on startup
|
||||
shell: microdnf install -y net-tools
|
||||
hidden: true
|
||||
execOnStartup: true
|
||||
timeout: 120
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
- title: WakeOnLan Server1
|
||||
# The r0gger/docker-wake-on-lan is a minimal container for WOL
|
||||
# that can be run on the host network.
|
||||
# It is not required to run the OliveTin container on the host network.
|
||||
shell: |
|
||||
docker run --rm --name wake-on-lan --net=host -e MAC='A8:5E:45:E4:FF:2A' r0gger/docker-wake-on-lan
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |