This commit is contained in:
Michel Roegl-Brunner 2025-03-12 11:23:35 +01:00
commit 7b203a10d8
9 changed files with 146 additions and 357 deletions

View File

@ -45,6 +45,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci --prefer-offline --legacy-peer-deps run: npm ci --prefer-offline --legacy-peer-deps
- name: Run tests
run: npm run test
- name: Configure Next.js for pages - name: Configure Next.js for pages
uses: actions/configure-pages@v5 uses: actions/configure-pages@v5
with: with:

View File

@ -4,17 +4,7 @@ import path from "path";
import { ScriptSchema, type Script } from "@/app/json-editor/_schemas/schemas"; import { ScriptSchema, type Script } from "@/app/json-editor/_schemas/schemas";
import { Metadata } from "@/lib/types"; import { Metadata } from "@/lib/types";
const publicJsonPath = path.join(process.cwd(), 'public/json'); const jsonDir = "public/json";
const getJsonDirectory = async () => {
if (!(await fs.stat(publicJsonPath).catch(() => null))) {
throw new Error(`JSON path file not found: ${publicJsonPath}`);
}
const jsonPath = (await fs.readFile(publicJsonPath, "utf-8")).trim();
return path.resolve(process.cwd(), jsonPath);
};
const jsonDir = await getJsonDirectory();
const metadataFileName = "metadata.json"; const metadataFileName = "metadata.json";
const encoding = "utf-8"; const encoding = "utf-8";
@ -25,7 +15,7 @@ describe.each(fileNames)("%s", async (fileName) => {
let script: Script; let script: Script;
beforeAll(async () => { beforeAll(async () => {
const filePath = path.resolve(jsonDir, fileName); const filePath = path.resolve(jsonDir, fileName);
const fileContent = await fs.readFile(filePath, encoding) const fileContent = await fs.readFile(filePath, encoding)
script = JSON.parse(fileContent); script = JSON.parse(fileContent);
}) })
@ -46,7 +36,7 @@ describe(`${metadataFileName}`, async () => {
let metadata: Metadata; let metadata: Metadata;
beforeAll(async () => { beforeAll(async () => {
const filePath = path.resolve(jsonDir, metadataFileName); const filePath = path.resolve(jsonDir, metadataFileName);
const fileContent = await fs.readFile(filePath, encoding) const fileContent = await fs.readFile(filePath, encoding)
metadata = JSON.parse(fileContent); metadata = JSON.parse(fileContent);
}) })
@ -55,9 +45,9 @@ describe(`${metadataFileName}`, async () => {
// TODO: create zod schema for metadata. Move zod schemas to /lib/types.ts // TODO: create zod schema for metadata. Move zod schemas to /lib/types.ts
assert(metadata.categories.length > 0); assert(metadata.categories.length > 0);
metadata.categories.forEach((category) => { metadata.categories.forEach((category) => {
assert.isString(category.name) assert.isString(category.name)
assert.isNumber(category.id) assert.isNumber(category.id)
assert.isNumber(category.sort_order) assert.isNumber(category.sort_order)
}); });
}); });
}) })

View File

@ -5,16 +5,7 @@ import path from "path";
export const dynamic = "force-static"; export const dynamic = "force-static";
const publicJsonPath = path.join(process.cwd(), 'public/json'); const jsonDir = "public/json";
const getJsonDirectory = async () => {
if (!(await fs.stat(publicJsonPath).catch(() => null))) {
throw new Error(`JSON path file not found: ${publicJsonPath}`);
}
const jsonPath = (await fs.readFile(publicJsonPath, "utf-8")).trim();
return path.resolve(process.cwd(), jsonPath);
};
const jsonDir = await getJsonDirectory();
const metadataFileName = "metadata.json"; const metadataFileName = "metadata.json";
const encoding = "utf-8"; const encoding = "utf-8";

View File

@ -1,198 +0,0 @@
"use client";
import React, { JSX, useEffect, useState } from "react";
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import ApplicationChart from "../../components/ApplicationChart";
interface DataModel {
id: number;
ct_type: number;
disk_size: number;
core_count: number;
ram_size: number;
os_type: string;
os_version: string;
disableip6: string;
nsapp: string;
created_at: string;
method: string;
pve_version: string;
status: string;
error: string;
type: string;
[key: string]: any;
}
interface SummaryData {
total_entries: number;
status_count: Record<string, number>;
nsapp_count: Record<string, number>;
}
const DataFetcher: React.FC = () => {
const [data, setData] = useState<DataModel[]>([]);
const [summary, setSummary] = useState<SummaryData | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'ascending' | 'descending' } | null>(null);
useEffect(() => {
const fetchSummary = async () => {
try {
const response = await fetch("https://api.htl-braunau.at/data/summary");
if (!response.ok) throw new Error(`Failed to fetch summary: ${response.statusText}`);
const result: SummaryData = await response.json();
setSummary(result);
} catch (err) {
setError((err as Error).message);
}
};
fetchSummary();
}, []);
useEffect(() => {
const fetchPaginatedData = async () => {
setLoading(true);
try {
const response = await fetch(`https://api.htl-braunau.at/data/paginated?page=${currentPage}&limit=${itemsPerPage === 0 ? '' : itemsPerPage}`);
if (!response.ok) throw new Error(`Failed to fetch data: ${response.statusText}`);
const result: DataModel[] = await response.json();
setData(result);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
fetchPaginatedData();
}, [currentPage, itemsPerPage]);
const sortedData = React.useMemo(() => {
if (!sortConfig) return data;
const sorted = [...data].sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'ascending' ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'ascending' ? 1 : -1;
}
return 0;
});
return sorted;
}, [data, sortConfig]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
const requestSort = (key: string) => {
let direction: 'ascending' | 'descending' = 'ascending';
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'ascending') {
direction = 'descending';
}
setSortConfig({ key, direction });
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const timezoneOffset = dateString.slice(-6);
return `${day}.${month}.${year} ${hours}:${minutes} ${timezoneOffset} GMT`;
};
return (
<div className="p-6 mt-20">
<h1 className="text-2xl font-bold mb-4 text-center">Created LXCs</h1>
<ApplicationChart data={summary} />
<p className="text-lg font-bold mt-4"> </p>
<div className="mb-4 flex justify-between items-center">
<p className="text-lg font-bold">{summary?.total_entries} results found</p>
<p className="text-lg font">Status Legend: 🔄 installing {summary?.status_count["installing"] ?? 0} | completed {summary?.status_count["done"] ?? 0} | failed {summary?.status_count["failed"] ?? 0} | unknown</p>
</div>
<div className="overflow-x-auto">
<div className="overflow-y-auto lg:overflow-y-visible">
<table className="min-w-full table-auto border-collapse">
<thead>
<tr>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('status')}>Status</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('type')}>Type</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('nsapp')}>Application</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('os_type')}>OS</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('os_version')}>OS Version</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('disk_size')}>Disk Size</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('core_count')}>Core Count</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('ram_size')}>RAM Size</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('method')}>Method</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('pve_version')}>PVE Version</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('error')}>Error Message</th>
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('created_at')}>Created At</th>
</tr>
</thead>
<tbody>
{sortedData.map((item, index) => (
<tr key={index}>
<td className="px-4 py-2 border-b">
{item.status === "done" ? (
"✔️"
) : item.status === "failed" ? (
"❌"
) : item.status === "installing" ? (
"🔄"
) : (
item.status
)}
</td>
<td className="px-4 py-2 border-b">{item.type === "lxc" ? (
"📦"
) : item.type === "vm" ? (
"🖥️"
) : (
item.type
)}</td>
<td className="px-4 py-2 border-b">{item.nsapp}</td>
<td className="px-4 py-2 border-b">{item.os_type}</td>
<td className="px-4 py-2 border-b">{item.os_version}</td>
<td className="px-4 py-2 border-b">{item.disk_size}</td>
<td className="px-4 py-2 border-b">{item.core_count}</td>
<td className="px-4 py-2 border-b">{item.ram_size}</td>
<td className="px-4 py-2 border-b">{item.method}</td>
<td className="px-4 py-2 border-b">{item.pve_version}</td>
<td className="px-4 py-2 border-b">{item.error}</td>
<td className="px-4 py-2 border-b">{formatDate(item.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="mt-4 flex justify-between items-center">
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border">Previous</button>
<span>Page {currentPage}</span>
<button onClick={() => setCurrentPage(prev => prev + 1)} className="p-2 border">Next</button>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="p-2 border"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={250}>250</option>
<option value={500}>500</option>
<option value={5000}>5000</option>
</select>
</div>
</div>
);
};
export default DataFetcher;

View File

@ -31,13 +31,13 @@
"password": "admin123" "password": "admin123"
}, },
"notes": [ "notes": [
{ {
"text": "This LXC is not interchangeable with the Calibre-Web LXC", "text": "This LXC is not interchangeable with the Calibre-Web LXC",
"type": "warning" "type": "warning"
}, },
{ {
"text": "Options enabled by default: Kobo sync; Goodreads author info; metadata extaction from epub, fb2, pdf; cover extraction from cbr, cbz, cbt files" "text": "Options enabled by default: Kobo sync; Goodreads author info; metadata extaction from epub, fb2, pdf; cover extraction from cbr, cbz, cbt files",
"type": "info" "type": "info"
} }
] ]
} }

View File

@ -1,39 +0,0 @@
{
"name": "InvenTree",
"slug": "inventree",
"categories": [
25
],
"date_created": "2025-03-05",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 80,
"documentation": "https://docs.inventree.org/en/latest/",
"website": "https://inventree.org",
"logo": "https://docs.inventree.org/en/latest/assets/logo.png",
"description": "InvenTree is an open-source inventory management system which provides intuitive parts management and stock control. It is designed to be lightweight and easy to use for SME or hobbyist applications.",
"install_methods": [
{
"type": "default",
"script": "ct/inventree.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 6,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": "admin",
"password": "`cat /etc/inventree/admin_password.txt`"
},
"notes": [
{
"text": "Please read the documentation for your configuration needs.",
"type": "info"
}
]
}

View File

@ -1,43 +0,0 @@
{
"name": "Seafile",
"slug": "Seafile",
"categories": [
12
],
"date_created": "2025-02-25",
"type": "ct",
"updateable": false,
"privileged": false,
"interface_port": 8000,
"documentation": "https://manual.seafile.com/11.0/deploy",
"website": "https://seafile.com",
"logo": "https://manual.seafile.com/11.0/media/seafile-transparent-1024.png",
"description": "Seafile is an open source file sync and share platform, focusing on reliability and performance.",
"install_methods": [
{
"type": "default",
"script": "ct/seafile.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 20,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Application credentials: `cat ~/seafile.creds`",
"type": "info"
},
{
"text": "Change STORAGE_DIR value in `external-storage.sh` and run `bash external-storage.sh` to use your defined storage instead of internal.",
"type": "info"
}
]
}

View File

@ -1,43 +0,0 @@
{
"name": "RevealJS",
"slug": "revealjs",
"categories": [
12
],
"date_created": "2025-03-03",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 8000,
"documentation": "https://github.com/hakimel/reveal.js/wiki",
"website": "https://github.com/hakimel/reveal.js",
"logo": "https://static.slid.es/reveal/logo-v1/reveal-white-text.svg",
"description": "reveal.js is an open source HTML presentation framework. It's a tool that enables anyone with a web browser to create fully-featured and beautiful presentations for free.",
"install_methods": [
{
"type": "default",
"script": "ct/revealjs.sh",
"resources": {
"cpu": 1,
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Config file is at `/opt/revealjs/gulpfile.js`. Check the documentation for more information.",
"type": "info"
},
{
"text": "LiveReload is on port: 35729",
"type": "info"
}
]
}

View File

@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2025 community-scripts
# Author: MickLesk
# License: MIT
# https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
set -eEuo pipefail
BL=$(echo "\033[36m")
RD=$(echo "\033[01;31m")
GN=$(echo "\033[1;92m")
CL=$(echo "\033[m")
function header_info {
clear
cat <<"EOF"
____ _ ____________ __ ____ _ ________
/ __ \_________ _ ______ ___ ____ _ _| | / / ____/ __ \ / /_____ / __ \_________ _ ______ ___ ____ _ _| | / / ____/
/ /_/ / ___/ __ \| |/_/ __ `__ \/ __ \| |/_/ | / / __/ / / / / / __/ __ \ / /_/ / ___/ __ \| |/_/ __ `__ \/ __ \| |/_/ | / / __/
/ ____/ / / /_/ /> </ / / / / / /_/ /> < | |/ / /___/ /_/ / / /_/ /_/ / / ____/ / / /_/ /> </ / / / / / /_/ /> < | |/ / /___
/_/ /_/ \____/_/|_/_/ /_/ /_/\____/_/|_| |___/_____/_____/ \__/\____/ /_/ /_/ \____/_/|_/_/ /_/ /_/\____/_/|_| |___/_____/
EOF
}
function update_container() {
container=$1
os=$(pct config "$container" | awk '/^ostype/ {print $2}')
if [[ "$os" == "ubuntu" || "$os" == "debian" || "$os" == "alpine" ]]; then
echo -e "${BL}[Info]${GN} Checking /usr/bin/update in ${BL}$container${CL} (OS: ${GN}$os${CL})"
if pct exec "$container" -- [ -e /usr/bin/update ]; then
pct exec "$container" -- bash -c "sed -i 's/ProxmoxVED/ProxmoxVE/g' /usr/bin/update"
if pct exec "$container" -- grep -q "ProxmoxVE" /usr/bin/update; then
echo -e "${GN}[Success]${CL} /usr/bin/update updated in ${BL}$container${CL}.\n"
else
echo -e "${RD}[Error]${CL} /usr/bin/update in ${BL}$container${CL} could not be updated properly.\n"
fi
else
echo -e "${RD}[Error]${CL} /usr/bin/update not found in container ${BL}$container${CL}.\n"
fi
fi
}
function update_motd() {
container=$1
os=$(pct config "$container" | awk '/^ostype/ {print $2}')
echo -e "${BL}[Debug]${GN} Processing container: ${BL}$container${CL} (OS: ${GN}$os${CL})"
if [[ "$os" == "ubuntu" || "$os" == "debian" ]]; then
echo -e "${BL}[Debug]${GN} Updating Debian/Ubuntu MOTD in ${BL}$container${CL}"
pct exec "$container" -- bash -c "
PROFILE_FILE='/etc/profile.d/00_motd.sh'
echo 'echo -e \"\"' > \"\$PROFILE_FILE\"
echo 'echo -e \"🌐 Provided by: community-scripts ORG | GitHub: https://github.com/community-scripts/ProxmoxVE\"' >> \"\$PROFILE_FILE\"
echo 'echo -e \"🖥️ OS: \$(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '\"') - Version: \$(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '\"')\"' >> \"\$PROFILE_FILE\"
echo 'echo -e \"🏠 Hostname: \$(hostname)\"' >> \"\$PROFILE_FILE\"
echo 'echo -e \"💡 IP Address: \$(hostname -I | awk '\''{print \$1}'\'')\"' >> \"\$PROFILE_FILE\"
chmod -x /etc/update-motd.d/*
"
echo -e "${GN}[Debug] Finished Debian/Ubuntu MOTD update for ${BL}$container${CL}"
elif [[ "$os" == "alpine" ]]; then
echo -e "${BL}[Debug]${GN} Updating Alpine MOTD in ${BL}$container${CL}"
pct exec "$container" -- /bin/sh -c '
echo "[Debug] Alpine: Start updating MOTD" > /tmp/motd_debug.log
echo "export TERM=\"xterm-256color\"" >> /root/.bashrc
echo "[Debug] Alpine: Set TERM variable" >> /tmp/motd_debug.log
IP=$(ip -4 addr show eth0 | awk "/inet / {print \$2}" | cut -d/ -f1 | head -n 1)
echo "[Debug] Alpine: Fetched IP: $IP" >> /tmp/motd_debug.log
PROFILE_FILE="/etc/profile.d/00_lxc-details.sh"
echo "[Debug] Alpine: Writing to profile file" >> /tmp/motd_debug.log
echo "echo -e \"\"" > \"$PROFILE_FILE\"
echo "echo -e \" LXC Container\"" >> \"$PROFILE_FILE\"
echo "echo -e \" 🌐 Provided by: community-scripts ORG | GitHub: https://github.com/community-scripts/ProxmoxVE\"" >> \"$PROFILE_FILE\"
echo "[Debug] Alpine: Wrote MOTD header" >> /tmp/motd_debug.log
echo "echo \"\"" >> \"$PROFILE_FILE\"
echo "echo -e \" 🖥️ OS: $(grep ^NAME /etc/os-release | cut -d= -f2 | tr -d '\"') - Version: $(grep ^VERSION_ID /etc/os-release | cut -d= -f2 | tr -d '\"')\"" >> \"$PROFILE_FILE\"
echo "[Debug] Alpine: Wrote OS details" >> /tmp/motd_debug.log
echo "echo -e \"🏠 Hostname: $(hostname)\"" >> \"$PROFILE_FILE\"
echo "echo -e \"💡 IP Address: $IP\"" >> \"$PROFILE_FILE\"
echo "[Debug] Alpine: Wrote hostname & IP" >> /tmp/motd_debug.log
'
echo -e "${GN}[Debug] Finished Alpine MOTD update for ${BL}$container${CL}"
fi
}
function remove_dev_tag() {
container=$1
current_tags=$(pct config "$container" | awk '/^tags/ {print $2}')
if [[ "$current_tags" == *"dev"* ]]; then
new_tags=$(echo "$current_tags" | sed 's/,*dev,*//g' | sed 's/^,//' | sed 's/,$//')
if [[ -z "$new_tags" ]]; then
pct set "$container" -delete tags
else
pct set "$container" -tags "$new_tags"
fi
echo -e "${GN}[Success]${CL} 'dev' tag removed from ${BL}$container${CL}.\n"
fi
}
header_info
echo "Searching for containers with 'dev' tag..."
for container in $(pct list | awk '{if(NR>1) print $1}'); do
tags=$(pct config "$container" | awk '/^tags/ {print $2}')
if [[ "$tags" == *"dev"* ]]; then
update_container "$container"
update_motd "$container"
remove_dev_tag "$container"
fi
done
header_info
echo -e "${GN}The process is complete.${CL}\n"