Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b54f5ecc0 | ||
|
|
2276a124b8 | ||
|
|
216f53c641 | ||
|
|
8903452e96 | ||
|
|
41dbb6297c | ||
|
|
1ac9b08233 | ||
|
|
1827c6de6c | ||
|
|
4f4bdf8d72 | ||
|
|
3b44048250 | ||
|
|
fe267b8766 | ||
|
|
ea23f3f35d | ||
|
|
30ed0bb79b | ||
|
|
c2e4f64d82 | ||
|
|
7a27fb85c4 | ||
|
|
4670666421 | ||
|
|
dcca3fa042 | ||
|
|
8f64760add | ||
|
|
ffa34815cd | ||
|
|
ac6840c759 |
@@ -32,3 +32,9 @@ node_modules/
|
|||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
|
||||||
|
# runtime-generated files
|
||||||
|
access_log.json
|
||||||
|
|
||||||
|
# User and passwords
|
||||||
|
config/users.json
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ Primary files:
|
|||||||
|
|
||||||
## Planned Direction
|
## Planned Direction
|
||||||
|
|
||||||
- The existing admin UI/API in RollCalc is planned for removal.
|
- The previous admin UI/API has been removed from RollCalc.
|
||||||
- Do not expand or repair the admin area unless explicitly requested as a temporary measure.
|
- Do not reintroduce admin screens or admin APIs unless explicitly requested as a temporary measure.
|
||||||
- Keep login/access logging in RollCalc.
|
- Keep login/access logging in RollCalc.
|
||||||
- Administration and maintenance of `article-data.json` should move to a separate application.
|
- Administration and maintenance of `article-data.json` should move to a separate application.
|
||||||
- Treat `templates/admin.html` and `/api/admin/*` expectations as legacy/transitional, not as target architecture.
|
- Treat article-data administration as out of scope for RollCalc.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ These credentials are hardcoded and are a known risk; do not introduce more secr
|
|||||||
- Access logging writes to `access_log.json` by reading and rewriting the whole file.
|
- Access logging writes to `access_log.json` by reading and rewriting the whole file.
|
||||||
- The active UI logic is largely inline in `templates/roll_calculator.html`.
|
- The active UI logic is largely inline in `templates/roll_calculator.html`.
|
||||||
- Several `static/*.js` files appear to be older, alternative, or integration modules. Confirm script inclusion before modifying them.
|
- Several `static/*.js` files appear to be older, alternative, or integration modules. Confirm script inclusion before modifying them.
|
||||||
- The admin template and backend admin routes are inconsistent and scheduled for removal rather than expansion.
|
- The previous admin template and backend admin route have been removed; do not rebuild them by default.
|
||||||
- The disclaimer confirmation is client-side only.
|
- The disclaimer confirmation is client-side only.
|
||||||
|
|
||||||
## Safety Notes
|
## Safety Notes
|
||||||
|
|||||||
+116
-18
@@ -14,13 +14,17 @@ Backend:
|
|||||||
|
|
||||||
- `app.py` creates the Flask app.
|
- `app.py` creates the Flask app.
|
||||||
- HTTP Basic Auth is implemented with `Flask-HTTPAuth`.
|
- HTTP Basic Auth is implemented with `Flask-HTTPAuth`.
|
||||||
- Users are currently hardcoded in `BETA_USERS` and `ADMIN_USERS`.
|
- Users are configured in local deployment file `config/users.json` with Werkzeug password hashes. This file is ignored by Git.
|
||||||
- `/` renders the active calculator template.
|
- `/` renders the active calculator template.
|
||||||
|
- `/help` renders the authenticated Help landing page.
|
||||||
|
- `/help/user-manual` renders `docs/de/user_manual.md` server-side inside the RollCalc Help layout.
|
||||||
|
- `/help/recent-changes` renders `docs/de/recent_changes.md` server-side inside the RollCalc Help layout.
|
||||||
|
- `/help/user-manual/screenshots/<path:filename>` serves German manual screenshots from `docs/de/screenshots/`.
|
||||||
- `/static/<path:filename>` is intended to serve static files behind Basic Auth.
|
- `/static/<path:filename>` is intended to serve static files behind Basic Auth.
|
||||||
- `/api/health` returns health/version information.
|
- `/api/health` returns health/version information.
|
||||||
- `/api/user` returns the authenticated user and admin flag.
|
- `/api/user` returns the authenticated user.
|
||||||
- `/admin/logs` returns access logs for admin users.
|
|
||||||
- Access events are appended to `access_log.json`.
|
- Access events are appended to `access_log.json`.
|
||||||
|
- `build_info.json` is loaded at startup and exposed to all templates as `build_info`.
|
||||||
|
|
||||||
Frontend:
|
Frontend:
|
||||||
|
|
||||||
@@ -29,6 +33,111 @@ Frontend:
|
|||||||
- `window.ARTICLE_DATA` is loaded from `/static/article-data.json`.
|
- `window.ARTICLE_DATA` is loaded from `/static/article-data.json`.
|
||||||
- `window.APP_CONFIG` is currently defined inline for forklift rules.
|
- `window.APP_CONFIG` is currently defined inline for forklift rules.
|
||||||
- A disclaimer modal blocks use until the user checks the confirmation checkbox.
|
- A disclaimer modal blocks use until the user checks the confirmation checkbox.
|
||||||
|
- The footer displays build metadata: version, branch, commit, and timestamp.
|
||||||
|
|
||||||
|
## Build Info
|
||||||
|
|
||||||
|
Build/deployment metadata is read from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
build_info.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected fields:
|
||||||
|
|
||||||
|
- `version`
|
||||||
|
- `branch`
|
||||||
|
- `commit`
|
||||||
|
- `timestamp`
|
||||||
|
|
||||||
|
If `build_info.json` is missing, malformed, or does not contain a usable value, RollCalc falls back to `unknown` for that value and continues serving the site.
|
||||||
|
|
||||||
|
The data is loaded centrally in `app.py` and made available to every template as `build_info`.
|
||||||
|
|
||||||
|
## Help and User Manual
|
||||||
|
|
||||||
|
The in-app Help area keeps `docs/de/user_manual.md` as the leading German documentation source. RollCalc reads the Markdown file on each `/help/user-manual` request and renders it server-side with `markdown-it-py`.
|
||||||
|
|
||||||
|
The documentation structure is prepared for language-specific manuals:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/
|
||||||
|
de/
|
||||||
|
user_manual.md
|
||||||
|
screenshots/
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional languages can follow the same structure later. The current public route still opens the German manual and no language switcher is implemented.
|
||||||
|
|
||||||
|
Rendering rules:
|
||||||
|
|
||||||
|
- Raw HTML from the Markdown source is disabled.
|
||||||
|
- Heading ids and the table of contents are generated from the Markdown content.
|
||||||
|
- Relative image references such as `screenshots/disclaimer.png` are rewritten to the authenticated manual screenshot route.
|
||||||
|
- Screenshots belong in the active language's `screenshots/` directory, currently `docs/de/screenshots/`; the screenshot route must remain limited to that directory.
|
||||||
|
|
||||||
|
The Help pages use the same HTTP Basic Auth protection as the calculator.
|
||||||
|
|
||||||
|
### Recent Changes and What's New
|
||||||
|
|
||||||
|
Full user-facing release notes are maintained in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/de/recent_changes.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The calculator header links directly to `/help/user-manual`. The Help landing page lists only existing documents, currently User Manual and Recent Changes.
|
||||||
|
|
||||||
|
The startup What's New dialog uses `build_info.version` and is suppressed when the version is empty or `unknown`. The last acknowledged version is stored only in browser `localStorage` under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rollcalc_last_seen_version
|
||||||
|
```
|
||||||
|
|
||||||
|
The short dialog item list is maintained separately in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
static/recent_changes.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This JSON is a compact summary for the dialog, not the complete release history. Keep it aligned with the most relevant current entries from `docs/de/recent_changes.md`.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
RollCalc uses HTTP Basic Auth. User entries are stored in the local deployment file:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/users.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This file is intentionally ignored by Git. The versioned file `config/users.example.json` documents the JSON format only and must not contain real usernames, hashes, or passwords.
|
||||||
|
|
||||||
|
The file stores username-to-password-hash mappings:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "<hash>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Password verification uses `werkzeug.security.check_password_hash`. Users are managed with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/manage_users.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The management tool provides a text menu for listing users, creating users, changing passwords, and deleting users. New hashes use the existing RollCalc method `pbkdf2:sha256:600000`.
|
||||||
|
|
||||||
|
The Flask app requires a readable, valid, non-empty `config/users.json` at startup and does not silently create an empty user file. If the file is missing or invalid, startup should fail with a message pointing to `python3 scripts/manage_users.py`.
|
||||||
|
|
||||||
|
Passwords must not be logged. Password hashes should only be stored in `config/users.json` or a future protected deployment-specific secret source.
|
||||||
|
|
||||||
|
Deployment notes:
|
||||||
|
|
||||||
|
- The systemd/service user must be able to read `config/users.json`.
|
||||||
|
- Suitable server permissions can be owner `martin`, group `www-data`, mode `640`, adjusted to the actual deployment users.
|
||||||
|
- Do not hard-code deployment owners or groups in Python.
|
||||||
|
- Back up the productive `config/users.json` before deploying or replacing a server instance.
|
||||||
|
|
||||||
## Roll Geometry
|
## Roll Geometry
|
||||||
|
|
||||||
@@ -184,25 +293,14 @@ This is client-side only. It is not persisted, logged, or enforced server-side.
|
|||||||
|
|
||||||
## Admin Area State
|
## Admin Area State
|
||||||
|
|
||||||
Current backend route:
|
RollCalc no longer contains an admin UI or admin API. The previous `/admin/logs` route and `templates/admin.html` have been removed.
|
||||||
|
|
||||||
```text
|
Login/access logging remains part of RollCalc, but log viewing and article-data administration should not be implemented inside this app. Maintenance of `article-data.json` is planned for a separate application.
|
||||||
/admin/logs
|
|
||||||
```
|
|
||||||
|
|
||||||
Current `templates/admin.html` expects:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/api/admin/stats
|
|
||||||
/api/admin/logs
|
|
||||||
```
|
|
||||||
|
|
||||||
Those `/api/admin/*` endpoints are not currently implemented in `app.py`, and there is no route rendering `templates/admin.html`.
|
|
||||||
|
|
||||||
## Known Technical Risks
|
## Known Technical Risks
|
||||||
|
|
||||||
- Hardcoded plaintext credentials in `app.py`.
|
- Password hashes are currently configured in `app.py`; this should eventually move to a protected external configuration or secrets mechanism.
|
||||||
- Basic Auth only; no sessions or role framework beyond user dictionaries.
|
- Basic Auth only; no sessions or role framework beyond the user dictionary.
|
||||||
- `access_log.json` is rewritten on every logged request and is not concurrency-safe.
|
- `access_log.json` is rewritten on every logged request and is not concurrency-safe.
|
||||||
- No log rotation or retention policy is implemented.
|
- No log rotation or retention policy is implemented.
|
||||||
- Flask's default static route may conflict with the intended authenticated `/static/<path:filename>` behavior; verify effective routing before relying on protected static files.
|
- Flask's default static route may conflict with the intended authenticated `/static/<path:filename>` behavior; verify effective routing before relying on protected static files.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ This README is intended for developers maintaining the project, not for end user
|
|||||||
- Python 3
|
- Python 3
|
||||||
- Flask 2.3.3
|
- Flask 2.3.3
|
||||||
- Flask-HTTPAuth 4.8.0
|
- Flask-HTTPAuth 4.8.0
|
||||||
|
- markdown-it-py 3.0.0 for server-side Markdown rendering
|
||||||
- Vanilla HTML, CSS, and JavaScript
|
- Vanilla HTML, CSS, and JavaScript
|
||||||
- JSON files for product data and forklift/load rules
|
- JSON files for product data and forklift/load rules
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ If port `5000` is already occupied, start through Flask's CLI without changing f
|
|||||||
flask --app app run --host 127.0.0.1 --port 5001
|
flask --app app run --host 127.0.0.1 --port 5001
|
||||||
```
|
```
|
||||||
|
|
||||||
The app uses HTTP Basic Auth. Current credentials are defined in `BETA_USERS` and `ADMIN_USERS` in `app.py`.
|
The app uses HTTP Basic Auth. Current users are defined in a local deployment file, `config/users.json`, with Werkzeug password hashes. This file is intentionally not versioned.
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -51,14 +52,16 @@ The app uses HTTP Basic Auth. Current credentials are defined in `BETA_USERS` an
|
|||||||
├── app.py
|
├── app.py
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── README.md
|
├── README.md
|
||||||
|
├── build_info.json
|
||||||
├── access_log.json
|
├── access_log.json
|
||||||
├── config.json
|
├── config.json
|
||||||
├── article-data_.json
|
├── article-data_.json
|
||||||
├── fix_article_data.py
|
├── fix_article_data.py
|
||||||
├── service-worker.js
|
├── service-worker.js
|
||||||
├── templates/
|
├── templates/
|
||||||
|
│ ├── help_index.html
|
||||||
│ ├── roll_calculator.html
|
│ ├── roll_calculator.html
|
||||||
│ └── admin.html
|
│ └── user_manual.html
|
||||||
├── static/
|
├── static/
|
||||||
│ ├── article-data.json
|
│ ├── article-data.json
|
||||||
│ ├── config.json
|
│ ├── config.json
|
||||||
@@ -71,6 +74,9 @@ The app uses HTTP Basic Auth. Current credentials are defined in `BETA_USERS` an
|
|||||||
│ ├── rollcalc_stddev_ranges.js
|
│ ├── rollcalc_stddev_ranges.js
|
||||||
│ └── rollcalc_stddev_integration.js
|
│ └── rollcalc_stddev_integration.js
|
||||||
└── docs/
|
└── docs/
|
||||||
|
├── de/
|
||||||
|
│ ├── screenshots/
|
||||||
|
│ └── user_manual.md
|
||||||
├── DEPLOYMENT_GUIDE.md
|
├── DEPLOYMENT_GUIDE.md
|
||||||
├── QOL_UPDATE_SUMMARY.md
|
├── QOL_UPDATE_SUMMARY.md
|
||||||
├── STDDEV_IMPLEMENTATION_CHECKLIST.md
|
├── STDDEV_IMPLEMENTATION_CHECKLIST.md
|
||||||
@@ -86,15 +92,138 @@ Implemented routes:
|
|||||||
| Route | Methods | Auth | Purpose |
|
| Route | Methods | Auth | Purpose |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `/` | `GET` | Basic Auth | Renders `templates/roll_calculator.html`. |
|
| `/` | `GET` | Basic Auth | Renders `templates/roll_calculator.html`. |
|
||||||
|
| `/help` | `GET` | Basic Auth | Renders the Help landing page. |
|
||||||
|
| `/help/user-manual` | `GET` | Basic Auth | Reads `docs/de/user_manual.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
|
||||||
|
| `/help/recent-changes` | `GET` | Basic Auth | Reads `docs/de/recent_changes.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
|
||||||
|
| `/help/user-manual/screenshots/<path:filename>` | `GET` | Basic Auth | Serves German user-manual screenshots from `docs/de/screenshots/` only. |
|
||||||
| `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. |
|
| `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. |
|
||||||
| `/api/health` | `GET` | Basic Auth | Returns app health and version. |
|
| `/api/health` | `GET` | Basic Auth | Returns app health and version. |
|
||||||
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user and admin flag. |
|
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. |
|
||||||
| `/admin/logs` | `GET` | Basic Auth plus admin check | Returns `access_log.json` contents. |
|
|
||||||
|
|
||||||
Authentication is implemented with `Flask-HTTPAuth`. The current code merges `BETA_USERS` and `ADMIN_USERS` and performs direct plaintext string comparison.
|
Authentication is implemented with `Flask-HTTPAuth`. The current code loads the local `config/users.json` deployment file and checks password hashes with `werkzeug.security.check_password_hash`; plaintext passwords are not stored in the application.
|
||||||
|
|
||||||
|
## User Management
|
||||||
|
|
||||||
|
Manage users with the interactive CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/manage_users.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The menu supports:
|
||||||
|
|
||||||
|
- List users
|
||||||
|
- Create user
|
||||||
|
- Change password
|
||||||
|
- Delete user
|
||||||
|
|
||||||
|
User data is stored in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/users.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`config/users.json` is local deployment data and is ignored by Git. The versioned file `config/users.example.json` documents the format only and must not contain real usernames, hashes, or passwords.
|
||||||
|
|
||||||
|
The file contains only username-to-password-hash mappings:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "<hash>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Passwords are entered with `getpass`, are never echoed, and are never stored in plaintext. New and changed passwords use the existing RollCalc hash method:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pbkdf2:sha256:600000
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not commit real passwords or print them in logs.
|
||||||
|
|
||||||
|
On a server, ensure that the process running RollCalc can read `config/users.json`. A suitable deployment setup can be:
|
||||||
|
|
||||||
|
```text
|
||||||
|
owner: martin
|
||||||
|
group: www-data
|
||||||
|
mode: 640
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact owner and group depend on the deployment. Do not hard-code them in the application. Before deploying or replacing a server instance, back up the productive `config/users.json` file.
|
||||||
|
|
||||||
Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back.
|
Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back.
|
||||||
|
|
||||||
|
## Help and User Manual
|
||||||
|
|
||||||
|
RollCalc exposes an authenticated Help area at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/help
|
||||||
|
```
|
||||||
|
|
||||||
|
The user manual is available at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/help/user-manual
|
||||||
|
```
|
||||||
|
|
||||||
|
Recent changes are available at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/help/recent-changes
|
||||||
|
```
|
||||||
|
|
||||||
|
`docs/de/user_manual.md` is the leading source for the German user manual. The app reads this Markdown file on request and renders it server-side with `markdown-it-py`; no generated static HTML copy is maintained. Raw HTML in the Markdown source is disabled during rendering.
|
||||||
|
|
||||||
|
`docs/de/recent_changes.md` is the leading source for full German release notes. It is rendered through the same authenticated Markdown infrastructure.
|
||||||
|
|
||||||
|
The documentation directory is prepared for additional languages:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/
|
||||||
|
de/
|
||||||
|
user_manual.md
|
||||||
|
screenshots/
|
||||||
|
```
|
||||||
|
|
||||||
|
The public route `/help/user-manual` currently opens the German manual. No language switcher is implemented yet.
|
||||||
|
|
||||||
|
Screenshots referenced from the manual as `screenshots/filename.png` should be placed in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/de/screenshots/
|
||||||
|
```
|
||||||
|
|
||||||
|
The application rewrites those relative Markdown image paths to the authenticated manual screenshot route, which is limited to the active language's screenshot directory.
|
||||||
|
|
||||||
|
### What's New Dialog
|
||||||
|
|
||||||
|
The calculator page shows a compact What's New dialog once per build version. The current version comes from `build_info.version`; if that value is empty or `unknown`, the dialog is not shown.
|
||||||
|
|
||||||
|
The last acknowledged version is stored only in the browser:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rollcalc_last_seen_version
|
||||||
|
```
|
||||||
|
|
||||||
|
The short dialog summary is maintained in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
static/recent_changes.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `docs/de/recent_changes.md` as the complete release-note source and `static/recent_changes.json` as the short 3-5 item summary for the startup dialog.
|
||||||
|
|
||||||
|
## User Manual PDF Export
|
||||||
|
|
||||||
|
Generate the current PDF version of the German user manual with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build_user_manual_pdf.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script renders `docs/de/user_manual.md` to `docs/de/user_manual.pdf` with Pandoc. The Markdown file remains the leading source.
|
||||||
|
|
||||||
## Frontend Entry Point
|
## Frontend Entry Point
|
||||||
|
|
||||||
The active UI is `templates/roll_calculator.html`.
|
The active UI is `templates/roll_calculator.html`.
|
||||||
@@ -103,6 +232,7 @@ The template contains:
|
|||||||
|
|
||||||
- Page layout and all main CSS.
|
- Page layout and all main CSS.
|
||||||
- A disclaimer modal shown after Basic Auth login and before calculator use.
|
- A disclaimer modal shown after Basic Auth login and before calculator use.
|
||||||
|
- Footer build metadata from `build_info`.
|
||||||
- Global state:
|
- Global state:
|
||||||
- `window.ARTICLE_DATA`
|
- `window.ARTICLE_DATA`
|
||||||
- `window.APP_CONFIG`
|
- `window.APP_CONFIG`
|
||||||
@@ -243,20 +373,9 @@ These provide or describe range calculations for material thickness, area weight
|
|||||||
|
|
||||||
### Admin UI
|
### Admin UI
|
||||||
|
|
||||||
There are admin-oriented HTML files:
|
RollCalc no longer contains an admin UI or admin API. Login/access logging remains in the backend, but logs are not exposed through a RollCalc admin screen.
|
||||||
|
|
||||||
- `templates/admin.html`
|
Administration and maintenance of `article-data.json` is planned for a separate application. RollCalc should continue to consume `static/article-data.json` read-only.
|
||||||
- `admin.html`
|
|
||||||
- `admin_simple.html`
|
|
||||||
|
|
||||||
The backend currently exposes `/admin/logs`, but `templates/admin.html` expects:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/api/admin/stats
|
|
||||||
/api/admin/logs
|
|
||||||
```
|
|
||||||
|
|
||||||
Those `/api/admin/*` routes are not implemented in `app.py` at the time this README was written. There is also no route currently rendering `templates/admin.html`.
|
|
||||||
|
|
||||||
## Static Assets and Data Files
|
## Static Assets and Data Files
|
||||||
|
|
||||||
@@ -274,6 +393,19 @@ JSON audit log written by `app.py`.
|
|||||||
|
|
||||||
Important implementation detail: each logged access reads and rewrites the entire JSON file. This is simple but not concurrency-safe and can become inefficient as the file grows.
|
Important implementation detail: each logged access reads and rewrites the entire JSON file. This is simple but not concurrency-safe and can become inefficient as the file grows.
|
||||||
|
|
||||||
|
### `build_info.json`
|
||||||
|
|
||||||
|
Deployment/build metadata displayed in the site footer.
|
||||||
|
|
||||||
|
Expected fields:
|
||||||
|
|
||||||
|
- `version`
|
||||||
|
- `branch`
|
||||||
|
- `commit`
|
||||||
|
- `timestamp`
|
||||||
|
|
||||||
|
`app.py` loads this file centrally at startup and exposes it to all templates as `build_info`. If the file is missing, invalid, or a field is empty, the affected values fall back to `unknown` and the website continues to work.
|
||||||
|
|
||||||
### `fix_article_data.py`
|
### `fix_article_data.py`
|
||||||
|
|
||||||
Utility script that updates relative frontend fetch/register paths to Flask-style `/static/...` paths and checks that key static files exist.
|
Utility script that updates relative frontend fetch/register paths to Flask-style `/static/...` paths and checks that key static files exist.
|
||||||
@@ -285,15 +417,15 @@ Utility script that updates relative frontend fetch/register paths to Flask-styl
|
|||||||
- There are duplicated or legacy-looking files with similar names. Before editing JavaScript under `static/`, confirm it is actually referenced by the active template.
|
- There are duplicated or legacy-looking files with similar names. Before editing JavaScript under `static/`, confirm it is actually referenced by the active template.
|
||||||
- The documentation under `docs/` contains deployment and feature notes, but some filenames and route assumptions may not match the current app exactly.
|
- The documentation under `docs/` contains deployment and feature notes, but some filenames and route assumptions may not match the current app exactly.
|
||||||
- The Flask dev server is used for local development only. Production should use a WSGI server.
|
- The Flask dev server is used for local development only. Production should use a WSGI server.
|
||||||
|
- Use `scripts/manage_users.py` to add users, rotate passwords, or delete users.
|
||||||
|
|
||||||
## Known Risks and Maintenance Items
|
## Known Risks and Maintenance Items
|
||||||
|
|
||||||
- Credentials are hardcoded in `app.py` and should be moved to environment variables or a secrets manager.
|
- Password hashes are currently stored in `config/users.json`; a secrets manager or protected deployment-specific config would be more robust for production.
|
||||||
- Passwords are stored in plaintext and compared directly.
|
|
||||||
- `access_log.json` is not safe for concurrent writes.
|
- `access_log.json` is not safe for concurrent writes.
|
||||||
- `access_log.json` grows without rotation or retention limits.
|
- `access_log.json` grows without rotation or retention limits.
|
||||||
- The custom `/static/<path:filename>` route is intended to protect static files, but Flask also creates a default static route unless disabled. Verify effective route behavior before relying on static-file protection.
|
- The custom `/static/<path:filename>` route is intended to protect static files, but Flask also creates a default static route unless disabled. Verify effective route behavior before relying on static-file protection.
|
||||||
- Admin frontend and backend routes are currently inconsistent.
|
- RollCalc no longer includes an admin UI/API; article data administration belongs in a separate application.
|
||||||
- There is no visible automated test suite.
|
- There is no visible automated test suite.
|
||||||
- The main template is large and mixes layout, styling, data loading, calculations, and UI behavior.
|
- The main template is large and mixes layout, styling, data loading, calculations, and UI behavior.
|
||||||
- The disclaimer confirmation is client-side only and is not persisted or audited server-side.
|
- The disclaimer confirmation is client-side only and is not persisted or audited server-side.
|
||||||
|
|||||||
-604
@@ -1,604 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:06:19.854940",
|
|
||||||
"username": "beta",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:06:58.223722",
|
|
||||||
"username": "beta",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:07:05.902636",
|
|
||||||
"username": "beta",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:07:51.345366",
|
|
||||||
"username": "beta",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:09:37.481260",
|
|
||||||
"username": "beta",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:14:02.867188",
|
|
||||||
"username": "beta",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:28:08.386403",
|
|
||||||
"username": "beta",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:29:06.541184",
|
|
||||||
"username": "beta",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:30:47.979071",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:46:05.447815",
|
|
||||||
"username": "beta",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T13:46:19.317688",
|
|
||||||
"username": "beta",
|
|
||||||
"endpoint": "/admin",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 403
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T14:42:29.477028",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T14:43:31.374462",
|
|
||||||
"username": "admin",
|
|
||||||
"endpoint": "/admin",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T14:43:31.411860",
|
|
||||||
"username": "admin",
|
|
||||||
"endpoint": "/api/admin/stats",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T14:43:31.562482",
|
|
||||||
"username": "admin",
|
|
||||||
"endpoint": "/api/admin/logs",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T14:44:01.420524",
|
|
||||||
"username": "admin",
|
|
||||||
"endpoint": "/api/admin/stats",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T14:44:01.438789",
|
|
||||||
"username": "admin",
|
|
||||||
"endpoint": "/api/admin/logs",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T14:44:13.773466",
|
|
||||||
"username": "admin",
|
|
||||||
"endpoint": "/admin",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T14:44:13.817782",
|
|
||||||
"username": "admin",
|
|
||||||
"endpoint": "/api/admin/stats",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-05-28T14:44:13.894128",
|
|
||||||
"username": "admin",
|
|
||||||
"endpoint": "/api/admin/logs",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-06-03T05:20:48.808775",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T07:31:48.451519",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T07:31:48.676701",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T07:31:54.481445",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T07:31:58.011356",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T07:32:48.747837",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T07:33:07.753648",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T07:42:11.820817",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T07:43:37.598978",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T07:47:39.689201",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T08:25:47.585793",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T09:32:59.419063",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T09:35:05.236655",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T09:40:47.890997",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T09:41:02.461403",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T09:42:11.914989",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T09:42:57.125161",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T09:54:34.067541",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T10:02:39.927663",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:00:50.808697",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:09:28.615255",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:09:40.011779",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:09:53.681061",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:10:06.334747",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:17:18.862106",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:44:00.451943",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:44:20.277301",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:49:56.333142",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:50:11.692907",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:56:07.569410",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:59:03.417506",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T12:59:21.102453",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:04:52.131022",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:05:09.915771",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:09:55.899370",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:10:20.387853",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:10:20.736005",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:12:48.924277",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:15:13.236624",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:17:32.470202",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:23:23.613251",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:23:24.369105",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:26:48.783516",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:47:07.888072",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:52:38.653855",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:53:02.662397",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T13:57:19.316896",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T14:05:28.671663",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T14:06:26.413418",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T14:06:42.715163",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T14:12:15.337499",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T14:12:19.510992",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-01T14:13:17.018760",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-02T11:28:30.320494",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-02T11:28:46.119436",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-02T11:36:55.439798",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-02T11:41:31.868950",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-02T16:07:56.334163",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-02T16:08:38.097844",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-03T08:39:44.284544",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-03T08:41:57.673023",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-03T08:42:19.877155",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-06T10:34:02.710459",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-06T13:14:58.113794",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-06T13:15:20.071507",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"timestamp": "2026-07-06T13:15:32.538576",
|
|
||||||
"username": "mtazl",
|
|
||||||
"endpoint": "/",
|
|
||||||
"method": "GET",
|
|
||||||
"status": 200
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -3,12 +3,27 @@ Naue Roll Calculator - Beta
|
|||||||
Flask app with HTTP Basic Authentication
|
Flask app with HTTP Basic Authentication
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Flask, render_template, request, send_file, send_from_directory, jsonify
|
from flask import (
|
||||||
|
Flask,
|
||||||
|
abort,
|
||||||
|
jsonify,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
send_file,
|
||||||
|
send_from_directory,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
from flask_httpauth import HTTPBasicAuth
|
from flask_httpauth import HTTPBasicAuth
|
||||||
|
from markdown_it import MarkdownIt
|
||||||
|
from markupsafe import Markup
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
auth = HTTPBasicAuth()
|
auth = HTTPBasicAuth()
|
||||||
@@ -17,33 +32,143 @@ auth = HTTPBasicAuth()
|
|||||||
# CONFIGURATION
|
# CONFIGURATION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Benutzer für Beta-Phase (in Produktion aus env-Variablen laden!)
|
|
||||||
BETA_USERS = {
|
|
||||||
"beta": "rollcalc_beta_2026", # ⚠️ ÄNDERN!
|
|
||||||
"naue": "naue_access_2026", # ⚠️ ÄNDERN!
|
|
||||||
"cniehues": "beta_test_2026", # ⚠️ ÄNDERN!
|
|
||||||
"lvollmert": "Geheim!", # ⚠️ ÄNDERN!
|
|
||||||
"mtazl": "rollcalc", # ⚠️ ÄNDERN!
|
|
||||||
"controlling": "beta2026" # ⚠️ ÄNDERN!
|
|
||||||
}
|
|
||||||
|
|
||||||
# Optional: Admin-Features (z.B. Logs, Stats)
|
|
||||||
ADMIN_USERS = {
|
|
||||||
"admin": "admin_secure_pwd_2026" # ⚠️ ÄNDERN!
|
|
||||||
}
|
|
||||||
|
|
||||||
# Logging für Auditing
|
|
||||||
LOG_FILE = "access_log.json"
|
LOG_FILE = "access_log.json"
|
||||||
|
BUILD_INFO_FILE = "build_info.json"
|
||||||
|
USER_CONFIG_FILE = os.path.join(app.root_path, "config", "users.json")
|
||||||
|
DOCS_DIR = os.path.join(app.root_path, "docs")
|
||||||
|
DEFAULT_DOC_LANGUAGE = "de"
|
||||||
|
USER_MANUAL_FILENAME = "user_manual.md"
|
||||||
|
RECENT_CHANGES_FILENAME = "recent_changes.md"
|
||||||
|
WHATS_NEW_SUMMARY_FILE = os.path.join(app.root_path, "static", "recent_changes.json")
|
||||||
|
UNKNOWN_BUILD_INFO = {
|
||||||
|
"version": "unknown",
|
||||||
|
"branch": "unknown",
|
||||||
|
"commit": "unknown",
|
||||||
|
"timestamp": "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# BUILD INFO
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def load_build_info():
|
||||||
|
"""Load deployment/build metadata for templates."""
|
||||||
|
try:
|
||||||
|
with open(BUILD_INFO_FILE, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Build Info Error] {e}")
|
||||||
|
return UNKNOWN_BUILD_INFO.copy()
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return UNKNOWN_BUILD_INFO.copy()
|
||||||
|
|
||||||
|
build_info = UNKNOWN_BUILD_INFO.copy()
|
||||||
|
for key in build_info:
|
||||||
|
value = data.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
build_info[key] = value.strip()
|
||||||
|
return build_info
|
||||||
|
|
||||||
|
BUILD_INFO = load_build_info()
|
||||||
|
|
||||||
|
|
||||||
|
def load_whats_new_summary():
|
||||||
|
"""Load compact release notes for the What's New dialog."""
|
||||||
|
try:
|
||||||
|
with open(WHATS_NEW_SUMMARY_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[What's New Error] {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
items = data.get("items") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
item.strip()
|
||||||
|
for item in items
|
||||||
|
if isinstance(item, str) and item.strip()
|
||||||
|
][:5]
|
||||||
|
|
||||||
|
|
||||||
|
WHATS_NEW_SUMMARY = load_whats_new_summary()
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_build_info():
|
||||||
|
"""Make build metadata and release-note summary available in templates."""
|
||||||
|
return {
|
||||||
|
"build_info": BUILD_INFO,
|
||||||
|
"whats_new_summary": WHATS_NEW_SUMMARY
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# AUTHENTICATION
|
# AUTHENTICATION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
class UserConfigError(RuntimeError):
|
||||||
|
"""Raised when the user configuration is missing or invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
def load_user_hashes(path=USER_CONFIG_FILE):
|
||||||
|
"""Load user password hashes from config/users.json."""
|
||||||
|
command_hint = "Run python3 scripts/manage_users.py to initialize or repair it."
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise UserConfigError(
|
||||||
|
f"User config file is missing: {path}. {command_hint}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise UserConfigError(
|
||||||
|
f"Invalid JSON in user config file {path}: {e}. {command_hint}"
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
raise UserConfigError(
|
||||||
|
f"Could not read user config file {path}: {e}. {command_hint}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise UserConfigError(
|
||||||
|
f"Invalid user config file {path}: root element must be an object. "
|
||||||
|
f"{command_hint}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
raise UserConfigError(
|
||||||
|
f"User config file {path} does not contain any users. {command_hint}"
|
||||||
|
)
|
||||||
|
|
||||||
|
users = {}
|
||||||
|
for username, value in data.items():
|
||||||
|
if not isinstance(username, str) or not username.strip():
|
||||||
|
raise UserConfigError(
|
||||||
|
f"Invalid user config file {path}: usernames must be strings. "
|
||||||
|
f"{command_hint}"
|
||||||
|
)
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise UserConfigError(
|
||||||
|
f"Invalid user config file {path}: password hash for "
|
||||||
|
f"user {username!r} must be a string. {command_hint}"
|
||||||
|
)
|
||||||
|
users[username.strip()] = value.strip()
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
USER_PASSWORD_HASHES = load_user_hashes()
|
||||||
|
|
||||||
@auth.verify_password
|
@auth.verify_password
|
||||||
def verify_password(username, password):
|
def verify_password(username, password):
|
||||||
"""Verify HTTP Basic Auth credentials"""
|
"""Verify HTTP Basic Auth credentials"""
|
||||||
all_users = {**BETA_USERS, **ADMIN_USERS}
|
password_hash = USER_PASSWORD_HASHES.get(username)
|
||||||
if username in all_users and all_users[username] == password:
|
if not password_hash:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if password_hash and check_password_hash(password_hash, password):
|
||||||
return username
|
return username
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -67,10 +192,119 @@ def log_access(username, endpoint, method, status=200):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Logging Error] {e}")
|
print(f"[Logging Error] {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0"):
|
||||||
|
"""Find the first available TCP port at or above start_port."""
|
||||||
|
for port in range(start_port, start_port + max_attempts):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
try:
|
||||||
|
sock.bind((host, port))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return port
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No free port found from {start_port} to "
|
||||||
|
f"{start_port + max_attempts - 1}"
|
||||||
|
)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ROUTES
|
# ROUTES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
def slugify_heading(text):
|
||||||
|
"""Create stable, URL-friendly heading ids for rendered Markdown."""
|
||||||
|
normalized = unicodedata.normalize("NFKD", text)
|
||||||
|
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
|
||||||
|
slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_text).strip("-").lower()
|
||||||
|
return slug or "section"
|
||||||
|
|
||||||
|
|
||||||
|
def build_manual_renderer():
|
||||||
|
"""Build a Markdown renderer with raw HTML disabled."""
|
||||||
|
renderer = MarkdownIt("commonmark", {"html": False})
|
||||||
|
renderer.enable("table")
|
||||||
|
return renderer
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_path(filename, language=DEFAULT_DOC_LANGUAGE):
|
||||||
|
"""Return the Markdown path for a language-specific help document."""
|
||||||
|
return os.path.join(DOCS_DIR, language, filename)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manual_path(language=DEFAULT_DOC_LANGUAGE):
|
||||||
|
"""Return the Markdown path for a language-specific manual."""
|
||||||
|
return get_document_path(USER_MANUAL_FILENAME, language)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manual_screenshot_dir(language=DEFAULT_DOC_LANGUAGE):
|
||||||
|
"""Return the screenshot directory for a language-specific manual."""
|
||||||
|
return os.path.join(DOCS_DIR, language, "screenshots")
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_manual_asset_paths(markdown_text, language=DEFAULT_DOC_LANGUAGE):
|
||||||
|
"""Route relative manual screenshot links through the protected docs route."""
|
||||||
|
screenshot_url = url_for("manual_screenshot", filename="")
|
||||||
|
return markdown_text.replace("](screenshots/", f"]({screenshot_url}")
|
||||||
|
|
||||||
|
|
||||||
|
def add_heading_ids_and_toc(tokens):
|
||||||
|
"""Attach ids to headings and create a compact table of contents."""
|
||||||
|
toc = []
|
||||||
|
used_slugs = {}
|
||||||
|
|
||||||
|
for index, token in enumerate(tokens):
|
||||||
|
if token.type != "heading_open":
|
||||||
|
continue
|
||||||
|
|
||||||
|
level = int(token.tag[1])
|
||||||
|
inline_token = tokens[index + 1] if index + 1 < len(tokens) else None
|
||||||
|
title = (
|
||||||
|
inline_token.content
|
||||||
|
if inline_token and inline_token.type == "inline"
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
base_slug = slugify_heading(title)
|
||||||
|
count = used_slugs.get(base_slug, 0)
|
||||||
|
used_slugs[base_slug] = count + 1
|
||||||
|
slug = base_slug if count == 0 else f"{base_slug}-{count + 1}"
|
||||||
|
token.attrSet("id", slug)
|
||||||
|
|
||||||
|
if level <= 2:
|
||||||
|
toc.append({
|
||||||
|
"level": level,
|
||||||
|
"title": title,
|
||||||
|
"id": slug
|
||||||
|
})
|
||||||
|
|
||||||
|
return toc
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown_document(filename, language=DEFAULT_DOC_LANGUAGE):
|
||||||
|
"""Read and render a Markdown help document into HTML and a generated TOC."""
|
||||||
|
renderer = build_manual_renderer()
|
||||||
|
document_path = get_document_path(filename, language)
|
||||||
|
|
||||||
|
with open(document_path, "r", encoding="utf-8") as document_file:
|
||||||
|
markdown_text = document_file.read()
|
||||||
|
|
||||||
|
markdown_text = rewrite_manual_asset_paths(markdown_text, language)
|
||||||
|
tokens = renderer.parse(markdown_text)
|
||||||
|
toc = add_heading_ids_and_toc(tokens)
|
||||||
|
html = renderer.renderer.render(tokens, renderer.options, {})
|
||||||
|
return Markup(html), toc
|
||||||
|
|
||||||
|
|
||||||
|
def render_user_manual(language=DEFAULT_DOC_LANGUAGE):
|
||||||
|
"""Read and render the Markdown user manual."""
|
||||||
|
return render_markdown_document(USER_MANUAL_FILENAME, language)
|
||||||
|
|
||||||
|
|
||||||
|
def render_recent_changes(language=DEFAULT_DOC_LANGUAGE):
|
||||||
|
"""Read and render the Markdown recent changes document."""
|
||||||
|
return render_markdown_document(RECENT_CHANGES_FILENAME, language)
|
||||||
|
|
||||||
@app.route("/", methods=["GET"])
|
@app.route("/", methods=["GET"])
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def index():
|
def index():
|
||||||
@@ -78,6 +312,92 @@ def index():
|
|||||||
log_access(auth.current_user(), "/", "GET")
|
log_access(auth.current_user(), "/", "GET")
|
||||||
return render_template("roll_calculator.html")
|
return render_template("roll_calculator.html")
|
||||||
|
|
||||||
|
@app.route("/help", methods=["GET"])
|
||||||
|
@auth.login_required
|
||||||
|
def help_index():
|
||||||
|
"""Help landing page - requires authentication."""
|
||||||
|
log_access(auth.current_user(), "/help", "GET")
|
||||||
|
return render_template("help_index.html")
|
||||||
|
|
||||||
|
@app.route("/help/user-manual", methods=["GET"])
|
||||||
|
@auth.login_required
|
||||||
|
def user_manual():
|
||||||
|
"""Render the Markdown user manual inside the RollCalc layout."""
|
||||||
|
log_access(auth.current_user(), "/help/user-manual", "GET")
|
||||||
|
try:
|
||||||
|
manual_html, toc = render_user_manual(DEFAULT_DOC_LANGUAGE)
|
||||||
|
return render_template(
|
||||||
|
"user_manual.html",
|
||||||
|
document_title="User Manual",
|
||||||
|
document_subtitle="RollCalc technical user documentation",
|
||||||
|
error_title="User Manual unavailable",
|
||||||
|
manual_html=manual_html,
|
||||||
|
toc=toc,
|
||||||
|
render_error=None
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
app.logger.exception("Could not render user manual")
|
||||||
|
return render_template(
|
||||||
|
"user_manual.html",
|
||||||
|
document_title="User Manual",
|
||||||
|
document_subtitle="RollCalc technical user documentation",
|
||||||
|
error_title="User Manual unavailable",
|
||||||
|
manual_html=None,
|
||||||
|
toc=[],
|
||||||
|
render_error=(
|
||||||
|
"The user manual could not be loaded. "
|
||||||
|
"Please contact the RollCalc maintainer if the problem persists."
|
||||||
|
)
|
||||||
|
), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/help/recent-changes", methods=["GET"])
|
||||||
|
@auth.login_required
|
||||||
|
def recent_changes():
|
||||||
|
"""Render recent changes inside the RollCalc Help layout."""
|
||||||
|
log_access(auth.current_user(), "/help/recent-changes", "GET")
|
||||||
|
try:
|
||||||
|
changes_html, toc = render_recent_changes(DEFAULT_DOC_LANGUAGE)
|
||||||
|
return render_template(
|
||||||
|
"user_manual.html",
|
||||||
|
document_title="Recent Changes",
|
||||||
|
document_subtitle="RollCalc release notes",
|
||||||
|
error_title="Recent Changes unavailable",
|
||||||
|
manual_html=changes_html,
|
||||||
|
toc=toc,
|
||||||
|
render_error=None
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
app.logger.exception("Could not render recent changes")
|
||||||
|
return render_template(
|
||||||
|
"user_manual.html",
|
||||||
|
document_title="Recent Changes",
|
||||||
|
document_subtitle="RollCalc release notes",
|
||||||
|
error_title="Recent Changes unavailable",
|
||||||
|
manual_html=None,
|
||||||
|
toc=[],
|
||||||
|
render_error=(
|
||||||
|
"Recent changes could not be loaded. "
|
||||||
|
"Please contact the RollCalc maintainer if the problem persists."
|
||||||
|
)
|
||||||
|
), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/help/user-manual/screenshots/<path:filename>", methods=["GET"])
|
||||||
|
@auth.login_required
|
||||||
|
def manual_screenshot(filename, language=DEFAULT_DOC_LANGUAGE):
|
||||||
|
"""Serve user-manual screenshots from the selected language directory."""
|
||||||
|
log_access(
|
||||||
|
auth.current_user(),
|
||||||
|
f"/help/user-manual/screenshots/{filename}",
|
||||||
|
"GET"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return send_from_directory(get_manual_screenshot_dir(language), filename)
|
||||||
|
except Exception:
|
||||||
|
app.logger.warning("Missing or inaccessible manual screenshot: %s", filename)
|
||||||
|
abort(404)
|
||||||
|
|
||||||
@app.route("/static/<path:filename>", methods=["GET"])
|
@app.route("/static/<path:filename>", methods=["GET"])
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def serve_static(filename):
|
def serve_static(filename):
|
||||||
@@ -96,30 +416,12 @@ def health_check():
|
|||||||
def get_user():
|
def get_user():
|
||||||
"""Get current authenticated user info"""
|
"""Get current authenticated user info"""
|
||||||
user = auth.current_user()
|
user = auth.current_user()
|
||||||
is_admin = user in ADMIN_USERS
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"username": user,
|
"username": user,
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"is_admin": is_admin,
|
|
||||||
"timestamp": datetime.now().isoformat()
|
"timestamp": datetime.now().isoformat()
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
@app.route("/admin/logs", methods=["GET"])
|
|
||||||
@auth.login_required
|
|
||||||
def get_logs():
|
|
||||||
"""View access logs - admin only"""
|
|
||||||
user = auth.current_user()
|
|
||||||
if user not in ADMIN_USERS:
|
|
||||||
log_access(user, "/admin/logs", "GET", 403)
|
|
||||||
return jsonify({"error": "Unauthorized"}), 403
|
|
||||||
|
|
||||||
log_access(user, "/admin/logs", "GET")
|
|
||||||
logs = []
|
|
||||||
if os.path.exists(LOG_FILE):
|
|
||||||
with open(LOG_FILE, "r") as f:
|
|
||||||
logs = json.load(f)
|
|
||||||
return jsonify({"logs": logs}), 200
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ERROR HANDLERS
|
# ERROR HANDLERS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -146,21 +448,22 @@ def internal_error(e):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
os.makedirs("templates", exist_ok=True)
|
os.makedirs("templates", exist_ok=True)
|
||||||
os.makedirs("static", exist_ok=True)
|
os.makedirs("static", exist_ok=True)
|
||||||
|
port = find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0")
|
||||||
|
|
||||||
print("""
|
print(f"""
|
||||||
╔═══════════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════════╗
|
||||||
║ Naue Roll Calculator - V14 + QoL Update ║
|
║ Naue Roll Calculator - V14 + QoL Update ║
|
||||||
║ HTTP Basic Auth Enabled ║
|
║ HTTP Basic Auth Enabled ║
|
||||||
║ ║
|
║ ║
|
||||||
║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║
|
║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║
|
||||||
║ ║
|
║ ║
|
||||||
║ Starten auf: http://localhost:5000 ║
|
║ Starten auf: http://localhost:{port:<5} ║
|
||||||
╚═══════════════════════════════════════════════════════════╝
|
╚═══════════════════════════════════════════════════════════╝
|
||||||
""")
|
""")
|
||||||
|
|
||||||
app.run(
|
app.run(
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=5000,
|
port=port,
|
||||||
debug=False,
|
debug=False,
|
||||||
use_reloader=False
|
use_reloader=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": "v0.4-dev",
|
||||||
|
"branch": "feature/transport-capacity-optimizer",
|
||||||
|
"commit": "216f53c",
|
||||||
|
"timestamp": "2026-07-28T13:02:23+02:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"example-user": "pbkdf2:sha256:600000$example-salt$example-hash"
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Documentation Guidelines
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
The primary documentation language is German.
|
||||||
|
|
||||||
|
An English version will be maintained as a translation of the German master document.
|
||||||
|
|
||||||
|
## UI terminology
|
||||||
|
|
||||||
|
All user interface terms remain in English.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Direct Calculation
|
||||||
|
- Extrapolation
|
||||||
|
- Roll Diameter
|
||||||
|
- Product Length
|
||||||
|
- Capacity Summary
|
||||||
|
- Capacity Analysis
|
||||||
|
- Governing Constraint
|
||||||
|
|
||||||
|
Do not translate UI labels.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Technical user manual
|
||||||
|
- Workflow-oriented
|
||||||
|
- Explain why, not only how
|
||||||
|
- Engineering language
|
||||||
|
- No marketing language
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Write German chapter.
|
||||||
|
2. Technical review.
|
||||||
|
3. Add screenshots.
|
||||||
|
4. Approve.
|
||||||
|
5. Translate to English.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Recent Changes
|
||||||
|
|
||||||
|
## Version 0.4
|
||||||
|
|
||||||
|
- User Manual integrated into RollCalc.
|
||||||
|
- Help area added for internal documentation.
|
||||||
|
- Capacity Analysis presentation improved.
|
||||||
|
- Governing Constraint highlighted more clearly.
|
||||||
|
- Remaining Capacity added to the Capacity Summary.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,3 +5,4 @@ click==8.1.7
|
|||||||
itsdangerous==2.1.2
|
itsdangerous==2.1.2
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
MarkupSafe==2.1.3
|
MarkupSafe==2.1.3
|
||||||
|
markdown-it-py==3.0.0
|
||||||
|
|||||||
Executable
+238
@@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Interactive RollCalc user management."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from getpass import getpass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
USER_FILE = ROOT_DIR / "config" / "users.json"
|
||||||
|
HASH_METHOD = "pbkdf2:sha256:600000"
|
||||||
|
|
||||||
|
|
||||||
|
class UserManagementError(Exception):
|
||||||
|
"""Expected user-management error."""
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_user_file(path=USER_FILE):
|
||||||
|
"""Create an empty user file if needed."""
|
||||||
|
path = Path(path)
|
||||||
|
if path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("{}\n", encoding="utf-8")
|
||||||
|
set_restrictive_permissions(path)
|
||||||
|
|
||||||
|
|
||||||
|
def set_restrictive_permissions(path):
|
||||||
|
"""Set user-only read/write permissions where supported."""
|
||||||
|
try:
|
||||||
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Warning: could not set restrictive permissions: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_users(path=USER_FILE):
|
||||||
|
"""Load user hashes from JSON."""
|
||||||
|
path = Path(path)
|
||||||
|
ensure_user_file(path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise UserManagementError(f"Invalid JSON in {path}: {exc}") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise UserManagementError(f"Could not read {path}: {exc}") from exc
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise UserManagementError(f"Invalid user file format in {path}: expected object")
|
||||||
|
|
||||||
|
users = {}
|
||||||
|
for username, password_hash in data.items():
|
||||||
|
if not isinstance(username, str) or not username.strip():
|
||||||
|
raise UserManagementError(f"Invalid username in {path}")
|
||||||
|
if not isinstance(password_hash, str) or not password_hash.strip():
|
||||||
|
raise UserManagementError(f"Invalid password hash for user {username!r}")
|
||||||
|
users[username.strip()] = password_hash.strip()
|
||||||
|
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
def save_users(users, path=USER_FILE):
|
||||||
|
"""Persist user hashes to JSON."""
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
existing_mode = None
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
existing_mode = stat.S_IMODE(path.stat().st_mode)
|
||||||
|
except OSError:
|
||||||
|
existing_mode = None
|
||||||
|
|
||||||
|
temp_path = path.with_name(f".{path.name}.tmp")
|
||||||
|
try:
|
||||||
|
temp_path.write_text(
|
||||||
|
json.dumps(users, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
if existing_mode is not None:
|
||||||
|
os.chmod(temp_path, existing_mode)
|
||||||
|
else:
|
||||||
|
os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||||
|
os.replace(temp_path, path)
|
||||||
|
except OSError as exc:
|
||||||
|
try:
|
||||||
|
temp_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise UserManagementError(f"Could not write {path}: {exc}") from exc
|
||||||
|
if existing_mode is None:
|
||||||
|
set_restrictive_permissions(path)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password):
|
||||||
|
"""Hash a password with RollCalc's established hash method."""
|
||||||
|
return generate_password_hash(password, method=HASH_METHOD)
|
||||||
|
|
||||||
|
|
||||||
|
def add_user(users, username, password):
|
||||||
|
"""Add a user to a user dictionary."""
|
||||||
|
username = username.strip()
|
||||||
|
if not username:
|
||||||
|
raise UserManagementError("Username must not be empty.")
|
||||||
|
if username in users:
|
||||||
|
raise UserManagementError(f'User "{username}" already exists.')
|
||||||
|
users[username] = hash_password(password)
|
||||||
|
|
||||||
|
|
||||||
|
def update_password(users, username, password):
|
||||||
|
"""Update an existing user's password hash."""
|
||||||
|
username = username.strip()
|
||||||
|
if username not in users:
|
||||||
|
raise UserManagementError(f'User "{username}" does not exist.')
|
||||||
|
users[username] = hash_password(password)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_user(users, username):
|
||||||
|
"""Remove an existing user."""
|
||||||
|
username = username.strip()
|
||||||
|
if username not in users:
|
||||||
|
raise UserManagementError(f'User "{username}" does not exist.')
|
||||||
|
del users[username]
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_password_pair():
|
||||||
|
"""Read and validate a repeated password."""
|
||||||
|
password = getpass("Password: ")
|
||||||
|
repeat = getpass("Repeat password: ")
|
||||||
|
if password != repeat:
|
||||||
|
raise UserManagementError("Passwords do not match.")
|
||||||
|
if not password:
|
||||||
|
raise UserManagementError("Password must not be empty.")
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
|
def list_users(users):
|
||||||
|
"""Print existing usernames without hashes."""
|
||||||
|
print("\nExisting users\n")
|
||||||
|
if not users:
|
||||||
|
print("(none)")
|
||||||
|
else:
|
||||||
|
for username in sorted(users):
|
||||||
|
print(f"- {username}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def create_user_interactive(users):
|
||||||
|
"""Create a user from prompts."""
|
||||||
|
username = input("Username: ").strip()
|
||||||
|
password = prompt_password_pair()
|
||||||
|
add_user(users, username, password)
|
||||||
|
print(f'User "{username}" created.')
|
||||||
|
|
||||||
|
|
||||||
|
def change_password_interactive(users):
|
||||||
|
"""Change a user's password from prompts."""
|
||||||
|
username = input("Username: ").strip()
|
||||||
|
password = prompt_password_pair()
|
||||||
|
update_password(users, username, password)
|
||||||
|
print(f'Password for "{username}" changed.')
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_interactive(users):
|
||||||
|
"""Delete a user after confirmation."""
|
||||||
|
username = input("Username: ").strip()
|
||||||
|
if username not in users:
|
||||||
|
raise UserManagementError(f'User "{username}" does not exist.')
|
||||||
|
|
||||||
|
confirmation = input(f'Delete user "{username}"? yes/no: ').strip().lower()
|
||||||
|
if confirmation != "yes":
|
||||||
|
print("Delete cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
remove_user(users, username)
|
||||||
|
print(f'User "{username}" deleted.')
|
||||||
|
|
||||||
|
|
||||||
|
def print_menu():
|
||||||
|
"""Print the main menu."""
|
||||||
|
print(
|
||||||
|
"\n"
|
||||||
|
"--------------------------------------------------\n\n"
|
||||||
|
"RollCalc User Management\n\n"
|
||||||
|
"1 - List users\n"
|
||||||
|
"2 - Create user\n"
|
||||||
|
"3 - Change password\n"
|
||||||
|
"4 - Delete user\n"
|
||||||
|
"5 - Exit\n\n"
|
||||||
|
"--------------------------------------------------"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_menu(path=USER_FILE):
|
||||||
|
"""Run the interactive menu."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
users = load_users(path)
|
||||||
|
except UserManagementError as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print_menu()
|
||||||
|
choice = input("Select option: ").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if choice == "1":
|
||||||
|
list_users(users)
|
||||||
|
continue
|
||||||
|
if choice == "2":
|
||||||
|
create_user_interactive(users)
|
||||||
|
elif choice == "3":
|
||||||
|
change_password_interactive(users)
|
||||||
|
elif choice == "4":
|
||||||
|
delete_user_interactive(users)
|
||||||
|
elif choice == "5":
|
||||||
|
print("Exit.")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("Unknown option.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
save_users(users, path)
|
||||||
|
except UserManagementError as exc:
|
||||||
|
print(f"Error: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""CLI entry point."""
|
||||||
|
return run_menu()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
VERSION="${1:-v0.4-dev}"
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
cat > build_info.json <<EOF
|
||||||
|
{
|
||||||
|
"version": "$VERSION",
|
||||||
|
"branch": "$(git branch --show-current)",
|
||||||
|
"commit": "$(git rev-parse --short HEAD)",
|
||||||
|
"timestamp": "$(date -Iseconds)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"User Manual integrated",
|
||||||
|
"Capacity Analysis improved",
|
||||||
|
"Governing Constraint clarified",
|
||||||
|
"Remaining Capacity added"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,568 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="theme-color" content="#0066cc">
|
|
||||||
<title>Admin Panel – Roll Calculator</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Arial, sans-serif;
|
|
||||||
background: #f4f6f9;
|
|
||||||
color: #333;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- HEADER ---- */
|
|
||||||
.admin-header {
|
|
||||||
background: linear-gradient(135deg, #003366 0%, #0066cc 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 20px 32px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header .subtitle {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header .user-info {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.9;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header .user-info strong {
|
|
||||||
opacity: 1;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- CONTAINER ---- */
|
|
||||||
.admin-container {
|
|
||||||
max-width: 1300px;
|
|
||||||
margin: 30px auto;
|
|
||||||
padding: 0 20px 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- STATS GRID ---- */
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
|
|
||||||
border-left: 4px solid #0066cc;
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.warning {
|
|
||||||
border-left-color: #f0b429;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.danger {
|
|
||||||
border-left-color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .value {
|
|
||||||
font-size: 36px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0066cc;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.warning .value {
|
|
||||||
color: #f0b429;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.danger .value {
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card .sublabel {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #999;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- LOGS SECTION ---- */
|
|
||||||
.logs-section {
|
|
||||||
background: white;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 24px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-header h2 {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #003366;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
background: #0066cc;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: #0052a3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.secondary {
|
|
||||||
background: #e0e0e0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.secondary:hover {
|
|
||||||
background: #d0d0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-refresh {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-refresh.spinning {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- TABLE ---- */
|
|
||||||
.logs-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-table thead {
|
|
||||||
background: #f0f4fa;
|
|
||||||
border-bottom: 2px solid #0066cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-table th {
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-table td {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 1px solid #e8ecf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-table tbody tr {
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-table tbody tr:hover {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-200 {
|
|
||||||
color: #28a745;
|
|
||||||
font-weight: 600;
|
|
||||||
background: #f0f9f6;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-401 {
|
|
||||||
color: #dc3545;
|
|
||||||
font-weight: 600;
|
|
||||||
background: #fff0f0;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-403 {
|
|
||||||
color: #f0b429;
|
|
||||||
font-weight: 600;
|
|
||||||
background: #fffbea;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- LOADING / EMPTY ---- */
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #999;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background: #fff0f0;
|
|
||||||
color: #dc3545;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border-left: 4px solid #dc3545;
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- FILTERS ---- */
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #0066cc;
|
|
||||||
box-shadow: 0 0 0 2px rgba(0,102,204,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- FOOTER ---- */
|
|
||||||
.admin-footer {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #aaa;
|
|
||||||
margin-top: 40px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #e8ecf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.admin-header {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header .user-info {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-table {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-table th, .logs-table td {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<!-- HEADER -->
|
|
||||||
<div class="admin-header">
|
|
||||||
<div>
|
|
||||||
<h1>🔐 Admin Panel</h1>
|
|
||||||
<div class="subtitle">Roll Calculator – Naue GmbH & Co. KG</div>
|
|
||||||
</div>
|
|
||||||
<div class="user-info">
|
|
||||||
<span>Angemeldet als:</span>
|
|
||||||
<strong id="authUser">admin</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- MAIN CONTAINER -->
|
|
||||||
<div class="admin-container">
|
|
||||||
|
|
||||||
<!-- STATISTICS -->
|
|
||||||
<div class="stats-grid" id="statsGrid">
|
|
||||||
<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: #999;">
|
|
||||||
⏳ Statistiken werden geladen...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- LOGS SECTION -->
|
|
||||||
<div class="logs-section">
|
|
||||||
|
|
||||||
<!-- HEADER -->
|
|
||||||
<div class="logs-header">
|
|
||||||
<h2>📋 Access Logs</h2>
|
|
||||||
<div class="controls">
|
|
||||||
<button class="btn" id="refreshBtn" onclick="refreshData()">
|
|
||||||
<span id="refreshIcon">🔄</span> Aktualisieren
|
|
||||||
</button>
|
|
||||||
<button class="btn secondary" onclick="downloadLogs()">
|
|
||||||
⬇️ Download
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FILTERS -->
|
|
||||||
<div class="filters">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="filter-input"
|
|
||||||
placeholder="Nach Benutzer filtern..."
|
|
||||||
id="filterUser"
|
|
||||||
onkeyup="filterLogs()"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="filter-input"
|
|
||||||
placeholder="Nach Endpoint filtern..."
|
|
||||||
id="filterEndpoint"
|
|
||||||
onkeyup="filterLogs()"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TABLE -->
|
|
||||||
<div id="errorContainer"></div>
|
|
||||||
|
|
||||||
<table class="logs-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Zeitstempel</th>
|
|
||||||
<th>Benutzer</th>
|
|
||||||
<th>Endpoint</th>
|
|
||||||
<th>Methode</th>
|
|
||||||
<th style="width: 80px;">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="logsList">
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="loading">Logs werden geladen...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FOOTER -->
|
|
||||||
<div class="admin-footer">
|
|
||||||
ℹ️ Logs werden automatisch alle 30 Sekunden aktualisiert |
|
|
||||||
Letztes Update: <span id="lastUpdate">-</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SCRIPTS -->
|
|
||||||
<script>
|
|
||||||
let allLogs = [];
|
|
||||||
|
|
||||||
// Get authenticated user from API
|
|
||||||
async function getAuthUser() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/user');
|
|
||||||
const data = await res.json();
|
|
||||||
document.getElementById('authUser').textContent = data.username || 'admin';
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Konnte Benutzerinformation nicht laden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh Data (Statistiken + Logs)
|
|
||||||
async function refreshData() {
|
|
||||||
const btn = document.getElementById('refreshBtn');
|
|
||||||
const icon = document.getElementById('refreshIcon');
|
|
||||||
|
|
||||||
try {
|
|
||||||
icon.style.animation = 'spin 1s linear infinite';
|
|
||||||
|
|
||||||
// Load Statistics
|
|
||||||
const statsRes = await fetch('/api/admin/stats');
|
|
||||||
if (!statsRes.ok) throw new Error('Statistiken konnten nicht geladen werden');
|
|
||||||
const stats = await statsRes.json();
|
|
||||||
|
|
||||||
// Load Logs
|
|
||||||
const logsRes = await fetch('/api/admin/logs');
|
|
||||||
if (!logsRes.ok) throw new Error('Logs konnten nicht geladen werden');
|
|
||||||
const logsData = await logsRes.json();
|
|
||||||
|
|
||||||
allLogs = logsData.logs || [];
|
|
||||||
|
|
||||||
// Render Statistics
|
|
||||||
renderStats(stats);
|
|
||||||
|
|
||||||
// Render Logs
|
|
||||||
renderLogs(allLogs);
|
|
||||||
|
|
||||||
// Update timestamp
|
|
||||||
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE');
|
|
||||||
|
|
||||||
// Clear error
|
|
||||||
document.getElementById('errorContainer').innerHTML = '';
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Laden der Daten:', error);
|
|
||||||
document.getElementById('errorContainer').innerHTML =
|
|
||||||
'<div class="error">❌ ' + error.message + '</div>';
|
|
||||||
} finally {
|
|
||||||
icon.style.animation = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render Statistics
|
|
||||||
function renderStats(stats) {
|
|
||||||
const html = `
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="label">📊 Gesamte Anfragen</div>
|
|
||||||
<div class="value">${stats.total_requests || 0}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="label">👥 Eindeutige Benutzer</div>
|
|
||||||
<div class="value">${stats.unique_users || 0}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card danger">
|
|
||||||
<div class="label">⛔ Fehlgeschlagene Logins</div>
|
|
||||||
<div class="value">${stats.failed_logins || 0}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.getElementById('statsGrid').innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render Logs
|
|
||||||
function renderLogs(logs) {
|
|
||||||
if (!logs || logs.length === 0) {
|
|
||||||
document.getElementById('logsList').innerHTML =
|
|
||||||
'<tr><td colspan="5" class="empty">Keine Logs verfügbar</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = logs.reverse().map(log => {
|
|
||||||
const time = new Date(log.timestamp).toLocaleString('de-DE');
|
|
||||||
const statusClass = `status-${log.status}`;
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td>${time}</td>
|
|
||||||
<td><strong>${log.username}</strong></td>
|
|
||||||
<td><code>${log.endpoint}</code></td>
|
|
||||||
<td>${log.method}</td>
|
|
||||||
<td><span class="${statusClass}">${log.status}</span></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
document.getElementById('logsList').innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter Logs
|
|
||||||
function filterLogs() {
|
|
||||||
const userFilter = document.getElementById('filterUser').value.toLowerCase();
|
|
||||||
const endpointFilter = document.getElementById('filterEndpoint').value.toLowerCase();
|
|
||||||
|
|
||||||
const filtered = allLogs.filter(log =>
|
|
||||||
log.username.toLowerCase().includes(userFilter) &&
|
|
||||||
log.endpoint.toLowerCase().includes(endpointFilter)
|
|
||||||
);
|
|
||||||
|
|
||||||
renderLogs(filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download Logs as JSON
|
|
||||||
function downloadLogs() {
|
|
||||||
const data = JSON.stringify(allLogs, null, 2);
|
|
||||||
const blob = new Blob([data], { type: 'application/json' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `access-logs-${new Date().toISOString()}.json`;
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
getAuthUser();
|
|
||||||
refreshData();
|
|
||||||
|
|
||||||
// Auto-refresh every 30 seconds
|
|
||||||
setInterval(refreshData, 30000);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="RollCalc Help">
|
||||||
|
<title>RollCalc Help</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #2f3437; min-height: 100vh; }
|
||||||
|
.help-header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
|
||||||
|
.help-header-inner { max-width: 980px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||||
|
.help-header h1 { font-size: 24px; margin-bottom: 5px; }
|
||||||
|
.help-header p { font-size: 13px; opacity: 0.9; }
|
||||||
|
.help-nav { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.help-nav a { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
|
||||||
|
.help-nav a:hover { background: rgba(255,255,255,0.18); }
|
||||||
|
.help-container { max-width: 980px; margin: 30px auto; padding: 0 16px 60px; }
|
||||||
|
.help-intro { margin-bottom: 22px; line-height: 1.55; color: #4b555a; }
|
||||||
|
.help-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 18px; }
|
||||||
|
.help-card { background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||||
|
.help-card h2 { color: #1F5438; font-size: 18px; margin-bottom: 10px; }
|
||||||
|
.help-card p { color: #4f5b60; font-size: 14px; line-height: 1.55; margin-bottom: 18px; }
|
||||||
|
.help-card a { display: inline-block; background: #2BAC70; color: #fff; text-decoration: none; border-radius: 6px; padding: 10px 16px; font-size: 14px; font-weight: 700; }
|
||||||
|
.help-card a:hover { background: #1F5438; }
|
||||||
|
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; }
|
||||||
|
.footer-build-info { margin-top: 6px; color: #bbb; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.help-header { padding: 18px 16px; }
|
||||||
|
.help-header-inner { align-items: flex-start; flex-direction: column; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="help-header">
|
||||||
|
<div class="help-header-inner">
|
||||||
|
<div>
|
||||||
|
<h1>RollCalc Help</h1>
|
||||||
|
<p>Documentation for roll calculation and transport capacity analysis</p>
|
||||||
|
</div>
|
||||||
|
<nav class="help-nav" aria-label="Help navigation">
|
||||||
|
<a href="{{ url_for('index') }}">Calculator</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="help-container">
|
||||||
|
<p class="help-intro">
|
||||||
|
This area provides internal technical documentation for RollCalc. The documents are rendered from the maintained Markdown sources.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="help-grid" aria-label="Available help documents">
|
||||||
|
<article class="help-card">
|
||||||
|
<h2>User Manual</h2>
|
||||||
|
<p>
|
||||||
|
Workflow-oriented manual for roll calculation, handling evaluation, Load Optimizer usage, and interpretation of Capacity Summary and Capacity Analysis.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('user_manual') }}">Open User Manual</a>
|
||||||
|
</article>
|
||||||
|
<article class="help-card">
|
||||||
|
<h2>Recent Changes</h2>
|
||||||
|
<p>
|
||||||
|
Compact release notes for relevant user-facing changes in RollCalc.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('recent_changes') }}">Open Recent Changes</a>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
RollCalc Beta © Naue
|
||||||
|
<div class="footer-build-info">
|
||||||
|
Version {{ build_info.version }} |
|
||||||
|
Branch {{ build_info.branch }} |
|
||||||
|
Commit {{ build_info.commit }} |
|
||||||
|
Built {{ build_info.timestamp }}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+328
-26
@@ -9,8 +9,11 @@
|
|||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; }
|
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; }
|
||||||
.header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
|
.header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
|
||||||
|
.header-inner { max-width: 900px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||||
.header h1 { font-size: 24px; margin-bottom: 5px; }
|
.header h1 { font-size: 24px; margin-bottom: 5px; }
|
||||||
.header p { font-size: 13px; opacity: 0.9; }
|
.header p { font-size: 13px; opacity: 0.9; }
|
||||||
|
.header-help-link { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
|
||||||
|
.header-help-link:hover { background: rgba(255,255,255,0.18); }
|
||||||
.container { max-width: 900px; margin: 30px auto; padding: 0 16px 60px; }
|
.container { max-width: 900px; margin: 30px auto; padding: 0 16px 60px; }
|
||||||
.card { background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 24px; overflow: hidden; }
|
.card { background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 24px; overflow: hidden; }
|
||||||
.card-header { background: #f0f4fa; padding: 16px 22px; border-bottom: 1px solid #e0e8f0; }
|
.card-header { background: #f0f4fa; padding: 16px 22px; border-bottom: 1px solid #e0e8f0; }
|
||||||
@@ -48,6 +51,9 @@
|
|||||||
.result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; }
|
.result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; }
|
||||||
.result-value { font-size: 28px; font-weight: 700; color: #1F5438; line-height: 1.2; }
|
.result-value { font-size: 28px; font-weight: 700; color: #1F5438; line-height: 1.2; }
|
||||||
.result-range { font-size: 12px; color: #1F5438; margin-top: 4px; font-weight: 500; }
|
.result-range { font-size: 12px; color: #1F5438; margin-top: 4px; font-weight: 500; }
|
||||||
|
.result-range div { margin-top: 2px; }
|
||||||
|
.result-range .range-warning { color: #9a6700; font-weight: 700; }
|
||||||
|
.result-range .range-ok { color: #1F5438; font-weight: 700; }
|
||||||
.forklift-check { border-radius: 7px; padding: 14px 16px; margin-top: 14px; font-size: 13px; line-height: 1.6; display: none; }
|
.forklift-check { border-radius: 7px; padding: 14px 16px; margin-top: 14px; font-size: 13px; line-height: 1.6; display: none; }
|
||||||
.forklift-check.show { display: block; }
|
.forklift-check.show { display: block; }
|
||||||
.forklift-check.fc-ok { background: #eafaf1; border: 1.5px solid #28a745; }
|
.forklift-check.fc-ok { background: #eafaf1; border: 1.5px solid #28a745; }
|
||||||
@@ -60,6 +66,7 @@
|
|||||||
.fc-req-nok { color: #dc3545; font-weight: 600; }
|
.fc-req-nok { color: #dc3545; font-weight: 600; }
|
||||||
.formula-box { background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #666; margin-top: 18px; font-family: 'Courier New', monospace; }
|
.formula-box { background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #666; margin-top: 18px; font-family: 'Courier New', monospace; }
|
||||||
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; margin-top: 30px; }
|
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; margin-top: 30px; }
|
||||||
|
.footer-build-info { margin-top: 6px; color: #bbb; }
|
||||||
.disclaimer-overlay {
|
.disclaimer-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -139,6 +146,59 @@
|
|||||||
background: #b8c7bf;
|
background: #b8c7bf;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
.whats-new-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(20, 28, 34, 0.55);
|
||||||
|
}
|
||||||
|
.whats-new-overlay.is-hidden { display: none; }
|
||||||
|
.whats-new-dialog {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.whats-new-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #1F5438;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.whats-new-header h2 { font-size: 18px; margin: 0 0 4px; }
|
||||||
|
.whats-new-version { font-size: 12px; opacity: 0.88; }
|
||||||
|
.whats-new-content {
|
||||||
|
padding: 18px 20px 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.whats-new-content ul { margin: 8px 0 0 20px; }
|
||||||
|
.whats-new-content li { margin-bottom: 5px; }
|
||||||
|
.whats-new-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px 18px;
|
||||||
|
border-top: 1px solid #e0e8f0;
|
||||||
|
background: #f8fafa;
|
||||||
|
}
|
||||||
|
.whats-new-btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.whats-new-btn-primary { background: #2BAC70; color: #fff; }
|
||||||
|
.whats-new-btn-primary:hover { background: #1F5438; }
|
||||||
|
.whats-new-btn-secondary { background: #fff; color: #1F5438; border: 1px solid #a8c8ba; }
|
||||||
|
.whats-new-btn-secondary:hover { background: #eef4f1; }
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
LOAD OPTIMIZER STYLES
|
LOAD OPTIMIZER STYLES
|
||||||
@@ -285,6 +345,23 @@
|
|||||||
border-left: 4px solid #2BAC70;
|
border-left: 4px solid #2BAC70;
|
||||||
}
|
}
|
||||||
.lo-results-summary h4 { margin-top: 0; color: #333; font-size: 14px; margin-bottom: 12px; }
|
.lo-results-summary h4 { margin-top: 0; color: #333; font-size: 14px; margin-bottom: 12px; }
|
||||||
|
.lo-summary-section {
|
||||||
|
padding-top: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid #e6ece9;
|
||||||
|
}
|
||||||
|
.lo-summary-section:first-of-type {
|
||||||
|
padding-top: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.lo-summary-section-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #1F5438;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
.lo-summary-item {
|
.lo-summary-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -293,9 +370,23 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.lo-summary-item:last-child { margin-bottom: 0; }
|
.lo-summary-item:last-child { margin-bottom: 0; }
|
||||||
|
.lo-summary-item.lo-governing-summary {
|
||||||
|
background: #eef8f2;
|
||||||
|
border-left: 3px solid #2BAC70;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
.lo-value-highlight { color: #2BAC70; font-weight: bold; font-size: 16px; }
|
.lo-value-highlight { color: #2BAC70; font-weight: bold; font-size: 16px; }
|
||||||
.lo-value-number { color: #1F5438; font-weight: bold; }
|
.lo-value-number { color: #1F5438; font-weight: bold; }
|
||||||
.lo-util-group { display: flex; flex-direction: column; gap: 4px; text-align: right; }
|
.lo-util-group { display: flex; flex-direction: column; gap: 4px; text-align: right; }
|
||||||
|
.lo-reserve-note {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: right;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
.lo-results-table {
|
.lo-results-table {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -311,10 +402,15 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #333;
|
||||||
border-bottom: 2px solid #2BAC70;
|
border-bottom: 2px solid #2BAC70;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.lo-results-table td { padding: 8px; border-bottom: 1px solid #e0e0e0; }
|
.lo-results-table td { padding: 8px; border-bottom: 1px solid #e0e0e0; }
|
||||||
.lo-results-table tbody tr:hover { background: #f9f9f9; }
|
.lo-results-table tbody tr:hover { background: #f9f9f9; }
|
||||||
.lo-results-table tbody tr.lo-recommended { background: #fffacd; font-weight: bold; }
|
.lo-results-table tbody tr.lo-governing {
|
||||||
|
background: #eef8f2;
|
||||||
|
font-weight: 600;
|
||||||
|
border-left: 3px solid #2BAC70;
|
||||||
|
}
|
||||||
.lo-warning {
|
.lo-warning {
|
||||||
background: #fff8e1;
|
background: #fff8e1;
|
||||||
border: 1px solid #f0b429;
|
border: 1px solid #f0b429;
|
||||||
@@ -392,6 +488,11 @@
|
|||||||
}
|
}
|
||||||
.lo-sideview-section.show { display: block; }
|
.lo-sideview-section.show { display: block; }
|
||||||
.lo-sideview-section h4 { margin: 0 0 12px 0; font-size: 14px; color: #333; }
|
.lo-sideview-section h4 { margin: 0 0 12px 0; font-size: 14px; color: #333; }
|
||||||
|
.lo-sideview-context {
|
||||||
|
margin: -6px 0 12px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
.lo-sideview-wrap {
|
.lo-sideview-wrap {
|
||||||
background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden;
|
background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -403,6 +504,10 @@
|
|||||||
.margin-color { background: repeating-linear-gradient(45deg,#fff7e0,#fff7e0 3px,#ffe8a0 3px,#ffe8a0 6px); border-color:#f0b429; }
|
.margin-color { background: repeating-linear-gradient(45deg,#fff7e0,#fff7e0 3px,#ffe8a0 3px,#ffe8a0 6px); border-color:#f0b429; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.header { padding: 18px 16px; }
|
||||||
|
.header-inner { align-items: flex-start; }
|
||||||
|
.whats-new-actions { flex-direction: column; }
|
||||||
|
.whats-new-btn { width: 100%; }
|
||||||
.lo-accordion-header h3 { font-size: 14px; }
|
.lo-accordion-header h3 { font-size: 14px; }
|
||||||
.lo-preset-buttons { flex-direction: column; }
|
.lo-preset-buttons { flex-direction: column; }
|
||||||
.lo-preset-btn { width: 100%; }
|
.lo-preset-btn { width: 100%; }
|
||||||
@@ -446,10 +551,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="whats-new-overlay is-hidden" id="whatsNewOverlay" role="dialog" aria-modal="true" aria-labelledby="whatsNewTitle">
|
||||||
|
<div class="whats-new-dialog">
|
||||||
|
<div class="whats-new-header">
|
||||||
|
<h2 id="whatsNewTitle">What’s New</h2>
|
||||||
|
<div class="whats-new-version">Version: <span id="whatsNewVersion">–</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="whats-new-content">
|
||||||
|
<ul id="whatsNewList"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="whats-new-actions">
|
||||||
|
<button class="whats-new-btn whats-new-btn-secondary" id="whatsNewOpenChangesBtn" type="button">Open Recent Changes</button>
|
||||||
|
<button class="whats-new-btn whats-new-btn-primary" id="whatsNewContinueBtn" type="button">Continue</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
<div class="header-inner">
|
||||||
|
<div>
|
||||||
<h1>Naue Roll Diameter Calculator</h1>
|
<h1>Naue Roll Diameter Calculator</h1>
|
||||||
<p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p>
|
<p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p>
|
||||||
</div>
|
</div>
|
||||||
|
<a class="header-help-link" href="{{ url_for('user_manual') }}">User Manual</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -571,6 +697,27 @@
|
|||||||
<span class="unit-label">kg</span>
|
<span class="unit-label">kg</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Max. Roll Diameter</label>
|
||||||
|
<div class="input-with-unit">
|
||||||
|
<input type="number" id="d-target-di" placeholder="mm" min="0" step="1">
|
||||||
|
<span class="unit-label">mm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Max. Roll Weight</label>
|
||||||
|
<div class="input-with-unit">
|
||||||
|
<input type="number" id="d-max-weight" placeholder="kg" min="0" step="1">
|
||||||
|
<span class="unit-label">kg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Minimum Product Length</label>
|
||||||
|
<div class="input-with-unit">
|
||||||
|
<input type="number" id="d-min-le" placeholder="m" min="0" step="0.1">
|
||||||
|
<span class="unit-label">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -782,11 +929,17 @@
|
|||||||
<div class="lo-results" id="resultsContainer">
|
<div class="lo-results" id="resultsContainer">
|
||||||
|
|
||||||
<div class="lo-results-summary">
|
<div class="lo-results-summary">
|
||||||
<h4>Results:</h4>
|
<h4>Capacity Summary:</h4>
|
||||||
<div class="lo-summary-item">
|
<div class="lo-summary-section">
|
||||||
<span><strong>Limiting Factor:</strong></span>
|
<div class="lo-summary-section-title">Governing Constraint</div>
|
||||||
|
<div class="lo-summary-item lo-governing-summary">
|
||||||
|
<span><strong>Constraint:</strong></span>
|
||||||
<span id="limitingFactorValue" class="lo-value-highlight">–</span>
|
<span id="limitingFactorValue" class="lo-value-highlight">–</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lo-summary-section">
|
||||||
|
<div class="lo-summary-section-title">Transport Result</div>
|
||||||
<div class="lo-summary-item">
|
<div class="lo-summary-item">
|
||||||
<span><strong>Total Rolls:</strong></span>
|
<span><strong>Total Rolls:</strong></span>
|
||||||
<div style="text-align:right;">
|
<div style="text-align:right;">
|
||||||
@@ -797,23 +950,37 @@
|
|||||||
<div class="lo-summary-item">
|
<div class="lo-summary-item">
|
||||||
<span><strong>Utilization:</strong></span>
|
<span><strong>Utilization:</strong></span>
|
||||||
<div class="lo-util-group">
|
<div class="lo-util-group">
|
||||||
<div>Weight: <span id="weightUtilValue" class="lo-value-number">–</span> %</div>
|
<div>Weight Utilization: <span id="weightUtilValue" class="lo-value-number">–</span> %</div>
|
||||||
<div>Volume: <span id="volumeUtilValue" class="lo-value-number">–</span> %</div>
|
<div>Volume Utilization: <span id="volumeUtilValue" class="lo-value-number">–</span> %</div>
|
||||||
<div>Product: <span id="productM2Value" class="lo-value-number">–</span> m²</div>
|
<div>Transported Product Area: <span id="productM2Value" class="lo-value-number">–</span> m²</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lo-summary-section">
|
||||||
|
<div class="lo-summary-section-title">Remaining Capacity</div>
|
||||||
|
<div class="lo-summary-item">
|
||||||
|
<span><strong>Remaining Capacity:</strong></span>
|
||||||
|
<div class="lo-util-group">
|
||||||
|
<div>Remaining Payload: <span id="remainingPayloadValue" class="lo-value-number">–</span> kg</div>
|
||||||
|
<div>Unused Payload: <span id="unusedPayloadValue" class="lo-value-number">–</span> %</div>
|
||||||
|
<div>Unused Volume: <span id="unusedVolumeValue" class="lo-value-number">–</span> %</div>
|
||||||
|
<div id="capacityInterpretationValue" class="lo-reserve-note"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lo-results-table">
|
<div class="lo-results-table">
|
||||||
<h4>Comparison Table:</h4>
|
<h4>Capacity Analysis:</h4>
|
||||||
<table id="comparisonTable">
|
<table id="comparisonTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Scenario</th>
|
<th>Constraint</th>
|
||||||
<th>Rolls</th>
|
<th>Max Rolls</th>
|
||||||
<th>kg</th>
|
<th>Total Weight</th>
|
||||||
<th>m²</th>
|
<th>Transported Area [m²]</th>
|
||||||
<th>Util. %</th>
|
<th>Payload Util. %</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
@@ -822,6 +989,7 @@
|
|||||||
|
|
||||||
<div class="lo-sideview-section" id="sideViewSection">
|
<div class="lo-sideview-section" id="sideViewSection">
|
||||||
<h4>🔍 Front View – Cross-Section</h4>
|
<h4>🔍 Front View – Cross-Section</h4>
|
||||||
|
<p class="lo-sideview-context">Cross-section of the actual loading configuration.</p>
|
||||||
<div class="lo-sideview-wrap">
|
<div class="lo-sideview-wrap">
|
||||||
<svg id="sideViewSVG" width="100%" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg">
|
<svg id="sideViewSVG" width="100%" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg">
|
||||||
<text x="400" y="130" text-anchor="middle" fill="#aaa" font-size="14">Run a calculation to see the side view.</text>
|
<text x="400" y="130" text-anchor="middle" fill="#aaa" font-size="14">Run a calculation to see the side view.</text>
|
||||||
@@ -844,6 +1012,9 @@
|
|||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
Naue GmbH & Co. KG · Roll Diameter Calculator · Internal Application
|
Naue GmbH & Co. KG · Roll Diameter Calculator · Internal Application
|
||||||
|
<div class="footer-build-info">
|
||||||
|
Version {{ build_info.version }} · Branch {{ build_info.branch }} · Commit {{ build_info.commit }} · Built {{ build_info.timestamp }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -989,21 +1160,69 @@ document.getElementById('d-calc').addEventListener('click', () => {
|
|||||||
resRange.textContent = '';
|
resRange.textContent = '';
|
||||||
document.getElementById('d-le').value = L_calc.toFixed(2);
|
document.getElementById('d-le').value = L_calc.toFixed(2);
|
||||||
} else if (mode === 'target') {
|
} else if (mode === 'target') {
|
||||||
if (!d || !t) { alert('Please enter d and t.'); return; }
|
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
||||||
const D_target = D || 800;
|
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
||||||
const L_calc = (Math.PI / (4 * t)) * (D_target*D_target - d*d) / 1000;
|
const maxWeight = parseFloat(document.getElementById('d-max-weight').value) || 0;
|
||||||
resLabel.textContent = 'Length for Target Diameter ' + D_target + ' mm';
|
const minLength = parseFloat(document.getElementById('d-min-le').value) || 0;
|
||||||
resVal.textContent = L_calc.toFixed(2) + ' m';
|
const D_target = parseFloat(document.getElementById('d-target-di').value) || 0;
|
||||||
resRange.textContent = '';
|
|
||||||
document.getElementById('d-le').value = L_calc.toFixed(2);
|
if (!d || !t || !D_target || !wi || !aw || !maxWeight) {
|
||||||
|
alert('Please enter d, t, max. roll diameter, roll width, area weight, and max. roll weight.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (D_target <= d) {
|
||||||
|
alert('Max. roll diameter must be larger than core diameter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const L_diameter = (Math.PI / (4 * t)) * (D_target*D_target - d*d) / 1000;
|
||||||
|
const L_weight = maxWeight * 1000 / (aw * wi);
|
||||||
|
const L_allowed = Math.min(L_diameter, L_weight);
|
||||||
|
const D_result = Math.sqrt(d*d + (4*L_allowed*1000*t)/Math.PI);
|
||||||
|
const W_result = aw * L_allowed * wi / 1000;
|
||||||
|
const diameterUtil = D_result / D_target * 100;
|
||||||
|
const weightUtil = W_result / maxWeight * 100;
|
||||||
|
const lengthDiff = minLength > 0 ? L_allowed - minLength : null;
|
||||||
|
const epsilon = 0.0001;
|
||||||
|
let limitingFactor = 'Diameter + Weight';
|
||||||
|
|
||||||
|
if (L_diameter < L_weight - epsilon) {
|
||||||
|
limitingFactor = 'Diameter';
|
||||||
|
} else if (L_weight < L_diameter - epsilon) {
|
||||||
|
limitingFactor = 'Weight';
|
||||||
|
}
|
||||||
|
|
||||||
|
resLabel.textContent = 'Maximum Allowed Product Length';
|
||||||
|
resVal.textContent = L_allowed.toFixed(2) + ' m';
|
||||||
|
|
||||||
|
const minLengthText = minLength > 0 ? minLength.toFixed(2) + ' m' : 'not set';
|
||||||
|
const diffText = lengthDiff !== null ? lengthDiff.toFixed(2) + ' m' : 'n/a';
|
||||||
|
let minStatus = '<div>Minimum length not set</div>';
|
||||||
|
if (minLength > 0 && L_allowed < minLength) {
|
||||||
|
minStatus = '<div class="range-warning">Warning: maximum allowed length is below the minimum product length.</div>';
|
||||||
|
} else if (minLength > 0) {
|
||||||
|
minStatus = '<div class="range-ok">Minimum length OK</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
resRange.innerHTML =
|
||||||
|
'<div>Resulting Roll Diameter: ' + D_result.toFixed(1) + ' mm</div>' +
|
||||||
|
'<div>Resulting weight: ' + W_result.toFixed(1) + ' kg</div>' +
|
||||||
|
'<div>Minimum length: ' + minLengthText + '</div>' +
|
||||||
|
'<div>Difference: ' + diffText + '</div>' +
|
||||||
|
'<div>Limiting factor: ' + limitingFactor + '</div>' +
|
||||||
|
'<div>Utilization: Diameter ' + diameterUtil.toFixed(1) + '% | Weight ' + weightUtil.toFixed(1) + '%</div>' +
|
||||||
|
minStatus;
|
||||||
|
|
||||||
|
document.getElementById('d-le').value = L_allowed.toFixed(2);
|
||||||
|
document.getElementById('d-wo').value = W_result.toFixed(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
||||||
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
||||||
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
|
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
|
||||||
if (wi > 0 && aw > 0 && L_final > 0) {
|
if (wi > 0 && aw > 0 && L_final > 0) {
|
||||||
const weight = (aw * L_final * wi / 1000).toFixed(1);
|
const weight = aw * L_final * wi / 1000;
|
||||||
document.getElementById('d-wo').value = weight;
|
document.getElementById('d-wo').value = weight.toFixed(1);
|
||||||
checkForklift(weight, d, category);
|
checkForklift(weight, d, category);
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('forkliftCheck').classList.remove('show');
|
document.getElementById('forkliftCheck').classList.remove('show');
|
||||||
@@ -1013,7 +1232,7 @@ document.getElementById('d-calc').addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('d-reset').addEventListener('click', () => {
|
document.getElementById('d-reset').addEventListener('click', () => {
|
||||||
['d-co', 'd-th', 'd-tol', 'd-le', 'd-di', 'd-wi', 'd-aw', 'd-wo'].forEach(id => {
|
['d-co', 'd-th', 'd-tol', 'd-le', 'd-di', 'd-wi', 'd-aw', 'd-wo', 'd-target-di', 'd-max-weight', 'd-min-le'].forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.value = id === 'd-co' ? '150' : '';
|
if (el) el.value = id === 'd-co' ? '150' : '';
|
||||||
});
|
});
|
||||||
@@ -1274,6 +1493,14 @@ class LoadOptimizerUI {
|
|||||||
|
|
||||||
showResults(res, maxW) {
|
showResults(res, maxW) {
|
||||||
const m2PerRoll = this.optimizer.state.m2PerRoll || 0;
|
const m2PerRoll = this.optimizer.state.m2PerRoll || 0;
|
||||||
|
const remainingPayload = Math.max(0, maxW - res.finalWeight);
|
||||||
|
const unusedPayload = Math.max(0, 100 - res.utilizationWeight);
|
||||||
|
const unusedVolume = Math.max(0, 100 - res.utilizationVolume);
|
||||||
|
const interpretations = {
|
||||||
|
geometry: 'Geometry prevents loading additional rolls although payload capacity remains available.',
|
||||||
|
weight: 'Payload limit is reached before the available geometry or volume is fully used.',
|
||||||
|
volume: 'Volume capacity is reached before payload or geometric capacity is fully used.'
|
||||||
|
};
|
||||||
document.getElementById('limitingFactorValue').textContent = res.limiting.name;
|
document.getElementById('limitingFactorValue').textContent = res.limiting.name;
|
||||||
document.getElementById('totalRollsValue').textContent = res.finalRolls;
|
document.getElementById('totalRollsValue').textContent = res.finalRolls;
|
||||||
document.getElementById('rollsDetailValue').textContent =
|
document.getElementById('rollsDetailValue').textContent =
|
||||||
@@ -1281,16 +1508,21 @@ class LoadOptimizerUI {
|
|||||||
document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight);
|
document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight);
|
||||||
document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume);
|
document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume);
|
||||||
document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0);
|
document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0);
|
||||||
|
document.getElementById('remainingPayloadValue').textContent = this.optimizer.fmt(remainingPayload, 0);
|
||||||
|
document.getElementById('unusedPayloadValue').textContent = this.optimizer.fmt(unusedPayload);
|
||||||
|
document.getElementById('unusedVolumeValue').textContent = this.optimizer.fmt(unusedVolume);
|
||||||
|
document.getElementById('capacityInterpretationValue').textContent =
|
||||||
|
interpretations[res.limiting.type] || '';
|
||||||
const tbody = document.querySelector('#comparisonTable tbody');
|
const tbody = document.querySelector('#comparisonTable tbody');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
res.scenarios.forEach(sc => {
|
res.scenarios.forEach(sc => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
const lim = (sc.type === res.limiting.type);
|
const lim = (sc.type === res.limiting.type);
|
||||||
if (lim) tr.classList.add('lo-recommended');
|
if (lim) tr.classList.add('lo-governing');
|
||||||
const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–';
|
const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–';
|
||||||
const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–';
|
const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–';
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
'<td>'+ sc.name +(lim?' ★':'')+'</td>'+
|
'<td>'+ sc.name +(lim?' → Governing Constraint':'')+'</td>'+
|
||||||
'<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+
|
'<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+
|
||||||
'<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+
|
'<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+
|
||||||
'<td>'+ scM2 +'</td>'+
|
'<td>'+ scM2 +'</td>'+
|
||||||
@@ -1412,12 +1644,81 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
window.loadOptimizerUI = new LoadOptimizerUI();
|
window.loadOptimizerUI = new LoadOptimizerUI();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ROLLCALC_BUILD_VERSION = {{ build_info.version|tojson }};
|
||||||
|
const ROLLCALC_WHATS_NEW_ITEMS = {{ whats_new_summary|tojson }};
|
||||||
|
const ROLLCALC_LAST_SEEN_VERSION_KEY = 'rollcalc_last_seen_version';
|
||||||
|
|
||||||
|
function shouldShowWhatsNew(currentVersion, storedVersion) {
|
||||||
|
return !!currentVersion &&
|
||||||
|
currentVersion !== 'unknown' &&
|
||||||
|
currentVersion !== storedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markWhatsNewSeen(currentVersion) {
|
||||||
|
if (!currentVersion || currentVersion === 'unknown') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ROLLCALC_LAST_SEEN_VERSION_KEY, currentVersion);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not store RollCalc release note state', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWhatsNewItems(items) {
|
||||||
|
const list = document.getElementById('whatsNewList');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
items.forEach(item => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = item;
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeShowWhatsNew() {
|
||||||
|
const overlay = document.getElementById('whatsNewOverlay');
|
||||||
|
const versionValue = document.getElementById('whatsNewVersion');
|
||||||
|
const continueBtn = document.getElementById('whatsNewContinueBtn');
|
||||||
|
const openChangesBtn = document.getElementById('whatsNewOpenChangesBtn');
|
||||||
|
const items = Array.isArray(ROLLCALC_WHATS_NEW_ITEMS)
|
||||||
|
? ROLLCALC_WHATS_NEW_ITEMS
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!overlay || !versionValue || !continueBtn || !openChangesBtn || items.length === 0) return;
|
||||||
|
|
||||||
|
let storedVersion = null;
|
||||||
|
try {
|
||||||
|
storedVersion = localStorage.getItem(ROLLCALC_LAST_SEEN_VERSION_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
storedVersion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldShowWhatsNew(ROLLCALC_BUILD_VERSION, storedVersion)) return;
|
||||||
|
|
||||||
|
versionValue.textContent = ROLLCALC_BUILD_VERSION;
|
||||||
|
renderWhatsNewItems(items);
|
||||||
|
overlay.classList.remove('is-hidden');
|
||||||
|
|
||||||
|
continueBtn.addEventListener('click', () => {
|
||||||
|
markWhatsNewSeen(ROLLCALC_BUILD_VERSION);
|
||||||
|
overlay.classList.add('is-hidden');
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
openChangesBtn.addEventListener('click', () => {
|
||||||
|
markWhatsNewSeen(ROLLCALC_BUILD_VERSION);
|
||||||
|
window.location.href = {{ url_for('recent_changes')|tojson }};
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const overlay = document.getElementById('disclaimerOverlay');
|
const overlay = document.getElementById('disclaimerOverlay');
|
||||||
const checkbox = document.getElementById('disclaimerAccepted');
|
const checkbox = document.getElementById('disclaimerAccepted');
|
||||||
const confirmBtn = document.getElementById('disclaimerConfirmBtn');
|
const confirmBtn = document.getElementById('disclaimerConfirmBtn');
|
||||||
|
|
||||||
if (!overlay || !checkbox || !confirmBtn) return;
|
if (!overlay || !checkbox || !confirmBtn) {
|
||||||
|
maybeShowWhatsNew();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
checkbox.addEventListener('change', () => {
|
checkbox.addEventListener('change', () => {
|
||||||
confirmBtn.disabled = !checkbox.checked;
|
confirmBtn.disabled = !checkbox.checked;
|
||||||
@@ -1426,6 +1727,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
confirmBtn.addEventListener('click', () => {
|
confirmBtn.addEventListener('click', () => {
|
||||||
if (!checkbox.checked) return;
|
if (!checkbox.checked) return;
|
||||||
overlay.classList.add('is-hidden');
|
overlay.classList.add('is-hidden');
|
||||||
|
maybeShowWhatsNew();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="RollCalc {{ document_title }}">
|
||||||
|
<title>RollCalc {{ document_title }}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #2f3437; min-height: 100vh; }
|
||||||
|
.help-header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
|
||||||
|
.help-header-inner { max-width: 1180px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||||
|
.help-header h1 { font-size: 24px; margin-bottom: 5px; }
|
||||||
|
.help-header p { font-size: 13px; opacity: 0.9; }
|
||||||
|
.help-nav { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.help-nav a { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
|
||||||
|
.help-nav a:hover { background: rgba(255,255,255,0.18); }
|
||||||
|
.manual-shell { max-width: 1180px; margin: 28px auto; padding: 0 16px 60px; }
|
||||||
|
.manual-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||||
|
.manual-toc { position: sticky; top: 18px; background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); max-height: calc(100vh - 36px); overflow: auto; }
|
||||||
|
.manual-toc h2 { color: #1F5438; font-size: 14px; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0; }
|
||||||
|
.manual-toc ul { list-style: none; }
|
||||||
|
.manual-toc li { margin: 0 0 8px; }
|
||||||
|
.manual-toc a { color: #34443b; text-decoration: none; font-size: 13px; line-height: 1.35; }
|
||||||
|
.manual-toc a:hover { color: #1F5438; text-decoration: underline; }
|
||||||
|
.manual-toc .toc-level-2 { padding-left: 14px; }
|
||||||
|
.manual-content { background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 34px 42px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); line-height: 1.62; font-size: 15px; }
|
||||||
|
.manual-content h1, .manual-content h2, .manual-content h3, .manual-content h4 { color: #1F5438; line-height: 1.25; scroll-margin-top: 18px; }
|
||||||
|
.manual-content h1 { font-size: 30px; margin: 0 0 24px; padding-bottom: 14px; border-bottom: 2px solid #dfe7e2; }
|
||||||
|
.manual-content h2 { font-size: 22px; margin: 34px 0 14px; padding-top: 4px; }
|
||||||
|
.manual-content h3 { font-size: 17px; margin: 26px 0 10px; color: #244b39; }
|
||||||
|
.manual-content h4 { font-size: 15px; margin: 20px 0 8px; color: #244b39; }
|
||||||
|
.manual-content p { margin: 0 0 14px; }
|
||||||
|
.manual-content ul, .manual-content ol { margin: 0 0 16px 24px; }
|
||||||
|
.manual-content li { margin: 5px 0; }
|
||||||
|
.manual-content a { color: #1F5438; font-weight: 600; }
|
||||||
|
.manual-content code { background: #eef4f1; border: 1px solid #d7e4de; border-radius: 4px; padding: 1px 4px; font-family: Consolas, 'Courier New', monospace; font-size: 0.92em; }
|
||||||
|
.manual-content pre { background: #f5f7f6; border: 1px solid #dfe7e2; border-radius: 6px; padding: 14px; margin: 16px 0; overflow-x: auto; }
|
||||||
|
.manual-content pre code { background: transparent; border: 0; padding: 0; }
|
||||||
|
.manual-content blockquote { margin: 18px 0; padding: 14px 18px; border-left: 4px solid #2BAC70; background: #f1f8f5; color: #34443b; }
|
||||||
|
.manual-content blockquote p:last-child, .manual-content blockquote ul:last-child { margin-bottom: 0; }
|
||||||
|
.manual-content table { width: 100%; border-collapse: collapse; margin: 18px 0; display: block; overflow-x: auto; }
|
||||||
|
.manual-content th, .manual-content td { border: 1px solid #d7e4de; padding: 9px 11px; text-align: left; vertical-align: top; }
|
||||||
|
.manual-content th { background: #eef4f1; color: #1F5438; }
|
||||||
|
.manual-content img { max-width: 100%; height: auto; border: 1px solid #dfe7e2; border-radius: 6px; background: #f8fafa; display: block; margin: 18px 0 8px; }
|
||||||
|
.manual-error { background: #fff; border: 1px solid #e6c7c7; border-left: 4px solid #b84a4a; border-radius: 8px; padding: 20px; color: #5a2d2d; box-shadow: 0 2px 12px rgba(0,0,0,0.05); }
|
||||||
|
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; }
|
||||||
|
.footer-build-info { margin-top: 6px; color: #bbb; }
|
||||||
|
@media (max-width: 840px) {
|
||||||
|
.help-header { padding: 18px 16px; }
|
||||||
|
.help-header-inner { align-items: flex-start; flex-direction: column; }
|
||||||
|
.manual-layout { grid-template-columns: 1fr; }
|
||||||
|
.manual-toc { position: static; max-height: none; }
|
||||||
|
.manual-content { padding: 24px 20px; }
|
||||||
|
.manual-content h1 { font-size: 25px; }
|
||||||
|
.manual-content h2 { font-size: 20px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="help-header">
|
||||||
|
<div class="help-header-inner">
|
||||||
|
<div>
|
||||||
|
<h1>{{ document_title }}</h1>
|
||||||
|
<p>{{ document_subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<nav class="help-nav" aria-label="Help navigation">
|
||||||
|
<a href="{{ url_for('index') }}">Calculator</a>
|
||||||
|
<a href="{{ url_for('help_index') }}">Help</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="manual-shell">
|
||||||
|
{% if render_error %}
|
||||||
|
<section class="manual-error" role="alert">
|
||||||
|
<strong>{{ error_title }}</strong>
|
||||||
|
<p>{{ render_error }}</p>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<div class="manual-layout">
|
||||||
|
<nav class="manual-toc" aria-label="Table of contents">
|
||||||
|
<h2>Contents</h2>
|
||||||
|
<ul>
|
||||||
|
{% for entry in toc %}
|
||||||
|
<li class="toc-level-{{ entry.level }}">
|
||||||
|
<a href="#{{ entry.id }}">{{ entry.title }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<article class="manual-content">
|
||||||
|
{{ manual_html }}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
RollCalc Beta © Naue
|
||||||
|
<div class="footer-build-info">
|
||||||
|
Version {{ build_info.version }} |
|
||||||
|
Branch {{ build_info.branch }} |
|
||||||
|
Commit {{ build_info.commit }} |
|
||||||
|
Built {{ build_info.timestamp }}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user