Erste Version

This commit is contained in:
Eric Neuber 2025-11-20 20:11:48 +01:00
parent 7e133d55de
commit f2417ec65d
8 changed files with 2729 additions and 1 deletions

2100
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

11
Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "table-server"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4.4.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
tera = "1.19"

44
Dockerfile Normal file
View File

@ -0,0 +1,44 @@
# Build Stage
FROM rust:1.75 as builder
WORKDIR /usr/src/app
# Copy manifest files
COPY Cargo.toml ./
# Copy source code and templates
COPY src ./src
COPY templates ./templates
COPY static ./static
# Build the application
RUN cargo build --release
# Runtime Stage
FROM debian:bookworm-slim
# Install required dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the binary from builder
COPY --from=builder /usr/src/app/target/release/table-server /app/table-server
# Copy templates and static files
COPY --from=builder /usr/src/app/templates /app/templates
COPY --from=builder /usr/src/app/static /app/static
# Create directory for config file
RUN mkdir -p /app/data
# Expose port
EXPOSE 8080
# Set environment to use the data directory
ENV CONFIG_PATH=/app/data/table_config.json
# Run the binary
CMD ["/app/table-server"]

171
README.md
View File

@ -1,2 +1,171 @@
# paramod-rust
# Tabellen Webserver
Ein einfacher Rust-Webserver, der eine editierbare 3x3-Tabelle bereitstellt und in einer JSON-Konfigurationsdatei persistiert.
## Projektstruktur
```
table-server/
├── src/
│ └── main.rs
├── templates/
│ └── index.html
├── static/
│ ├── style.css
│ └── script.js
├── Cargo.toml
├── Dockerfile
├── .gitignore
└── README.md
```
## Funktionen
- Webbasierte Sensor-Konfigurationstabelle mit 6 Spalten
- Editierbare Textfelder (Bezeichnung, Adresse, Type, Faktor)
- Toggle-Schalter für Boolean-Werte (MQTT, InfluxDB)
- Persistierung in JSON-Datei
- Einfache REST-API
- Docker-Unterstützung
## Lokale Entwicklung
### Voraussetzungen
- Rust (Version 1.75 oder höher)
- Cargo
### Installation und Start
```bash
# Projekt erstellen
cargo new table-server
cd table-server
# Dependencies installieren und starten
cargo run
```
Der Server läuft dann auf `http://localhost:8080`
## Docker
### Container bauen
```bash
docker build -t table-server .
```
### Container starten
```bash
docker run -p 8080:8080 -v $(pwd)/data:/app/data table-server
```
Mit Volume-Mount bleibt die Konfigurationsdatei auch nach Container-Neustarts erhalten.
### Docker Compose (optional)
Erstelle eine `docker-compose.yml`:
```yaml
version: '3.8'
services:
table-server:
build: .
ports:
- "8080:8080"
volumes:
- ./data:/app/data
restart: unless-stopped
```
Starten mit:
```bash
docker-compose up -d
```
## Verwendung
1. Öffne `http://localhost:8080` im Browser
2. Bearbeite die Sensor-Konfigurationen:
- **Bezeichnung**: Name des Sensors
- **Adresse**: IP-Adresse oder Identifier
- **Type**: Sensor-Typ (z.B. Temperatur, Luftfeuchtigkeit)
- **Faktor**: Numerischer Korrekturfaktor
- **MQTT**: Toggle-Schalter für MQTT-Aktivierung
- **InfluxDB**: Toggle-Schalter für InfluxDB-Aktivierung
3. Klicke auf "Speichern" um die Änderungen zu persistieren
4. Die Daten werden in `table_config.json` gespeichert
## API Endpoints
- `GET /` - Zeigt die HTML-Seite mit der Tabelle
- `POST /api/save` - Speichert die Tabellendaten
### Beispiel API-Request
```bash
curl -X POST http://localhost:8080/api/save \
-H "Content-Type: application/json" \
-d '{
"rows": [
{
"bezeichnung": "Sensor 1",
"adresse": "192.168.1.100",
"type": "Temperatur",
"faktor": "1.0",
"mqtt": true,
"influxdb": false
},
{
"bezeichnung": "Sensor 2",
"adresse": "192.168.1.101",
"type": "Luftfeuchtigkeit",
"faktor": "0.5",
"mqtt": false,
"influxdb": true
}
]
}'
```
## Konfigurationsdatei
Die Sensor-Daten werden in `table_config.json` gespeichert:
```json
{
"rows": [
{
"bezeichnung": "Sensor 1",
"adresse": "192.168.1.100",
"type": "Temperatur",
"faktor": "1.0",
"mqtt": true,
"influxdb": false
},
{
"bezeichnung": "Sensor 2",
"adresse": "192.168.1.101",
"type": "Luftfeuchtigkeit",
"faktor": "0.5",
"mqtt": false,
"influxdb": true
},
{
"bezeichnung": "Sensor 3",
"adresse": "192.168.1.102",
"type": "Druck",
"faktor": "2.0",
"mqtt": true,
"influxdb": true
}
]
}
```
## Lizenz
MIT

139
src/main.rs Normal file
View File

@ -0,0 +1,139 @@
use actix_web::{web, App, HttpResponse, HttpServer, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::sync::Mutex;
use tera::{Context, Tera};
#[derive(Debug, Serialize, Deserialize, Clone)]
struct TableRow {
bezeichnung: String,
adresse: String,
r#type: String,
faktor: String,
mqtt: bool,
influxdb: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct TableData {
rows: Vec<TableRow>,
}
impl Default for TableData {
fn default() -> Self {
TableData {
rows: vec![
TableRow {
bezeichnung: "Sensor 1".to_string(),
adresse: "192.168.1.100".to_string(),
r#type: "Temperatur".to_string(),
faktor: "1.0".to_string(),
mqtt: true,
influxdb: false,
},
TableRow {
bezeichnung: "Sensor 2".to_string(),
adresse: "192.168.1.101".to_string(),
r#type: "Luftfeuchtigkeit".to_string(),
faktor: "0.5".to_string(),
mqtt: false,
influxdb: true,
},
TableRow {
bezeichnung: "Sensor 3".to_string(),
adresse: "192.168.1.102".to_string(),
r#type: "Druck".to_string(),
faktor: "2.0".to_string(),
mqtt: true,
influxdb: true,
},
],
}
}
}
struct AppState {
table: Mutex<TableData>,
config_path: String,
templates: Tera,
}
impl AppState {
fn load_or_create(config_path: &str) -> Self {
let table = match fs::read_to_string(config_path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => {
let default = TableData::default();
let _ = fs::write(config_path, serde_json::to_string_pretty(&default).unwrap());
default
}
};
let tera = match Tera::new("templates/**/*") {
Ok(t) => t,
Err(e) => {
println!("Template parsing error: {}", e);
std::process::exit(1);
}
};
AppState {
table: Mutex::new(table),
config_path: config_path.to_string(),
templates: tera,
}
}
fn save(&self) -> Result<(), std::io::Error> {
let table = self.table.lock().unwrap();
let json = serde_json::to_string_pretty(&*table)?;
fs::write(&self.config_path, json)
}
}
async fn index(data: web::Data<AppState>) -> Result<HttpResponse> {
let table = data.table.lock().unwrap();
let mut context = Context::new();
context.insert("rows", &table.rows);
let html = data.templates.render("index.html", &context)
.map_err(|e| {
eprintln!("Template error: {}", e);
actix_web::error::ErrorInternalServerError("Template error")
})?;
Ok(HttpResponse::Ok().content_type("text/html").body(html))
}
async fn save_table(
data: web::Data<AppState>,
table_data: web::Json<TableData>,
) -> Result<HttpResponse> {
let mut table = data.table.lock().unwrap();
*table = table_data.into_inner();
drop(table);
match data.save() {
Ok(_) => Ok(HttpResponse::Ok().json(serde_json::json!({"status": "success"}))),
Err(_) => Ok(HttpResponse::InternalServerError().json(serde_json::json!({"status": "error"}))),
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let config_path = "table_config.json";
let app_state = web::Data::new(AppState::load_or_create(config_path));
println!("Server läuft auf http://0.0.0.0:8080");
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
.route("/", web::get().to(index))
.route("/api/save", web::post().to(save_table))
})
.bind("0.0.0.0:8080")?
.run()
.await
}

49
static/script.js Normal file
View File

@ -0,0 +1,49 @@
async function saveTable() {
const rows = [];
const rowCount = document.querySelectorAll('tbody tr').length;
for (let i = 0; i < rowCount; i++) {
const bezeichnung = document.querySelector(`input[data-row='${i}'][data-field='bezeichnung']`).value;
const adresse = document.querySelector(`input[data-row='${i}'][data-field='adresse']`).value;
const type = document.querySelector(`input[data-row='${i}'][data-field='type']`).value;
const faktor = document.querySelector(`input[data-row='${i}'][data-field='faktor']`).value;
const mqtt = document.querySelector(`input[data-row='${i}'][data-field='mqtt']`).checked;
const influxdb = document.querySelector(`input[data-row='${i}'][data-field='influxdb']`).checked;
rows.push({
bezeichnung,
adresse,
type,
faktor,
mqtt,
influxdb
});
}
try {
const response = await fetch('/api/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ rows: rows })
});
const messageDiv = document.getElementById('message');
if (response.ok) {
messageDiv.className = 'message success';
messageDiv.textContent = '✓ Erfolgreich gespeichert!';
} else {
messageDiv.className = 'message error';
messageDiv.textContent = '✗ Fehler beim Speichern!';
}
setTimeout(() => {
messageDiv.style.display = 'none';
}, 3000);
} catch (error) {
const messageDiv = document.getElementById('message');
messageDiv.className = 'message error';
messageDiv.textContent = '✗ Verbindungsfehler!';
}
}

162
static/style.css Normal file
View File

@ -0,0 +1,162 @@
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1200px;
margin: 30px auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
background-color: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
font-size: 28px;
}
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
background-color: white;
}
th {
background-color: #667eea;
color: white;
padding: 15px;
text-align: left;
font-weight: 600;
border: 1px solid #5568d3;
}
td {
border: 1px solid #e0e0e0;
padding: 12px;
}
tr:hover {
background-color: #f8f9ff;
}
.text-input {
width: 100%;
padding: 8px 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
box-sizing: border-box;
font-size: 14px;
transition: border-color 0.3s;
}
.text-input:focus {
outline: none;
border-color: #667eea;
}
/* Toggle Switch Styles */
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #4CAF50;
}
input:focus + .slider {
box-shadow: 0 0 1px #4CAF50;
}
input:checked + .slider:before {
transform: translateX(26px);
}
.save-btn {
display: block;
width: 200px;
margin: 30px auto 0;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
.save-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.save-btn:active {
transform: translateY(0);
}
.message {
text-align: center;
padding: 12px;
margin: 20px 0;
border-radius: 8px;
display: none;
font-weight: 500;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
display: block;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
display: block;
}

54
templates/index.html Normal file
View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sensor Konfiguration</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>🔧 Sensor Konfiguration</h1>
<div id="message" class="message"></div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Bezeichnung</th>
<th>Adresse</th>
<th>Type</th>
<th>Faktor</th>
<th>MQTT</th>
<th>InfluxDB</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td><input type='text' class='text-input' data-row='{{ loop.index0 }}' data-field='bezeichnung' value='{{ row.bezeichnung }}' /></td>
<td><input type='text' class='text-input' data-row='{{ loop.index0 }}' data-field='adresse' value='{{ row.adresse }}' /></td>
<td><input type='text' class='text-input' data-row='{{ loop.index0 }}' data-field='type' value='{{ row.type }}' /></td>
<td><input type='text' class='text-input' data-row='{{ loop.index0 }}' data-field='faktor' value='{{ row.faktor }}' /></td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-row='{{ loop.index0 }}' data-field='mqtt' {% if row.mqtt %}checked{% endif %} />
<span class='slider'></span>
</label>
</td>
<td>
<label class='switch'>
<input type='checkbox' class='bool-input' data-row='{{ loop.index0 }}' data-field='influxdb' {% if row.influxdb %}checked{% endif %} />
<span class='slider'></span>
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button class="save-btn" onclick="saveTable()">💾 Speichern</button>
</div>
<script src="/static/script.js"></script>
</body>
</html>