Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8e12bcdb5 | |||
| 0fff01730e |
@@ -1,14 +0,0 @@
|
|||||||
node_modules
|
|
||||||
**/node_modules
|
|
||||||
npm-debug.log
|
|
||||||
.DS_Store
|
|
||||||
.git
|
|
||||||
.env
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
frontend/build
|
|
||||||
frontend/dist
|
|
||||||
backend/.env
|
|
||||||
backend/node_modules
|
|
||||||
frontend/node_modules
|
|
||||||
backend/data/*.db*
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
node_modules
|
|
||||||
**/node_modules
|
|
||||||
npm-debug.log
|
|
||||||
.DS_Store
|
|
||||||
.git
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
frontend/build
|
|
||||||
frontend/dist
|
|
||||||
backend/node_modules
|
|
||||||
backend/.env
|
|
||||||
frontend/node_modules
|
|
||||||
docker-compose.dev.yml
|
|
||||||
devscripts
|
|
||||||
backend/data/stackpulse.db
|
|
||||||
backend/data/stackpulse.db-shm
|
|
||||||
backend/data/stackpulse.db-wal
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
pnpm-lock.yaml
|
|
||||||
package-lock.json
|
|
||||||
yarn.lock
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
!.vscode/extensions.json
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
/site/
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="no-js">
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<link rel="icon" href="/assets/images/favicon.png">
|
||||||
|
<meta name="generator" content="mkdocs-1.6.1, mkdocs-material-9.7.0">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<title>Stackpulse Dokumentation</title>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/assets/stylesheets/main.618322db.min.css">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i%7CRoboto+Mono:400,400i,700,700i&display=fallback">
|
||||||
|
<style>:root{--md-text-font:"Roboto";--md-code-font:"Roboto Mono"}</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script>__md_scope=new URL("/",location),__md_hash=e=>[...e].reduce(((e,_)=>(e<<5)-e+_.charCodeAt(0)),0),__md_get=(e,_=localStorage,t=__md_scope)=>JSON.parse(_.getItem(t.pathname+"."+e)),__md_set=(e,_,t=localStorage,a=__md_scope)=>{try{t.setItem(a.pathname+"."+e,JSON.stringify(_))}catch(e){}}</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
|
||||||
|
<body dir="ltr">
|
||||||
|
|
||||||
|
|
||||||
|
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
|
||||||
|
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
|
||||||
|
<label class="md-overlay" for="__drawer"></label>
|
||||||
|
<div data-md-component="skip">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div data-md-component="announce">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<header class="md-header md-header--shadow" data-md-component="header">
|
||||||
|
<nav class="md-header__inner md-grid" aria-label="Header">
|
||||||
|
<a href="/." title="Stackpulse Dokumentation" class="md-header__button md-logo" aria-label="Stackpulse Dokumentation" data-md-component="logo">
|
||||||
|
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54"/></svg>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
<label class="md-header__button md-icon" for="__drawer">
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>
|
||||||
|
</label>
|
||||||
|
<div class="md-header__title" data-md-component="header-title">
|
||||||
|
<div class="md-header__ellipsis">
|
||||||
|
<div class="md-header__topic">
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
Stackpulse Dokumentation
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="md-header__topic" data-md-component="header-topic">
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>var palette=__md_get("__palette");if(palette&&palette.color){if("(prefers-color-scheme)"===palette.color.media){var media=matchMedia("(prefers-color-scheme: light)"),input=document.querySelector(media.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");palette.color.media=input.getAttribute("data-md-color-media"),palette.color.scheme=input.getAttribute("data-md-color-scheme"),palette.color.primary=input.getAttribute("data-md-color-primary"),palette.color.accent=input.getAttribute("data-md-color-accent")}for(var[key,value]of Object.entries(palette.color))document.body.setAttribute("data-md-color-"+key,value)}</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<label class="md-header__button md-icon" for="__search">
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
|
||||||
|
</label>
|
||||||
|
<div class="md-search" data-md-component="search" role="dialog">
|
||||||
|
<label class="md-search__overlay" for="__search"></label>
|
||||||
|
<div class="md-search__inner" role="search">
|
||||||
|
<form class="md-search__form" name="search">
|
||||||
|
<input type="text" class="md-search__input" name="query" aria-label="Search" placeholder="Search" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" required>
|
||||||
|
<label class="md-search__icon md-icon" for="__search">
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg>
|
||||||
|
</label>
|
||||||
|
<nav class="md-search__options" aria-label="Search">
|
||||||
|
|
||||||
|
<button type="reset" class="md-search__icon md-icon" title="Clear" aria-label="Clear" tabindex="-1">
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
<div class="md-search__output">
|
||||||
|
<div class="md-search__scrollwrap" tabindex="0" data-md-scrollfix>
|
||||||
|
<div class="md-search-result" data-md-component="search-result">
|
||||||
|
<div class="md-search-result__meta">
|
||||||
|
Initializing search
|
||||||
|
</div>
|
||||||
|
<ol class="md-search-result__list" role="presentation"></ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="md-container" data-md-component="container">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<main class="md-main" data-md-component="main">
|
||||||
|
<div class="md-main__inner md-grid">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" >
|
||||||
|
<div class="md-sidebar__scrollwrap">
|
||||||
|
<div class="md-sidebar__inner">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<nav class="md-nav md-nav--primary" aria-label="Navigation" data-md-level="0">
|
||||||
|
<label class="md-nav__title" for="__drawer">
|
||||||
|
<a href="/." title="Stackpulse Dokumentation" class="md-nav__button md-logo" aria-label="Stackpulse Dokumentation" data-md-component="logo">
|
||||||
|
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54"/></svg>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
Stackpulse Dokumentation
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ul class="md-nav__list" data-md-scrollfix>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/." class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Start
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item md-nav__item--nested">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_2" >
|
||||||
|
|
||||||
|
|
||||||
|
<label class="md-nav__link" for="__nav_2" id="__nav_2_label" tabindex="0">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Benutzerhandbuch
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-nav__icon md-icon"></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_2_label" aria-expanded="false">
|
||||||
|
<label class="md-nav__title" for="__nav_2">
|
||||||
|
<span class="md-nav__icon md-icon"></span>
|
||||||
|
|
||||||
|
|
||||||
|
Benutzerhandbuch
|
||||||
|
|
||||||
|
|
||||||
|
</label>
|
||||||
|
<ul class="md-nav__list" data-md-scrollfix>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/user-guide/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Überblick
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/user-guide/setup/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Setup
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/user-guide/stacks/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Stacks
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/user-guide/logs/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Logs
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/user-guide/maintenance/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Wartung
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/user-guide/users/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Benutzer
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/user-guide/groups/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Benutzergruppen & Rechte
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item md-nav__item--nested">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_3" >
|
||||||
|
|
||||||
|
|
||||||
|
<label class="md-nav__link" for="__nav_3" id="__nav_3_label" tabindex="0">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Technik
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-nav__icon md-icon"></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_3_label" aria-expanded="false">
|
||||||
|
<label class="md-nav__title" for="__nav_3">
|
||||||
|
<span class="md-nav__icon md-icon"></span>
|
||||||
|
|
||||||
|
|
||||||
|
Technik
|
||||||
|
|
||||||
|
|
||||||
|
</label>
|
||||||
|
<ul class="md-nav__list" data-md-scrollfix>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/getting-started/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Getting Started
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/architecture/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Architektur
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/backend/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Backend & API
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/frontend/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Frontend
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<li class="md-nav__item">
|
||||||
|
<a href="/operations/" class="md-nav__link">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<span class="md-ellipsis">
|
||||||
|
|
||||||
|
|
||||||
|
Operations & Wartung
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="md-sidebar md-sidebar--secondary" data-md-component="sidebar" data-md-type="toc" >
|
||||||
|
<div class="md-sidebar__scrollwrap">
|
||||||
|
<div class="md-sidebar__inner">
|
||||||
|
|
||||||
|
|
||||||
|
<nav class="md-nav md-nav--secondary" aria-label="Table of contents">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="md-content" data-md-component="content">
|
||||||
|
|
||||||
|
<article class="md-content__inner md-typeset">
|
||||||
|
|
||||||
|
<h1>404 - Not found</h1>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="md-footer">
|
||||||
|
|
||||||
|
<div class="md-footer-meta md-typeset">
|
||||||
|
<div class="md-footer-meta__inner md-grid">
|
||||||
|
<div class="md-copyright">
|
||||||
|
|
||||||
|
|
||||||
|
Made with
|
||||||
|
<a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
|
||||||
|
Material for MkDocs
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="md-dialog" data-md-component="dialog">
|
||||||
|
<div class="md-dialog__inner md-typeset"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script id="__config" type="application/json">{"annotate": null, "base": "/", "features": [], "search": "/assets/javascripts/workers/search.7a47a382.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="/assets/javascripts/bundle.e71a0d61.min.js"></script>
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
# ===============================
|
|
||||||
# Stage 1: Frontend Build
|
|
||||||
# ===============================
|
|
||||||
FROM node:20-alpine AS frontend-build
|
|
||||||
|
|
||||||
# Arbeitsverzeichnis
|
|
||||||
WORKDIR /app/frontend
|
|
||||||
|
|
||||||
# Nur package.json & package-lock.json kopieren und Dependencies installieren
|
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Restliche Frontend-Dateien kopieren
|
|
||||||
COPY frontend/ ./
|
|
||||||
|
|
||||||
# Frontend Build erzeugen (statische Dateien)
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
|
|
||||||
# ===============================
|
|
||||||
# Stage 2: Backend + Frontend
|
|
||||||
# ===============================
|
|
||||||
FROM node:20-alpine AS runtime
|
|
||||||
|
|
||||||
#Install SSH-Client
|
|
||||||
RUN apk add --no-cache openssh-client
|
|
||||||
|
|
||||||
# Arbeitsverzeichnis
|
|
||||||
WORKDIR /app/backend
|
|
||||||
|
|
||||||
# Backend Dependencies installieren
|
|
||||||
COPY backend/package.json backend/package-lock.json ./
|
|
||||||
RUN npm ci --only=production
|
|
||||||
|
|
||||||
# Backend-Code kopieren
|
|
||||||
COPY backend/ ./
|
|
||||||
COPY backend/docker-entrypoint.sh ./docker-entrypoint.sh
|
|
||||||
|
|
||||||
# public leeren
|
|
||||||
RUN rm -rf ./public/*
|
|
||||||
|
|
||||||
# frische Datenbank vorbereiten
|
|
||||||
RUN rm -rf ./data && mkdir -p ./data && chown node:node ./data
|
|
||||||
|
|
||||||
# EntryPoint vorbereiten
|
|
||||||
RUN chmod +x ./docker-entrypoint.sh && chown node:node ./docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Inhalt von dist inklusive Unterordner direkt nach public kopieren
|
|
||||||
COPY --from=frontend-build /app/frontend/dist/. ./public/
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
# Ports
|
|
||||||
# Backend intern: 4001
|
|
||||||
# Frontend exposed: 5173
|
|
||||||
EXPOSE 5173
|
|
||||||
|
|
||||||
# Node User
|
|
||||||
USER node
|
|
||||||
|
|
||||||
# Container startet das Backend (liefert statisches Frontend)
|
|
||||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
|
||||||
CMD ["node", "index.js"]
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||

|
|
||||||
|
|
||||||
# 📦 StackPulse 
|
|
||||||
|
|
||||||
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
|
|
||||||
StackPulse funktioniert nur mit der Business-Edition von Portainer. Die Communitiy-Edition wird nicht unterstützt!
|
|
||||||
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
|
|
||||||
|
|
||||||
Ziel:
|
|
||||||
- Übersicht über alle Stacks in deiner Portainer-Instanz
|
|
||||||
- Später: Updates, Deployments und Monitoring
|
|
||||||
- Bereitstellung als **Docker Image**, nutzbar über **docker-compose**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Features & Roadmap
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>✅ v0.1 – Initial Release</summary>
|
|
||||||
|
|
||||||
- Projektstruktur mit Frontend & Backend
|
|
||||||
- Lokales Startskript (`scripts/start-dev.sh`)
|
|
||||||
- Frontend zeigt Stacks an (über Backend)
|
|
||||||
- API-Verbindung zu Portainer
|
|
||||||
- Stack Redeploy
|
|
||||||
- Bereitstellung eines Docker Images über GHCR
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>✅ v0.2 – Release</summary>
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- [x] Anbindung einer SQLite-Datenbank
|
|
||||||
- [x] Logging der Redeploy-Aktionen in SQLite speichern
|
|
||||||
- [x] API-Endpunkte für Log-Abfragen
|
|
||||||
- [x] Funktionen für Pagination, Löschen und Export
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- [x] Anzeige der Logs (inkl. Statusfarben)
|
|
||||||
- [x] UI-Komponenten für Log-Details
|
|
||||||
- [x] Filterfunktionen für die Logs
|
|
||||||
- [x] Pagination, Lösch- und Exportanzeigen
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- [x] Selektive Auswahl: einzelne Stacks oder Services neu deployen
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>✅ v0.3 – Release</summary>
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- [x] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
|
|
||||||
- [x] Erweiterung der Logs
|
|
||||||
- [x] API für Filter & Suche (Stacks nach Name/Status abrufen)
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- [x] Filter: Stacks nach Name oder Status durchsuchen
|
|
||||||
- [x] Benachrichtigungen im UI: erfolgreicher/fehlgeschlagener Redeploy (Toast)
|
|
||||||
- [x] Visualisierung der Datenbereinigung (Konflikt/Auto-Fix Meldungen)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- [x] Frontend-Filter für schnellere Navigation bei vielen Stacks
|
|
||||||
- [x] Echtzeit-Feedback im UI (Notifications)
|
|
||||||
- [x] Datenkonsistenz sichern: keine doppelten Stack-IDs mehr
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>✅ v0.4 – Release</summary>
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- keine Änderungen
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- [x] GUI komplett überarbeitet
|
|
||||||
- [x] Umstellung auf Material Tailwind
|
|
||||||
- [x] Dashboard von Creative Tim als neues Frontend
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- [x] Neue, übersichtliche Oberfläche im Material Design
|
|
||||||
- [x] leicht erweiterbar durch vorgefertigte Komponenten
|
|
||||||
- [x] kleine Bugfixes
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details open>
|
|
||||||
<summary>✅ v0.5 – Release</summary>
|
|
||||||
|
|
||||||
### 🛠️ Backend
|
|
||||||
- [x] Neue API-Endpunkte für Logging, Benutzer- und Gruppenverwaltung sowie Superuser-Registrierung
|
|
||||||
- [x] Authentifizierung und Log-In
|
|
||||||
- [x] Erweiterte Protokollierung und Datenbank-Migration
|
|
||||||
- [x] Präzisere Steuerung der globalen Benutzerrechte
|
|
||||||
|
|
||||||
### 💻 Frontend
|
|
||||||
- [x] Neue Log-In-Seite
|
|
||||||
- [x] Benutzer- und Gruppenverwaltung mit granularen Rechteeinstellungen
|
|
||||||
- [x] Erweiterte Log-Ausgabe mit Filtern und Suchoptionen
|
|
||||||
- [x] Konsolidierte und vereinheitlichte Ansichten im Dashboard
|
|
||||||
|
|
||||||
### ✨ Features
|
|
||||||
- [x] Vollständiges Authentifizierungs- und Berechtigungssystem
|
|
||||||
- [x] Erweiterte Logs in allen Bereichen
|
|
||||||
- [x] Speicherung von Server- und Superuser-Einstellungen in der Datenbank
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details open>
|
|
||||||
|
|
||||||
<summary>🟡 v0.6 – In Entwicklung</summary>
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details open>
|
|
||||||
<summary>🔮 Geplante Features (v0.6+)</summary>
|
|
||||||
|
|
||||||
- Notifications (z. B. via Webhooks oder Mail)
|
|
||||||
- Monitoring (Status, CPU/RAM)
|
|
||||||
- Verbesserte UI/UX
|
|
||||||
- Erweiterte Filterungen und Sortierungen
|
|
||||||
- Multi-Server Verwaltung
|
|
||||||
</details>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗂️ Projektstruktur
|
|
||||||
|
|
||||||
```bash
|
|
||||||
stackpulse/
|
|
||||||
├── backend/ # Node.js Backend mit Express
|
|
||||||
│ └── data # SQlite Datenbank
|
|
||||||
│ └── db # Datenbank-Integration
|
|
||||||
├── frontend/ # React Frontend mit Tailwind
|
|
||||||
├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images)
|
|
||||||
│ └── start-dev.sh #Skript für den lokalen Start
|
|
||||||
├── Dockerfile # Multi-Stage Build für Frontend + Backend
|
|
||||||
├── docker-compose.yml
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Lokaler Start
|
|
||||||
|
|
||||||
### 1. Dev-Server starten (Frontend + Backend)
|
|
||||||
```bash
|
|
||||||
./scripts/start-dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
➡️ Danach:
|
|
||||||
- Frontend → http://Deine-Server-IP:5173
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐳 Docker-Setup
|
|
||||||
|
|
||||||
### Mit Compose starten
|
|
||||||
```bash
|
|
||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
ports:
|
|
||||||
- "4001:4001"
|
|
||||||
volumes:
|
|
||||||
- stackpulse_data:/app/backend/data
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- PORTAINER_URL=Your_Portainer_Server_Adress (optional)
|
|
||||||
- PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
|
|
||||||
- SUPERUSER_USERNAME=Your_Superuser_Username (optional)
|
|
||||||
- SUPERUSER_EMAIL=Your_Superuser_Email (optional)
|
|
||||||
- SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
|
|
||||||
- SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
|
|
||||||
volumes:
|
|
||||||
stackpulse_data:
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
## 📋 Voraussetzungen
|
|
||||||
|
|
||||||
- Node.js >= 20
|
|
||||||
- Docker & Docker Compose
|
|
||||||
- Zugang zu einer Portainer-Instanz (API-Key erforderlich)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Mitmachen
|
|
||||||
|
|
||||||
1. Repo forken
|
|
||||||
2. Feature-Branch erstellen
|
|
||||||
3. PR gegen `dev` öffnen
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
+16
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/*!
|
||||||
|
* Lunr languages, `Danish` language
|
||||||
|
* https://github.com/MihaiValentin/lunr-languages
|
||||||
|
*
|
||||||
|
* Copyright 2014, Mihai Valentin
|
||||||
|
* http://www.mozilla.org/MPL/
|
||||||
|
*/
|
||||||
|
/*!
|
||||||
|
* based on
|
||||||
|
* Snowball JavaScript Library v0.3
|
||||||
|
* http://code.google.com/p/urim/
|
||||||
|
* http://snowball.tartarus.org/
|
||||||
|
*
|
||||||
|
* Copyright 2010, Oleg Mazko
|
||||||
|
* http://www.mozilla.org/MPL/
|
||||||
|
*/
|
||||||
|
|
||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.da=function(){this.pipeline.reset(),this.pipeline.add(e.da.trimmer,e.da.stopWordFilter,e.da.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.da.stemmer))},e.da.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.da.trimmer=e.trimmerSupport.generateTrimmer(e.da.wordCharacters),e.Pipeline.registerFunction(e.da.trimmer,"trimmer-da"),e.da.stemmer=function(){var r=e.stemmerSupport.Among,i=e.stemmerSupport.SnowballProgram,n=new function(){function e(){var e,r=f.cursor+3;if(d=f.limit,0<=r&&r<=f.limit){for(a=r;;){if(e=f.cursor,f.in_grouping(w,97,248)){f.cursor=e;break}if(f.cursor=e,e>=f.limit)return;f.cursor++}for(;!f.out_grouping(w,97,248);){if(f.cursor>=f.limit)return;f.cursor++}d=f.cursor,d<a&&(d=a)}}function n(){var e,r;if(f.cursor>=d&&(r=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,e=f.find_among_b(c,32),f.limit_backward=r,e))switch(f.bra=f.cursor,e){case 1:f.slice_del();break;case 2:f.in_grouping_b(p,97,229)&&f.slice_del()}}function t(){var e,r=f.limit-f.cursor;f.cursor>=d&&(e=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,f.find_among_b(l,4)?(f.bra=f.cursor,f.limit_backward=e,f.cursor=f.limit-r,f.cursor>f.limit_backward&&(f.cursor--,f.bra=f.cursor,f.slice_del())):f.limit_backward=e)}function s(){var e,r,i,n=f.limit-f.cursor;if(f.ket=f.cursor,f.eq_s_b(2,"st")&&(f.bra=f.cursor,f.eq_s_b(2,"ig")&&f.slice_del()),f.cursor=f.limit-n,f.cursor>=d&&(r=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,e=f.find_among_b(m,5),f.limit_backward=r,e))switch(f.bra=f.cursor,e){case 1:f.slice_del(),i=f.limit-f.cursor,t(),f.cursor=f.limit-i;break;case 2:f.slice_from("løs")}}function o(){var e;f.cursor>=d&&(e=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,f.out_grouping_b(w,97,248)?(f.bra=f.cursor,u=f.slice_to(u),f.limit_backward=e,f.eq_v_b(u)&&f.slice_del()):f.limit_backward=e)}var a,d,u,c=[new r("hed",-1,1),new r("ethed",0,1),new r("ered",-1,1),new r("e",-1,1),new r("erede",3,1),new r("ende",3,1),new r("erende",5,1),new r("ene",3,1),new r("erne",3,1),new r("ere",3,1),new r("en",-1,1),new r("heden",10,1),new r("eren",10,1),new r("er",-1,1),new r("heder",13,1),new r("erer",13,1),new r("s",-1,2),new r("heds",16,1),new r("es",16,1),new r("endes",18,1),new r("erendes",19,1),new r("enes",18,1),new r("ernes",18,1),new r("eres",18,1),new r("ens",16,1),new r("hedens",24,1),new r("erens",24,1),new r("ers",16,1),new r("ets",16,1),new r("erets",28,1),new r("et",-1,1),new r("eret",30,1)],l=[new r("gd",-1,-1),new r("dt",-1,-1),new r("gt",-1,-1),new r("kt",-1,-1)],m=[new r("ig",-1,1),new r("lig",0,1),new r("elig",1,1),new r("els",-1,1),new r("løst",-1,2)],w=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,48,0,128],p=[239,254,42,3,0,0,0,0,0,0,0,0,0,0,0,0,16],f=new i;this.setCurrent=function(e){f.setCurrent(e)},this.getCurrent=function(){return f.getCurrent()},this.stem=function(){var r=f.cursor;return e(),f.limit_backward=r,f.cursor=f.limit,n(),f.cursor=f.limit,t(),f.cursor=f.limit,s(),f.cursor=f.limit,o(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return n.setCurrent(e),n.stem(),n.getCurrent()}):(n.setCurrent(e),n.stem(),n.getCurrent())}}(),e.Pipeline.registerFunction(e.da.stemmer,"stemmer-da"),e.da.stopWordFilter=e.generateStopWordFilter("ad af alle alt anden at blev blive bliver da de dem den denne der deres det dette dig din disse dog du efter eller en end er et for fra ham han hans har havde have hende hendes her hos hun hvad hvis hvor i ikke ind jeg jer jo kunne man mange med meget men mig min mine mit mod ned noget nogle nu når og også om op os over på selv sig sin sine sit skal skulle som sådan thi til ud under var vi vil ville vor være været".split(" ")),e.Pipeline.registerFunction(e.da.stopWordFilter,"stopWordFilter-da")}});
|
||||||
+18
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.hi=function(){this.pipeline.reset(),this.pipeline.add(e.hi.trimmer,e.hi.stopWordFilter,e.hi.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.hi.stemmer))},e.hi.wordCharacters="ऀ-ःऄ-एऐ-टठ-यर-िी-ॏॐ-य़ॠ-९॰-ॿa-zA-Za-zA-Z0-90-9",e.hi.trimmer=e.trimmerSupport.generateTrimmer(e.hi.wordCharacters),e.Pipeline.registerFunction(e.hi.trimmer,"trimmer-hi"),e.hi.stopWordFilter=e.generateStopWordFilter("अत अपना अपनी अपने अभी अंदर आदि आप इत्यादि इन इनका इन्हीं इन्हें इन्हों इस इसका इसकी इसके इसमें इसी इसे उन उनका उनकी उनके उनको उन्हीं उन्हें उन्हों उस उसके उसी उसे एक एवं एस ऐसे और कई कर करता करते करना करने करें कहते कहा का काफ़ी कि कितना किन्हें किन्हों किया किर किस किसी किसे की कुछ कुल के को कोई कौन कौनसा गया घर जब जहाँ जा जितना जिन जिन्हें जिन्हों जिस जिसे जीधर जैसा जैसे जो तक तब तरह तिन तिन्हें तिन्हों तिस तिसे तो था थी थे दबारा दिया दुसरा दूसरे दो द्वारा न नके नहीं ना निहायत नीचे ने पर पहले पूरा पे फिर बनी बही बहुत बाद बाला बिलकुल भी भीतर मगर मानो मे में यदि यह यहाँ यही या यिह ये रखें रहा रहे ऱ्वासा लिए लिये लेकिन व वग़ैरह वर्ग वह वहाँ वहीं वाले वुह वे वो सकता सकते सबसे सभी साथ साबुत साभ सारा से सो संग ही हुआ हुई हुए है हैं हो होता होती होते होना होने".split(" ")),e.hi.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.hi.tokenizer=function(i){if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var t=i.toString().toLowerCase().replace(/^\s+/,"");return r.cut(t).split("|")},e.Pipeline.registerFunction(e.hi.stemmer,"stemmer-hi"),e.Pipeline.registerFunction(e.hi.stopWordFilter,"stopWordFilter-hi")}});
|
||||||
+18
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.hy=function(){this.pipeline.reset(),this.pipeline.add(e.hy.trimmer,e.hy.stopWordFilter)},e.hy.wordCharacters="[A-Za-z-֏ff-ﭏ]",e.hy.trimmer=e.trimmerSupport.generateTrimmer(e.hy.wordCharacters),e.Pipeline.registerFunction(e.hy.trimmer,"trimmer-hy"),e.hy.stopWordFilter=e.generateStopWordFilter("դու և եք էիր էիք հետո նաև նրանք որը վրա է որ պիտի են այս մեջ ն իր ու ի այդ որոնք այն կամ էր մի ես համար այլ իսկ էին ենք հետ ին թ էինք մենք նրա նա դուք եմ էի ըստ որպես ում".split(" ")),e.Pipeline.registerFunction(e.hy.stopWordFilter,"stopWordFilter-hy"),e.hy.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}(),e.Pipeline.registerFunction(e.hy.stemmer,"stemmer-hy")}});
|
||||||
+18
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var r="2"==e.version[0];e.ja=function(){this.pipeline.reset(),this.pipeline.add(e.ja.trimmer,e.ja.stopWordFilter,e.ja.stemmer),r?this.tokenizer=e.ja.tokenizer:(e.tokenizer&&(e.tokenizer=e.ja.tokenizer),this.tokenizerFn&&(this.tokenizerFn=e.ja.tokenizer))};var t=new e.TinySegmenter;e.ja.tokenizer=function(i){var n,o,s,p,a,u,m,l,c,f;if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(t){return r?new e.Token(t.toLowerCase()):t.toLowerCase()});for(o=i.toString().toLowerCase().replace(/^\s+/,""),n=o.length-1;n>=0;n--)if(/\S/.test(o.charAt(n))){o=o.substring(0,n+1);break}for(a=[],s=o.length,c=0,l=0;c<=s;c++)if(u=o.charAt(c),m=c-l,u.match(/\s/)||c==s){if(m>0)for(p=t.segment(o.slice(l,c)).filter(function(e){return!!e}),f=l,n=0;n<p.length;n++)r?a.push(new e.Token(p[n],{position:[f,p[n].length],index:a.length})):a.push(p[n]),f+=p[n].length;l=c+1}return a},e.ja.stemmer=function(){return function(e){return e}}(),e.Pipeline.registerFunction(e.ja.stemmer,"stemmer-ja"),e.ja.wordCharacters="一二三四五六七八九十百千万億兆一-龠々〆ヵヶぁ-んァ-ヴーア-ン゙a-zA-Za-zA-Z0-90-9",e.ja.trimmer=e.trimmerSupport.generateTrimmer(e.ja.wordCharacters),e.Pipeline.registerFunction(e.ja.trimmer,"trimmer-ja"),e.ja.stopWordFilter=e.generateStopWordFilter("これ それ あれ この その あの ここ そこ あそこ こちら どこ だれ なに なん 何 私 貴方 貴方方 我々 私達 あの人 あのかた 彼女 彼 です あります おります います は が の に を で え から まで より も どの と し それで しかし".split(" ")),e.Pipeline.registerFunction(e.ja.stopWordFilter,"stopWordFilter-ja"),e.jp=e.ja,e.Pipeline.registerFunction(e.jp.stemmer,"stemmer-jp"),e.Pipeline.registerFunction(e.jp.trimmer,"trimmer-jp"),e.Pipeline.registerFunction(e.jp.stopWordFilter,"stopWordFilter-jp")}});
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
module.exports=require("./lunr.ja");
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.kn=function(){this.pipeline.reset(),this.pipeline.add(e.kn.trimmer,e.kn.stopWordFilter,e.kn.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.kn.stemmer))},e.kn.wordCharacters="ಀ-಄ಅ-ಔಕ-ಹಾ-ೌ಼-ಽೕ-ೖೝ-ೞೠ-ೡೢ-ೣ೦-೯ೱ-ೳ",e.kn.trimmer=e.trimmerSupport.generateTrimmer(e.kn.wordCharacters),e.Pipeline.registerFunction(e.kn.trimmer,"trimmer-kn"),e.kn.stopWordFilter=e.generateStopWordFilter("ಮತ್ತು ಈ ಒಂದು ರಲ್ಲಿ ಹಾಗೂ ಎಂದು ಅಥವಾ ಇದು ರ ಅವರು ಎಂಬ ಮೇಲೆ ಅವರ ತನ್ನ ಆದರೆ ತಮ್ಮ ನಂತರ ಮೂಲಕ ಹೆಚ್ಚು ನ ಆ ಕೆಲವು ಅನೇಕ ಎರಡು ಹಾಗು ಪ್ರಮುಖ ಇದನ್ನು ಇದರ ಸುಮಾರು ಅದರ ಅದು ಮೊದಲ ಬಗ್ಗೆ ನಲ್ಲಿ ರಂದು ಇತರ ಅತ್ಯಂತ ಹೆಚ್ಚಿನ ಸಹ ಸಾಮಾನ್ಯವಾಗಿ ನೇ ಹಲವಾರು ಹೊಸ ದಿ ಕಡಿಮೆ ಯಾವುದೇ ಹೊಂದಿದೆ ದೊಡ್ಡ ಅನ್ನು ಇವರು ಪ್ರಕಾರ ಇದೆ ಮಾತ್ರ ಕೂಡ ಇಲ್ಲಿ ಎಲ್ಲಾ ವಿವಿಧ ಅದನ್ನು ಹಲವು ರಿಂದ ಕೇವಲ ದ ದಕ್ಷಿಣ ಗೆ ಅವನ ಅತಿ ನೆಯ ಬಹಳ ಕೆಲಸ ಎಲ್ಲ ಪ್ರತಿ ಇತ್ಯಾದಿ ಇವು ಬೇರೆ ಹೀಗೆ ನಡುವೆ ಇದಕ್ಕೆ ಎಸ್ ಇವರ ಮೊದಲು ಶ್ರೀ ಮಾಡುವ ಇದರಲ್ಲಿ ರೀತಿಯ ಮಾಡಿದ ಕಾಲ ಅಲ್ಲಿ ಮಾಡಲು ಅದೇ ಈಗ ಅವು ಗಳು ಎ ಎಂಬುದು ಅವನು ಅಂದರೆ ಅವರಿಗೆ ಇರುವ ವಿಶೇಷ ಮುಂದೆ ಅವುಗಳ ಮುಂತಾದ ಮೂಲ ಬಿ ಮೀ ಒಂದೇ ಇನ್ನೂ ಹೆಚ್ಚಾಗಿ ಮಾಡಿ ಅವರನ್ನು ಇದೇ ಯ ರೀತಿಯಲ್ಲಿ ಜೊತೆ ಅದರಲ್ಲಿ ಮಾಡಿದರು ನಡೆದ ಆಗ ಮತ್ತೆ ಪೂರ್ವ ಆತ ಬಂದ ಯಾವ ಒಟ್ಟು ಇತರೆ ಹಿಂದೆ ಪ್ರಮಾಣದ ಗಳನ್ನು ಕುರಿತು ಯು ಆದ್ದರಿಂದ ಅಲ್ಲದೆ ನಗರದ ಮೇಲಿನ ಏಕೆಂದರೆ ರಷ್ಟು ಎಂಬುದನ್ನು ಬಾರಿ ಎಂದರೆ ಹಿಂದಿನ ಆದರೂ ಆದ ಸಂಬಂಧಿಸಿದ ಮತ್ತೊಂದು ಸಿ ಆತನ ".split(" ")),e.kn.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.kn.tokenizer=function(t){if(!arguments.length||null==t||void 0==t)return[];if(Array.isArray(t))return t.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var n=t.toString().toLowerCase().replace(/^\s+/,"");return r.cut(n).split("|")},e.Pipeline.registerFunction(e.kn.stemmer,"stemmer-kn"),e.Pipeline.registerFunction(e.kn.stopWordFilter,"stopWordFilter-kn")}});
|
||||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){e.multiLanguage=function(){for(var t=Array.prototype.slice.call(arguments),i=t.join("-"),r="",n=[],s=[],p=0;p<t.length;++p)"en"==t[p]?(r+="\\w",n.unshift(e.stopWordFilter),n.push(e.stemmer),s.push(e.stemmer)):(r+=e[t[p]].wordCharacters,e[t[p]].stopWordFilter&&n.unshift(e[t[p]].stopWordFilter),e[t[p]].stemmer&&(n.push(e[t[p]].stemmer),s.push(e[t[p]].stemmer)));var o=e.trimmerSupport.generateTrimmer(r);return e.Pipeline.registerFunction(o,"lunr-multi-trimmer-"+i),n.unshift(o),function(){this.pipeline.reset(),this.pipeline.add.apply(this.pipeline,n),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add.apply(this.searchPipeline,s))}}}});
|
||||||
+18
File diff suppressed because one or more lines are too long
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/*!
|
||||||
|
* Lunr languages, `Norwegian` language
|
||||||
|
* https://github.com/MihaiValentin/lunr-languages
|
||||||
|
*
|
||||||
|
* Copyright 2014, Mihai Valentin
|
||||||
|
* http://www.mozilla.org/MPL/
|
||||||
|
*/
|
||||||
|
/*!
|
||||||
|
* based on
|
||||||
|
* Snowball JavaScript Library v0.3
|
||||||
|
* http://code.google.com/p/urim/
|
||||||
|
* http://snowball.tartarus.org/
|
||||||
|
*
|
||||||
|
* Copyright 2010, Oleg Mazko
|
||||||
|
* http://www.mozilla.org/MPL/
|
||||||
|
*/
|
||||||
|
|
||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.no=function(){this.pipeline.reset(),this.pipeline.add(e.no.trimmer,e.no.stopWordFilter,e.no.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.no.stemmer))},e.no.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.no.trimmer=e.trimmerSupport.generateTrimmer(e.no.wordCharacters),e.Pipeline.registerFunction(e.no.trimmer,"trimmer-no"),e.no.stemmer=function(){var r=e.stemmerSupport.Among,n=e.stemmerSupport.SnowballProgram,i=new function(){function e(){var e,r=w.cursor+3;if(a=w.limit,0<=r||r<=w.limit){for(s=r;;){if(e=w.cursor,w.in_grouping(d,97,248)){w.cursor=e;break}if(e>=w.limit)return;w.cursor=e+1}for(;!w.out_grouping(d,97,248);){if(w.cursor>=w.limit)return;w.cursor++}a=w.cursor,a<s&&(a=s)}}function i(){var e,r,n;if(w.cursor>=a&&(r=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,e=w.find_among_b(m,29),w.limit_backward=r,e))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:n=w.limit-w.cursor,w.in_grouping_b(c,98,122)?w.slice_del():(w.cursor=w.limit-n,w.eq_s_b(1,"k")&&w.out_grouping_b(d,97,248)&&w.slice_del());break;case 3:w.slice_from("er")}}function t(){var e,r=w.limit-w.cursor;w.cursor>=a&&(e=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,w.find_among_b(u,2)?(w.bra=w.cursor,w.limit_backward=e,w.cursor=w.limit-r,w.cursor>w.limit_backward&&(w.cursor--,w.bra=w.cursor,w.slice_del())):w.limit_backward=e)}function o(){var e,r;w.cursor>=a&&(r=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,e=w.find_among_b(l,11),e?(w.bra=w.cursor,w.limit_backward=r,1==e&&w.slice_del()):w.limit_backward=r)}var s,a,m=[new r("a",-1,1),new r("e",-1,1),new r("ede",1,1),new r("ande",1,1),new r("ende",1,1),new r("ane",1,1),new r("ene",1,1),new r("hetene",6,1),new r("erte",1,3),new r("en",-1,1),new r("heten",9,1),new r("ar",-1,1),new r("er",-1,1),new r("heter",12,1),new r("s",-1,2),new r("as",14,1),new r("es",14,1),new r("edes",16,1),new r("endes",16,1),new r("enes",16,1),new r("hetenes",19,1),new r("ens",14,1),new r("hetens",21,1),new r("ers",14,1),new r("ets",14,1),new r("et",-1,1),new r("het",25,1),new r("ert",-1,3),new r("ast",-1,1)],u=[new r("dt",-1,-1),new r("vt",-1,-1)],l=[new r("leg",-1,1),new r("eleg",0,1),new r("ig",-1,1),new r("eig",2,1),new r("lig",2,1),new r("elig",4,1),new r("els",-1,1),new r("lov",-1,1),new r("elov",7,1),new r("slov",7,1),new r("hetslov",9,1)],d=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,48,0,128],c=[119,125,149,1],w=new n;this.setCurrent=function(e){w.setCurrent(e)},this.getCurrent=function(){return w.getCurrent()},this.stem=function(){var r=w.cursor;return e(),w.limit_backward=r,w.cursor=w.limit,i(),w.cursor=w.limit,t(),w.cursor=w.limit,o(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return i.setCurrent(e),i.stem(),i.getCurrent()}):(i.setCurrent(e),i.stem(),i.getCurrent())}}(),e.Pipeline.registerFunction(e.no.stemmer,"stemmer-no"),e.no.stopWordFilter=e.generateStopWordFilter("alle at av bare begge ble blei bli blir blitt både båe da de deg dei deim deira deires dem den denne der dere deres det dette di din disse ditt du dykk dykkar då eg ein eit eitt eller elles en enn er et ett etter for fordi fra før ha hadde han hans har hennar henne hennes her hjå ho hoe honom hoss hossen hun hva hvem hver hvilke hvilken hvis hvor hvordan hvorfor i ikke ikkje ikkje ingen ingi inkje inn inni ja jeg kan kom korleis korso kun kunne kva kvar kvarhelst kven kvi kvifor man mange me med medan meg meget mellom men mi min mine mitt mot mykje ned no noe noen noka noko nokon nokor nokre nå når og også om opp oss over på samme seg selv si si sia sidan siden sin sine sitt sjøl skal skulle slik so som som somme somt så sånn til um upp ut uten var vart varte ved vere verte vi vil ville vore vors vort vår være være vært å".split(" ")),e.Pipeline.registerFunction(e.no.stopWordFilter,"stopWordFilter-no")}});
|
||||||
+18
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.sa=function(){this.pipeline.reset(),this.pipeline.add(e.sa.trimmer,e.sa.stopWordFilter,e.sa.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.sa.stemmer))},e.sa.wordCharacters="ऀ-ःऄ-एऐ-टठ-यर-िी-ॏॐ-य़ॠ-९॰-ॿ꣠-꣱ꣲ-ꣷ꣸-ꣻ꣼-ꣽꣾ-ꣿᆰ0-ᆰ9",e.sa.trimmer=e.trimmerSupport.generateTrimmer(e.sa.wordCharacters),e.Pipeline.registerFunction(e.sa.trimmer,"trimmer-sa"),e.sa.stopWordFilter=e.generateStopWordFilter('तथा अयम् एकम् इत्यस्मिन् तथा तत् वा अयम् इत्यस्य ते आहूत उपरि तेषाम् किन्तु तेषाम् तदा इत्यनेन अधिकः इत्यस्य तत् केचन बहवः द्वि तथा महत्वपूर्णः अयम् अस्य विषये अयं अस्ति तत् प्रथमः विषये इत्युपरि इत्युपरि इतर अधिकतमः अधिकः अपि सामान्यतया ठ इतरेतर नूतनम् द न्यूनम् कश्चित् वा विशालः द सः अस्ति तदनुसारम् तत्र अस्ति केवलम् अपि अत्र सर्वे विविधाः तत् बहवः यतः इदानीम् द दक्षिण इत्यस्मै तस्य उपरि नथ अतीव कार्यम् सर्वे एकैकम् इत्यादि। एते सन्ति उत इत्थम् मध्ये एतदर्थं . स कस्य प्रथमः श्री. करोति अस्मिन् प्रकारः निर्मिता कालः तत्र कर्तुं समान अधुना ते सन्ति स एकः अस्ति सः अर्थात् तेषां कृते . स्थितम् विशेषः अग्रिम तेषाम् समान स्रोतः ख म समान इदानीमपि अधिकतया करोतु ते समान इत्यस्य वीथी सह यस्मिन् कृतवान् धृतः तदा पुनः पूर्वं सः आगतः किम् कुल इतर पुरा मात्रा स विषये उ अतएव अपि नगरस्य उपरि यतः प्रतिशतं कतरः कालः साधनानि भूत तथापि जात सम्बन्धि अन्यत् ग अतः अस्माकं स्वकीयाः अस्माकं इदानीं अन्तः इत्यादयः भवन्तः इत्यादयः एते एताः तस्य अस्य इदम् एते तेषां तेषां तेषां तान् तेषां तेषां तेषां समानः सः एकः च तादृशाः बहवः अन्ये च वदन्ति यत् कियत् कस्मै कस्मै यस्मै यस्मै यस्मै यस्मै न अतिनीचः किन्तु प्रथमं सम्पूर्णतया ततः चिरकालानन्तरं पुस्तकं सम्पूर्णतया अन्तः किन्तु अत्र वा इह इव श्रद्धाय अवशिष्यते परन्तु अन्ये वर्गाः सन्ति ते सन्ति शक्नुवन्ति सर्वे मिलित्वा सर्वे एकत्र"'.split(" ")),e.sa.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.sa.tokenizer=function(t){if(!arguments.length||null==t||void 0==t)return[];if(Array.isArray(t))return t.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var i=t.toString().toLowerCase().replace(/^\s+/,"");return r.cut(i).split("|")},e.Pipeline.registerFunction(e.sa.stemmer,"stemmer-sa"),e.Pipeline.registerFunction(e.sa.stopWordFilter,"stopWordFilter-sa")}});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
!function(r,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(r.lunr)}(this,function(){return function(r){r.stemmerSupport={Among:function(r,t,i,s){if(this.toCharArray=function(r){for(var t=r.length,i=new Array(t),s=0;s<t;s++)i[s]=r.charCodeAt(s);return i},!r&&""!=r||!t&&0!=t||!i)throw"Bad Among initialisation: s:"+r+", substring_i: "+t+", result: "+i;this.s_size=r.length,this.s=this.toCharArray(r),this.substring_i=t,this.result=i,this.method=s},SnowballProgram:function(){var r;return{bra:0,ket:0,limit:0,cursor:0,limit_backward:0,setCurrent:function(t){r=t,this.cursor=0,this.limit=t.length,this.limit_backward=0,this.bra=this.cursor,this.ket=this.limit},getCurrent:function(){var t=r;return r=null,t},in_grouping:function(t,i,s){if(this.cursor<this.limit){var e=r.charCodeAt(this.cursor);if(e<=s&&e>=i&&(e-=i,t[e>>3]&1<<(7&e)))return this.cursor++,!0}return!1},in_grouping_b:function(t,i,s){if(this.cursor>this.limit_backward){var e=r.charCodeAt(this.cursor-1);if(e<=s&&e>=i&&(e-=i,t[e>>3]&1<<(7&e)))return this.cursor--,!0}return!1},out_grouping:function(t,i,s){if(this.cursor<this.limit){var e=r.charCodeAt(this.cursor);if(e>s||e<i)return this.cursor++,!0;if(e-=i,!(t[e>>3]&1<<(7&e)))return this.cursor++,!0}return!1},out_grouping_b:function(t,i,s){if(this.cursor>this.limit_backward){var e=r.charCodeAt(this.cursor-1);if(e>s||e<i)return this.cursor--,!0;if(e-=i,!(t[e>>3]&1<<(7&e)))return this.cursor--,!0}return!1},eq_s:function(t,i){if(this.limit-this.cursor<t)return!1;for(var s=0;s<t;s++)if(r.charCodeAt(this.cursor+s)!=i.charCodeAt(s))return!1;return this.cursor+=t,!0},eq_s_b:function(t,i){if(this.cursor-this.limit_backward<t)return!1;for(var s=0;s<t;s++)if(r.charCodeAt(this.cursor-t+s)!=i.charCodeAt(s))return!1;return this.cursor-=t,!0},find_among:function(t,i){for(var s=0,e=i,n=this.cursor,u=this.limit,o=0,h=0,c=!1;;){for(var a=s+(e-s>>1),f=0,l=o<h?o:h,_=t[a],m=l;m<_.s_size;m++){if(n+l==u){f=-1;break}if(f=r.charCodeAt(n+l)-_.s[m])break;l++}if(f<0?(e=a,h=l):(s=a,o=l),e-s<=1){if(s>0||e==s||c)break;c=!0}}for(;;){var _=t[s];if(o>=_.s_size){if(this.cursor=n+_.s_size,!_.method)return _.result;var b=_.method();if(this.cursor=n+_.s_size,b)return _.result}if((s=_.substring_i)<0)return 0}},find_among_b:function(t,i){for(var s=0,e=i,n=this.cursor,u=this.limit_backward,o=0,h=0,c=!1;;){for(var a=s+(e-s>>1),f=0,l=o<h?o:h,_=t[a],m=_.s_size-1-l;m>=0;m--){if(n-l==u){f=-1;break}if(f=r.charCodeAt(n-1-l)-_.s[m])break;l++}if(f<0?(e=a,h=l):(s=a,o=l),e-s<=1){if(s>0||e==s||c)break;c=!0}}for(;;){var _=t[s];if(o>=_.s_size){if(this.cursor=n-_.s_size,!_.method)return _.result;var b=_.method();if(this.cursor=n-_.s_size,b)return _.result}if((s=_.substring_i)<0)return 0}},replace_s:function(t,i,s){var e=s.length-(i-t),n=r.substring(0,t),u=r.substring(i);return r=n+s+u,this.limit+=e,this.cursor>=i?this.cursor+=e:this.cursor>t&&(this.cursor=t),e},slice_check:function(){if(this.bra<0||this.bra>this.ket||this.ket>this.limit||this.limit>r.length)throw"faulty slice operation"},slice_from:function(r){this.slice_check(),this.replace_s(this.bra,this.ket,r)},slice_del:function(){this.slice_from("")},insert:function(r,t,i){var s=this.replace_s(r,t,i);r<=this.bra&&(this.bra+=s),r<=this.ket&&(this.ket+=s)},slice_to:function(){return this.slice_check(),r.substring(this.bra,this.ket)},eq_v_b:function(r){return this.eq_s_b(r.length,r)}}}},r.trimmerSupport={generateTrimmer:function(r){var t=new RegExp("^[^"+r+"]+"),i=new RegExp("[^"+r+"]+$");return function(r){return"function"==typeof r.update?r.update(function(r){return r.replace(t,"").replace(i,"")}):r.replace(t,"").replace(i,"")}}}}});
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/*!
|
||||||
|
* Lunr languages, `Swedish` language
|
||||||
|
* https://github.com/MihaiValentin/lunr-languages
|
||||||
|
*
|
||||||
|
* Copyright 2014, Mihai Valentin
|
||||||
|
* http://www.mozilla.org/MPL/
|
||||||
|
*/
|
||||||
|
/*!
|
||||||
|
* based on
|
||||||
|
* Snowball JavaScript Library v0.3
|
||||||
|
* http://code.google.com/p/urim/
|
||||||
|
* http://snowball.tartarus.org/
|
||||||
|
*
|
||||||
|
* Copyright 2010, Oleg Mazko
|
||||||
|
* http://www.mozilla.org/MPL/
|
||||||
|
*/
|
||||||
|
|
||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.sv=function(){this.pipeline.reset(),this.pipeline.add(e.sv.trimmer,e.sv.stopWordFilter,e.sv.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.sv.stemmer))},e.sv.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.sv.trimmer=e.trimmerSupport.generateTrimmer(e.sv.wordCharacters),e.Pipeline.registerFunction(e.sv.trimmer,"trimmer-sv"),e.sv.stemmer=function(){var r=e.stemmerSupport.Among,n=e.stemmerSupport.SnowballProgram,t=new function(){function e(){var e,r=w.cursor+3;if(o=w.limit,0<=r||r<=w.limit){for(a=r;;){if(e=w.cursor,w.in_grouping(l,97,246)){w.cursor=e;break}if(w.cursor=e,w.cursor>=w.limit)return;w.cursor++}for(;!w.out_grouping(l,97,246);){if(w.cursor>=w.limit)return;w.cursor++}o=w.cursor,o<a&&(o=a)}}function t(){var e,r=w.limit_backward;if(w.cursor>=o&&(w.limit_backward=o,w.cursor=w.limit,w.ket=w.cursor,e=w.find_among_b(u,37),w.limit_backward=r,e))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:w.in_grouping_b(d,98,121)&&w.slice_del()}}function i(){var e=w.limit_backward;w.cursor>=o&&(w.limit_backward=o,w.cursor=w.limit,w.find_among_b(c,7)&&(w.cursor=w.limit,w.ket=w.cursor,w.cursor>w.limit_backward&&(w.bra=--w.cursor,w.slice_del())),w.limit_backward=e)}function s(){var e,r;if(w.cursor>=o){if(r=w.limit_backward,w.limit_backward=o,w.cursor=w.limit,w.ket=w.cursor,e=w.find_among_b(m,5))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:w.slice_from("lös");break;case 3:w.slice_from("full")}w.limit_backward=r}}var a,o,u=[new r("a",-1,1),new r("arna",0,1),new r("erna",0,1),new r("heterna",2,1),new r("orna",0,1),new r("ad",-1,1),new r("e",-1,1),new r("ade",6,1),new r("ande",6,1),new r("arne",6,1),new r("are",6,1),new r("aste",6,1),new r("en",-1,1),new r("anden",12,1),new r("aren",12,1),new r("heten",12,1),new r("ern",-1,1),new r("ar",-1,1),new r("er",-1,1),new r("heter",18,1),new r("or",-1,1),new r("s",-1,2),new r("as",21,1),new r("arnas",22,1),new r("ernas",22,1),new r("ornas",22,1),new r("es",21,1),new r("ades",26,1),new r("andes",26,1),new r("ens",21,1),new r("arens",29,1),new r("hetens",29,1),new r("erns",21,1),new r("at",-1,1),new r("andet",-1,1),new r("het",-1,1),new r("ast",-1,1)],c=[new r("dd",-1,-1),new r("gd",-1,-1),new r("nn",-1,-1),new r("dt",-1,-1),new r("gt",-1,-1),new r("kt",-1,-1),new r("tt",-1,-1)],m=[new r("ig",-1,1),new r("lig",0,1),new r("els",-1,1),new r("fullt",-1,3),new r("löst",-1,2)],l=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,24,0,32],d=[119,127,149],w=new n;this.setCurrent=function(e){w.setCurrent(e)},this.getCurrent=function(){return w.getCurrent()},this.stem=function(){var r=w.cursor;return e(),w.limit_backward=r,w.cursor=w.limit,t(),w.cursor=w.limit,i(),w.cursor=w.limit,s(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return t.setCurrent(e),t.stem(),t.getCurrent()}):(t.setCurrent(e),t.stem(),t.getCurrent())}}(),e.Pipeline.registerFunction(e.sv.stemmer,"stemmer-sv"),e.sv.stopWordFilter=e.generateStopWordFilter("alla allt att av blev bli blir blivit de dem den denna deras dess dessa det detta dig din dina ditt du där då efter ej eller en er era ert ett från för ha hade han hans har henne hennes hon honom hur här i icke ingen inom inte jag ju kan kunde man med mellan men mig min mina mitt mot mycket ni nu när någon något några och om oss på samma sedan sig sin sina sitta själv skulle som så sådan sådana sådant till under upp ut utan vad var vara varför varit varje vars vart vem vi vid vilka vilkas vilken vilket vår våra vårt än är åt över".split(" ")),e.Pipeline.registerFunction(e.sv.stopWordFilter,"stopWordFilter-sv")}});
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.ta=function(){this.pipeline.reset(),this.pipeline.add(e.ta.trimmer,e.ta.stopWordFilter,e.ta.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.ta.stemmer))},e.ta.wordCharacters="-உஊ-ஏஐ-ஙச-ட-னப-யர-ஹ-ிீ-ொ-ௐ---௩௪-௯௰-௹௺-a-zA-Za-zA-Z0-90-9",e.ta.trimmer=e.trimmerSupport.generateTrimmer(e.ta.wordCharacters),e.Pipeline.registerFunction(e.ta.trimmer,"trimmer-ta"),e.ta.stopWordFilter=e.generateStopWordFilter("அங்கு அங்கே அது அதை அந்த அவர் அவர்கள் அவள் அவன் அவை ஆக ஆகவே ஆகையால் ஆதலால் ஆதலினால் ஆனாலும் ஆனால் இங்கு இங்கே இது இதை இந்த இப்படி இவர் இவர்கள் இவள் இவன் இவை இவ்வளவு உனக்கு உனது உன் உன்னால் எங்கு எங்கே எது எதை எந்த எப்படி எவர் எவர்கள் எவள் எவன் எவை எவ்வளவு எனக்கு எனது எனவே என் என்ன என்னால் ஏது ஏன் தனது தன்னால் தானே தான் நாங்கள் நாம் நான் நீ நீங்கள்".split(" ")),e.ta.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var t=e.wordcut;t.init(),e.ta.tokenizer=function(r){if(!arguments.length||null==r||void 0==r)return[];if(Array.isArray(r))return r.map(function(t){return isLunr2?new e.Token(t.toLowerCase()):t.toLowerCase()});var i=r.toString().toLowerCase().replace(/^\s+/,"");return t.cut(i).split("|")},e.Pipeline.registerFunction(e.ta.stemmer,"stemmer-ta"),e.Pipeline.registerFunction(e.ta.stopWordFilter,"stopWordFilter-ta")}});
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.te=function(){this.pipeline.reset(),this.pipeline.add(e.te.trimmer,e.te.stopWordFilter,e.te.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.te.stemmer))},e.te.wordCharacters="ఀ-ఄఅ-ఔక-హా-ౌౕ-ౖౘ-ౚౠ-ౡౢ-ౣ౦-౯౸-౿఼ఽ్ౝ౷",e.te.trimmer=e.trimmerSupport.generateTrimmer(e.te.wordCharacters),e.Pipeline.registerFunction(e.te.trimmer,"trimmer-te"),e.te.stopWordFilter=e.generateStopWordFilter("అందరూ అందుబాటులో అడగండి అడగడం అడ్డంగా అనుగుణంగా అనుమతించు అనుమతిస్తుంది అయితే ఇప్పటికే ఉన్నారు ఎక్కడైనా ఎప్పుడు ఎవరైనా ఎవరో ఏ ఏదైనా ఏమైనప్పటికి ఒక ఒకరు కనిపిస్తాయి కాదు కూడా గా గురించి చుట్టూ చేయగలిగింది తగిన తర్వాత దాదాపు దూరంగా నిజంగా పై ప్రకారం ప్రక్కన మధ్య మరియు మరొక మళ్ళీ మాత్రమే మెచ్చుకో వద్ద వెంట వేరుగా వ్యతిరేకంగా సంబంధం".split(" ")),e.te.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var t=e.wordcut;t.init(),e.te.tokenizer=function(r){if(!arguments.length||null==r||void 0==r)return[];if(Array.isArray(r))return r.map(function(t){return isLunr2?new e.Token(t.toLowerCase()):t.toLowerCase()});var i=r.toString().toLowerCase().replace(/^\s+/,"");return t.cut(i).split("|")},e.Pipeline.registerFunction(e.te.stemmer,"stemmer-te"),e.Pipeline.registerFunction(e.te.stopWordFilter,"stopWordFilter-te")}});
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var r="2"==e.version[0];e.th=function(){this.pipeline.reset(),this.pipeline.add(e.th.trimmer),r?this.tokenizer=e.th.tokenizer:(e.tokenizer&&(e.tokenizer=e.th.tokenizer),this.tokenizerFn&&(this.tokenizerFn=e.th.tokenizer))},e.th.wordCharacters="[-]",e.th.trimmer=e.trimmerSupport.generateTrimmer(e.th.wordCharacters),e.Pipeline.registerFunction(e.th.trimmer,"trimmer-th");var t=e.wordcut;t.init(),e.th.tokenizer=function(i){if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(t){return r?new e.Token(t):t});var n=i.toString().replace(/^\s+/,"");return t.cut(n).split("|")}}});
|
||||||
+18
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.vi=function(){this.pipeline.reset(),this.pipeline.add(e.vi.stopWordFilter,e.vi.trimmer)},e.vi.wordCharacters="[A-Za-ẓ̀͐́͑̉̃̓ÂâÊêÔôĂ-ăĐ-đƠ-ơƯ-ư]",e.vi.trimmer=e.trimmerSupport.generateTrimmer(e.vi.wordCharacters),e.Pipeline.registerFunction(e.vi.trimmer,"trimmer-vi"),e.vi.stopWordFilter=e.generateStopWordFilter("là cái nhưng mà".split(" "))}});
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r(require("@node-rs/jieba")):r()(e.lunr)}(this,function(e){return function(r,t){if(void 0===r)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===r.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var i="2"==r.version[0];r.zh=function(){this.pipeline.reset(),this.pipeline.add(r.zh.trimmer,r.zh.stopWordFilter,r.zh.stemmer),i?this.tokenizer=r.zh.tokenizer:(r.tokenizer&&(r.tokenizer=r.zh.tokenizer),this.tokenizerFn&&(this.tokenizerFn=r.zh.tokenizer))},r.zh.tokenizer=function(n){if(!arguments.length||null==n||void 0==n)return[];if(Array.isArray(n))return n.map(function(e){return i?new r.Token(e.toLowerCase()):e.toLowerCase()});t&&e.load(t);var o=n.toString().trim().toLowerCase(),s=[];e.cut(o,!0).forEach(function(e){s=s.concat(e.split(" "))}),s=s.filter(function(e){return!!e});var u=0;return s.map(function(e,t){if(i){var n=o.indexOf(e,u),s={};return s.position=[n,e.length],s.index=t,u=n,new r.Token(e,s)}return e})},r.zh.wordCharacters="\\w一-龥",r.zh.trimmer=r.trimmerSupport.generateTrimmer(r.zh.wordCharacters),r.Pipeline.registerFunction(r.zh.trimmer,"trimmer-zh"),r.zh.stemmer=function(){return function(e){return e}}(),r.Pipeline.registerFunction(r.zh.stemmer,"stemmer-zh"),r.zh.stopWordFilter=r.generateStopWordFilter("的 一 不 在 人 有 是 为 為 以 于 於 上 他 而 后 後 之 来 來 及 了 因 下 可 到 由 这 這 与 與 也 此 但 并 並 个 個 其 已 无 無 小 我 们 們 起 最 再 今 去 好 只 又 或 很 亦 某 把 那 你 乃 它 吧 被 比 别 趁 当 當 从 從 得 打 凡 儿 兒 尔 爾 该 該 各 给 給 跟 和 何 还 還 即 几 幾 既 看 据 據 距 靠 啦 另 么 麽 每 嘛 拿 哪 您 凭 憑 且 却 卻 让 讓 仍 啥 如 若 使 谁 誰 虽 雖 随 隨 同 所 她 哇 嗡 往 些 向 沿 哟 喲 用 咱 则 則 怎 曾 至 致 着 著 诸 諸 自".split(" ")),r.Pipeline.registerFunction(r.zh.stopWordFilter,"stopWordFilter-zh")}});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* export the module via AMD, CommonJS or as a browser global
|
||||||
|
* Export code from https://github.com/umdjs/umd/blob/master/returnExports.js
|
||||||
|
*/
|
||||||
|
;(function (root, factory) {
|
||||||
|
if (typeof define === 'function' && define.amd) {
|
||||||
|
// AMD. Register as an anonymous module.
|
||||||
|
define(factory)
|
||||||
|
} else if (typeof exports === 'object') {
|
||||||
|
/**
|
||||||
|
* Node. Does not work with strict CommonJS, but
|
||||||
|
* only CommonJS-like environments that support module.exports,
|
||||||
|
* like Node.
|
||||||
|
*/
|
||||||
|
module.exports = factory()
|
||||||
|
} else {
|
||||||
|
// Browser globals (root is window)
|
||||||
|
factory()(root.lunr);
|
||||||
|
}
|
||||||
|
}(this, function () {
|
||||||
|
/**
|
||||||
|
* Just return a value to define the module export.
|
||||||
|
* This example returns an object, but the module
|
||||||
|
* can return a function as the exported value.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return function(lunr) {
|
||||||
|
// TinySegmenter 0.1 -- Super compact Japanese tokenizer in Javascript
|
||||||
|
// (c) 2008 Taku Kudo <taku@chasen.org>
|
||||||
|
// TinySegmenter is freely distributable under the terms of a new BSD licence.
|
||||||
|
// For details, see http://chasen.org/~taku/software/TinySegmenter/LICENCE.txt
|
||||||
|
|
||||||
|
function TinySegmenter() {
|
||||||
|
var patterns = {
|
||||||
|
"[一二三四五六七八九十百千万億兆]":"M",
|
||||||
|
"[一-龠々〆ヵヶ]":"H",
|
||||||
|
"[ぁ-ん]":"I",
|
||||||
|
"[ァ-ヴーア-ン゙ー]":"K",
|
||||||
|
"[a-zA-Za-zA-Z]":"A",
|
||||||
|
"[0-90-9]":"N"
|
||||||
|
}
|
||||||
|
this.chartype_ = [];
|
||||||
|
for (var i in patterns) {
|
||||||
|
var regexp = new RegExp(i);
|
||||||
|
this.chartype_.push([regexp, patterns[i]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.BIAS__ = -332
|
||||||
|
this.BC1__ = {"HH":6,"II":2461,"KH":406,"OH":-1378};
|
||||||
|
this.BC2__ = {"AA":-3267,"AI":2744,"AN":-878,"HH":-4070,"HM":-1711,"HN":4012,"HO":3761,"IA":1327,"IH":-1184,"II":-1332,"IK":1721,"IO":5492,"KI":3831,"KK":-8741,"MH":-3132,"MK":3334,"OO":-2920};
|
||||||
|
this.BC3__ = {"HH":996,"HI":626,"HK":-721,"HN":-1307,"HO":-836,"IH":-301,"KK":2762,"MK":1079,"MM":4034,"OA":-1652,"OH":266};
|
||||||
|
this.BP1__ = {"BB":295,"OB":304,"OO":-125,"UB":352};
|
||||||
|
this.BP2__ = {"BO":60,"OO":-1762};
|
||||||
|
this.BQ1__ = {"BHH":1150,"BHM":1521,"BII":-1158,"BIM":886,"BMH":1208,"BNH":449,"BOH":-91,"BOO":-2597,"OHI":451,"OIH":-296,"OKA":1851,"OKH":-1020,"OKK":904,"OOO":2965};
|
||||||
|
this.BQ2__ = {"BHH":118,"BHI":-1159,"BHM":466,"BIH":-919,"BKK":-1720,"BKO":864,"OHH":-1139,"OHM":-181,"OIH":153,"UHI":-1146};
|
||||||
|
this.BQ3__ = {"BHH":-792,"BHI":2664,"BII":-299,"BKI":419,"BMH":937,"BMM":8335,"BNN":998,"BOH":775,"OHH":2174,"OHM":439,"OII":280,"OKH":1798,"OKI":-793,"OKO":-2242,"OMH":-2402,"OOO":11699};
|
||||||
|
this.BQ4__ = {"BHH":-3895,"BIH":3761,"BII":-4654,"BIK":1348,"BKK":-1806,"BMI":-3385,"BOO":-12396,"OAH":926,"OHH":266,"OHK":-2036,"ONN":-973};
|
||||||
|
this.BW1__ = {",と":660,",同":727,"B1あ":1404,"B1同":542,"、と":660,"、同":727,"」と":1682,"あっ":1505,"いう":1743,"いっ":-2055,"いる":672,"うし":-4817,"うん":665,"から":3472,"がら":600,"こう":-790,"こと":2083,"こん":-1262,"さら":-4143,"さん":4573,"した":2641,"して":1104,"すで":-3399,"そこ":1977,"それ":-871,"たち":1122,"ため":601,"った":3463,"つい":-802,"てい":805,"てき":1249,"でき":1127,"です":3445,"では":844,"とい":-4915,"とみ":1922,"どこ":3887,"ない":5713,"なっ":3015,"など":7379,"なん":-1113,"にし":2468,"には":1498,"にも":1671,"に対":-912,"の一":-501,"の中":741,"ませ":2448,"まで":1711,"まま":2600,"まる":-2155,"やむ":-1947,"よっ":-2565,"れた":2369,"れで":-913,"をし":1860,"を見":731,"亡く":-1886,"京都":2558,"取り":-2784,"大き":-2604,"大阪":1497,"平方":-2314,"引き":-1336,"日本":-195,"本当":-2423,"毎日":-2113,"目指":-724,"B1あ":1404,"B1同":542,"」と":1682};
|
||||||
|
this.BW2__ = {"..":-11822,"11":-669,"――":-5730,"−−":-13175,"いう":-1609,"うか":2490,"かし":-1350,"かも":-602,"から":-7194,"かれ":4612,"がい":853,"がら":-3198,"きた":1941,"くな":-1597,"こと":-8392,"この":-4193,"させ":4533,"され":13168,"さん":-3977,"しい":-1819,"しか":-545,"した":5078,"して":972,"しな":939,"その":-3744,"たい":-1253,"たた":-662,"ただ":-3857,"たち":-786,"たと":1224,"たは":-939,"った":4589,"って":1647,"っと":-2094,"てい":6144,"てき":3640,"てく":2551,"ては":-3110,"ても":-3065,"でい":2666,"でき":-1528,"でし":-3828,"です":-4761,"でも":-4203,"とい":1890,"とこ":-1746,"とと":-2279,"との":720,"とみ":5168,"とも":-3941,"ない":-2488,"なが":-1313,"など":-6509,"なの":2614,"なん":3099,"にお":-1615,"にし":2748,"にな":2454,"によ":-7236,"に対":-14943,"に従":-4688,"に関":-11388,"のか":2093,"ので":-7059,"のに":-6041,"のの":-6125,"はい":1073,"はが":-1033,"はず":-2532,"ばれ":1813,"まし":-1316,"まで":-6621,"まれ":5409,"めて":-3153,"もい":2230,"もの":-10713,"らか":-944,"らし":-1611,"らに":-1897,"りし":651,"りま":1620,"れた":4270,"れて":849,"れば":4114,"ろう":6067,"われ":7901,"を通":-11877,"んだ":728,"んな":-4115,"一人":602,"一方":-1375,"一日":970,"一部":-1051,"上が":-4479,"会社":-1116,"出て":2163,"分の":-7758,"同党":970,"同日":-913,"大阪":-2471,"委員":-1250,"少な":-1050,"年度":-8669,"年間":-1626,"府県":-2363,"手権":-1982,"新聞":-4066,"日新":-722,"日本":-7068,"日米":3372,"曜日":-601,"朝鮮":-2355,"本人":-2697,"東京":-1543,"然と":-1384,"社会":-1276,"立て":-990,"第に":-1612,"米国":-4268,"11":-669};
|
||||||
|
this.BW3__ = {"あた":-2194,"あり":719,"ある":3846,"い.":-1185,"い。":-1185,"いい":5308,"いえ":2079,"いく":3029,"いた":2056,"いっ":1883,"いる":5600,"いわ":1527,"うち":1117,"うと":4798,"えと":1454,"か.":2857,"か。":2857,"かけ":-743,"かっ":-4098,"かに":-669,"から":6520,"かり":-2670,"が,":1816,"が、":1816,"がき":-4855,"がけ":-1127,"がっ":-913,"がら":-4977,"がり":-2064,"きた":1645,"けど":1374,"こと":7397,"この":1542,"ころ":-2757,"さい":-714,"さを":976,"し,":1557,"し、":1557,"しい":-3714,"した":3562,"して":1449,"しな":2608,"しま":1200,"す.":-1310,"す。":-1310,"する":6521,"ず,":3426,"ず、":3426,"ずに":841,"そう":428,"た.":8875,"た。":8875,"たい":-594,"たの":812,"たり":-1183,"たる":-853,"だ.":4098,"だ。":4098,"だっ":1004,"った":-4748,"って":300,"てい":6240,"てお":855,"ても":302,"です":1437,"でに":-1482,"では":2295,"とう":-1387,"とし":2266,"との":541,"とも":-3543,"どう":4664,"ない":1796,"なく":-903,"など":2135,"に,":-1021,"に、":-1021,"にし":1771,"にな":1906,"には":2644,"の,":-724,"の、":-724,"の子":-1000,"は,":1337,"は、":1337,"べき":2181,"まし":1113,"ます":6943,"まっ":-1549,"まで":6154,"まれ":-793,"らし":1479,"られ":6820,"るる":3818,"れ,":854,"れ、":854,"れた":1850,"れて":1375,"れば":-3246,"れる":1091,"われ":-605,"んだ":606,"んで":798,"カ月":990,"会議":860,"入り":1232,"大会":2217,"始め":1681,"市":965,"新聞":-5055,"日,":974,"日、":974,"社会":2024,"カ月":990};
|
||||||
|
this.TC1__ = {"AAA":1093,"HHH":1029,"HHM":580,"HII":998,"HOH":-390,"HOM":-331,"IHI":1169,"IOH":-142,"IOI":-1015,"IOM":467,"MMH":187,"OOI":-1832};
|
||||||
|
this.TC2__ = {"HHO":2088,"HII":-1023,"HMM":-1154,"IHI":-1965,"KKH":703,"OII":-2649};
|
||||||
|
this.TC3__ = {"AAA":-294,"HHH":346,"HHI":-341,"HII":-1088,"HIK":731,"HOH":-1486,"IHH":128,"IHI":-3041,"IHO":-1935,"IIH":-825,"IIM":-1035,"IOI":-542,"KHH":-1216,"KKA":491,"KKH":-1217,"KOK":-1009,"MHH":-2694,"MHM":-457,"MHO":123,"MMH":-471,"NNH":-1689,"NNO":662,"OHO":-3393};
|
||||||
|
this.TC4__ = {"HHH":-203,"HHI":1344,"HHK":365,"HHM":-122,"HHN":182,"HHO":669,"HIH":804,"HII":679,"HOH":446,"IHH":695,"IHO":-2324,"IIH":321,"III":1497,"IIO":656,"IOO":54,"KAK":4845,"KKA":3386,"KKK":3065,"MHH":-405,"MHI":201,"MMH":-241,"MMM":661,"MOM":841};
|
||||||
|
this.TQ1__ = {"BHHH":-227,"BHHI":316,"BHIH":-132,"BIHH":60,"BIII":1595,"BNHH":-744,"BOHH":225,"BOOO":-908,"OAKK":482,"OHHH":281,"OHIH":249,"OIHI":200,"OIIH":-68};
|
||||||
|
this.TQ2__ = {"BIHH":-1401,"BIII":-1033,"BKAK":-543,"BOOO":-5591};
|
||||||
|
this.TQ3__ = {"BHHH":478,"BHHM":-1073,"BHIH":222,"BHII":-504,"BIIH":-116,"BIII":-105,"BMHI":-863,"BMHM":-464,"BOMH":620,"OHHH":346,"OHHI":1729,"OHII":997,"OHMH":481,"OIHH":623,"OIIH":1344,"OKAK":2792,"OKHH":587,"OKKA":679,"OOHH":110,"OOII":-685};
|
||||||
|
this.TQ4__ = {"BHHH":-721,"BHHM":-3604,"BHII":-966,"BIIH":-607,"BIII":-2181,"OAAA":-2763,"OAKK":180,"OHHH":-294,"OHHI":2446,"OHHO":480,"OHIH":-1573,"OIHH":1935,"OIHI":-493,"OIIH":626,"OIII":-4007,"OKAK":-8156};
|
||||||
|
this.TW1__ = {"につい":-4681,"東京都":2026};
|
||||||
|
this.TW2__ = {"ある程":-2049,"いった":-1256,"ころが":-2434,"しょう":3873,"その後":-4430,"だって":-1049,"ていた":1833,"として":-4657,"ともに":-4517,"もので":1882,"一気に":-792,"初めて":-1512,"同時に":-8097,"大きな":-1255,"対して":-2721,"社会党":-3216};
|
||||||
|
this.TW3__ = {"いただ":-1734,"してい":1314,"として":-4314,"につい":-5483,"にとっ":-5989,"に当た":-6247,"ので,":-727,"ので、":-727,"のもの":-600,"れから":-3752,"十二月":-2287};
|
||||||
|
this.TW4__ = {"いう.":8576,"いう。":8576,"からな":-2348,"してい":2958,"たが,":1516,"たが、":1516,"ている":1538,"という":1349,"ました":5543,"ません":1097,"ようと":-4258,"よると":5865};
|
||||||
|
this.UC1__ = {"A":484,"K":93,"M":645,"O":-505};
|
||||||
|
this.UC2__ = {"A":819,"H":1059,"I":409,"M":3987,"N":5775,"O":646};
|
||||||
|
this.UC3__ = {"A":-1370,"I":2311};
|
||||||
|
this.UC4__ = {"A":-2643,"H":1809,"I":-1032,"K":-3450,"M":3565,"N":3876,"O":6646};
|
||||||
|
this.UC5__ = {"H":313,"I":-1238,"K":-799,"M":539,"O":-831};
|
||||||
|
this.UC6__ = {"H":-506,"I":-253,"K":87,"M":247,"O":-387};
|
||||||
|
this.UP1__ = {"O":-214};
|
||||||
|
this.UP2__ = {"B":69,"O":935};
|
||||||
|
this.UP3__ = {"B":189};
|
||||||
|
this.UQ1__ = {"BH":21,"BI":-12,"BK":-99,"BN":142,"BO":-56,"OH":-95,"OI":477,"OK":410,"OO":-2422};
|
||||||
|
this.UQ2__ = {"BH":216,"BI":113,"OK":1759};
|
||||||
|
this.UQ3__ = {"BA":-479,"BH":42,"BI":1913,"BK":-7198,"BM":3160,"BN":6427,"BO":14761,"OI":-827,"ON":-3212};
|
||||||
|
this.UW1__ = {",":156,"、":156,"「":-463,"あ":-941,"う":-127,"が":-553,"き":121,"こ":505,"で":-201,"と":-547,"ど":-123,"に":-789,"の":-185,"は":-847,"も":-466,"や":-470,"よ":182,"ら":-292,"り":208,"れ":169,"を":-446,"ん":-137,"・":-135,"主":-402,"京":-268,"区":-912,"午":871,"国":-460,"大":561,"委":729,"市":-411,"日":-141,"理":361,"生":-408,"県":-386,"都":-718,"「":-463,"・":-135};
|
||||||
|
this.UW2__ = {",":-829,"、":-829,"〇":892,"「":-645,"」":3145,"あ":-538,"い":505,"う":134,"お":-502,"か":1454,"が":-856,"く":-412,"こ":1141,"さ":878,"ざ":540,"し":1529,"す":-675,"せ":300,"そ":-1011,"た":188,"だ":1837,"つ":-949,"て":-291,"で":-268,"と":-981,"ど":1273,"な":1063,"に":-1764,"の":130,"は":-409,"ひ":-1273,"べ":1261,"ま":600,"も":-1263,"や":-402,"よ":1639,"り":-579,"る":-694,"れ":571,"を":-2516,"ん":2095,"ア":-587,"カ":306,"キ":568,"ッ":831,"三":-758,"不":-2150,"世":-302,"中":-968,"主":-861,"事":492,"人":-123,"会":978,"保":362,"入":548,"初":-3025,"副":-1566,"北":-3414,"区":-422,"大":-1769,"天":-865,"太":-483,"子":-1519,"学":760,"実":1023,"小":-2009,"市":-813,"年":-1060,"強":1067,"手":-1519,"揺":-1033,"政":1522,"文":-1355,"新":-1682,"日":-1815,"明":-1462,"最":-630,"朝":-1843,"本":-1650,"東":-931,"果":-665,"次":-2378,"民":-180,"気":-1740,"理":752,"発":529,"目":-1584,"相":-242,"県":-1165,"立":-763,"第":810,"米":509,"自":-1353,"行":838,"西":-744,"見":-3874,"調":1010,"議":1198,"込":3041,"開":1758,"間":-1257,"「":-645,"」":3145,"ッ":831,"ア":-587,"カ":306,"キ":568};
|
||||||
|
this.UW3__ = {",":4889,"1":-800,"−":-1723,"、":4889,"々":-2311,"〇":5827,"」":2670,"〓":-3573,"あ":-2696,"い":1006,"う":2342,"え":1983,"お":-4864,"か":-1163,"が":3271,"く":1004,"け":388,"げ":401,"こ":-3552,"ご":-3116,"さ":-1058,"し":-395,"す":584,"せ":3685,"そ":-5228,"た":842,"ち":-521,"っ":-1444,"つ":-1081,"て":6167,"で":2318,"と":1691,"ど":-899,"な":-2788,"に":2745,"の":4056,"は":4555,"ひ":-2171,"ふ":-1798,"へ":1199,"ほ":-5516,"ま":-4384,"み":-120,"め":1205,"も":2323,"や":-788,"よ":-202,"ら":727,"り":649,"る":5905,"れ":2773,"わ":-1207,"を":6620,"ん":-518,"ア":551,"グ":1319,"ス":874,"ッ":-1350,"ト":521,"ム":1109,"ル":1591,"ロ":2201,"ン":278,"・":-3794,"一":-1619,"下":-1759,"世":-2087,"両":3815,"中":653,"主":-758,"予":-1193,"二":974,"人":2742,"今":792,"他":1889,"以":-1368,"低":811,"何":4265,"作":-361,"保":-2439,"元":4858,"党":3593,"全":1574,"公":-3030,"六":755,"共":-1880,"円":5807,"再":3095,"分":457,"初":2475,"別":1129,"前":2286,"副":4437,"力":365,"動":-949,"務":-1872,"化":1327,"北":-1038,"区":4646,"千":-2309,"午":-783,"協":-1006,"口":483,"右":1233,"各":3588,"合":-241,"同":3906,"和":-837,"員":4513,"国":642,"型":1389,"場":1219,"外":-241,"妻":2016,"学":-1356,"安":-423,"実":-1008,"家":1078,"小":-513,"少":-3102,"州":1155,"市":3197,"平":-1804,"年":2416,"広":-1030,"府":1605,"度":1452,"建":-2352,"当":-3885,"得":1905,"思":-1291,"性":1822,"戸":-488,"指":-3973,"政":-2013,"教":-1479,"数":3222,"文":-1489,"新":1764,"日":2099,"旧":5792,"昨":-661,"時":-1248,"曜":-951,"最":-937,"月":4125,"期":360,"李":3094,"村":364,"東":-805,"核":5156,"森":2438,"業":484,"氏":2613,"民":-1694,"決":-1073,"法":1868,"海":-495,"無":979,"物":461,"特":-3850,"生":-273,"用":914,"町":1215,"的":7313,"直":-1835,"省":792,"県":6293,"知":-1528,"私":4231,"税":401,"立":-960,"第":1201,"米":7767,"系":3066,"約":3663,"級":1384,"統":-4229,"総":1163,"線":1255,"者":6457,"能":725,"自":-2869,"英":785,"見":1044,"調":-562,"財":-733,"費":1777,"車":1835,"軍":1375,"込":-1504,"通":-1136,"選":-681,"郎":1026,"郡":4404,"部":1200,"金":2163,"長":421,"開":-1432,"間":1302,"関":-1282,"雨":2009,"電":-1045,"非":2066,"駅":1620,"1":-800,"」":2670,"・":-3794,"ッ":-1350,"ア":551,"グ":1319,"ス":874,"ト":521,"ム":1109,"ル":1591,"ロ":2201,"ン":278};
|
||||||
|
this.UW4__ = {",":3930,".":3508,"―":-4841,"、":3930,"。":3508,"〇":4999,"「":1895,"」":3798,"〓":-5156,"あ":4752,"い":-3435,"う":-640,"え":-2514,"お":2405,"か":530,"が":6006,"き":-4482,"ぎ":-3821,"く":-3788,"け":-4376,"げ":-4734,"こ":2255,"ご":1979,"さ":2864,"し":-843,"じ":-2506,"す":-731,"ず":1251,"せ":181,"そ":4091,"た":5034,"だ":5408,"ち":-3654,"っ":-5882,"つ":-1659,"て":3994,"で":7410,"と":4547,"な":5433,"に":6499,"ぬ":1853,"ね":1413,"の":7396,"は":8578,"ば":1940,"ひ":4249,"び":-4134,"ふ":1345,"へ":6665,"べ":-744,"ほ":1464,"ま":1051,"み":-2082,"む":-882,"め":-5046,"も":4169,"ゃ":-2666,"や":2795,"ょ":-1544,"よ":3351,"ら":-2922,"り":-9726,"る":-14896,"れ":-2613,"ろ":-4570,"わ":-1783,"を":13150,"ん":-2352,"カ":2145,"コ":1789,"セ":1287,"ッ":-724,"ト":-403,"メ":-1635,"ラ":-881,"リ":-541,"ル":-856,"ン":-3637,"・":-4371,"ー":-11870,"一":-2069,"中":2210,"予":782,"事":-190,"井":-1768,"人":1036,"以":544,"会":950,"体":-1286,"作":530,"側":4292,"先":601,"党":-2006,"共":-1212,"内":584,"円":788,"初":1347,"前":1623,"副":3879,"力":-302,"動":-740,"務":-2715,"化":776,"区":4517,"協":1013,"参":1555,"合":-1834,"和":-681,"員":-910,"器":-851,"回":1500,"国":-619,"園":-1200,"地":866,"場":-1410,"塁":-2094,"士":-1413,"多":1067,"大":571,"子":-4802,"学":-1397,"定":-1057,"寺":-809,"小":1910,"屋":-1328,"山":-1500,"島":-2056,"川":-2667,"市":2771,"年":374,"庁":-4556,"後":456,"性":553,"感":916,"所":-1566,"支":856,"改":787,"政":2182,"教":704,"文":522,"方":-856,"日":1798,"時":1829,"最":845,"月":-9066,"木":-485,"来":-442,"校":-360,"業":-1043,"氏":5388,"民":-2716,"気":-910,"沢":-939,"済":-543,"物":-735,"率":672,"球":-1267,"生":-1286,"産":-1101,"田":-2900,"町":1826,"的":2586,"目":922,"省":-3485,"県":2997,"空":-867,"立":-2112,"第":788,"米":2937,"系":786,"約":2171,"経":1146,"統":-1169,"総":940,"線":-994,"署":749,"者":2145,"能":-730,"般":-852,"行":-792,"規":792,"警":-1184,"議":-244,"谷":-1000,"賞":730,"車":-1481,"軍":1158,"輪":-1433,"込":-3370,"近":929,"道":-1291,"選":2596,"郎":-4866,"都":1192,"野":-1100,"銀":-2213,"長":357,"間":-2344,"院":-2297,"際":-2604,"電":-878,"領":-1659,"題":-792,"館":-1984,"首":1749,"高":2120,"「":1895,"」":3798,"・":-4371,"ッ":-724,"ー":-11870,"カ":2145,"コ":1789,"セ":1287,"ト":-403,"メ":-1635,"ラ":-881,"リ":-541,"ル":-856,"ン":-3637};
|
||||||
|
this.UW5__ = {",":465,".":-299,"1":-514,"E2":-32768,"]":-2762,"、":465,"。":-299,"「":363,"あ":1655,"い":331,"う":-503,"え":1199,"お":527,"か":647,"が":-421,"き":1624,"ぎ":1971,"く":312,"げ":-983,"さ":-1537,"し":-1371,"す":-852,"だ":-1186,"ち":1093,"っ":52,"つ":921,"て":-18,"で":-850,"と":-127,"ど":1682,"な":-787,"に":-1224,"の":-635,"は":-578,"べ":1001,"み":502,"め":865,"ゃ":3350,"ょ":854,"り":-208,"る":429,"れ":504,"わ":419,"を":-1264,"ん":327,"イ":241,"ル":451,"ン":-343,"中":-871,"京":722,"会":-1153,"党":-654,"務":3519,"区":-901,"告":848,"員":2104,"大":-1296,"学":-548,"定":1785,"嵐":-1304,"市":-2991,"席":921,"年":1763,"思":872,"所":-814,"挙":1618,"新":-1682,"日":218,"月":-4353,"査":932,"格":1356,"機":-1508,"氏":-1347,"田":240,"町":-3912,"的":-3149,"相":1319,"省":-1052,"県":-4003,"研":-997,"社":-278,"空":-813,"統":1955,"者":-2233,"表":663,"語":-1073,"議":1219,"選":-1018,"郎":-368,"長":786,"間":1191,"題":2368,"館":-689,"1":-514,"E2":-32768,"「":363,"イ":241,"ル":451,"ン":-343};
|
||||||
|
this.UW6__ = {",":227,".":808,"1":-270,"E1":306,"、":227,"。":808,"あ":-307,"う":189,"か":241,"が":-73,"く":-121,"こ":-200,"じ":1782,"す":383,"た":-428,"っ":573,"て":-1014,"で":101,"と":-105,"な":-253,"に":-149,"の":-417,"は":-236,"も":-206,"り":187,"る":-135,"を":195,"ル":-673,"ン":-496,"一":-277,"中":201,"件":-800,"会":624,"前":302,"区":1792,"員":-1212,"委":798,"学":-960,"市":887,"広":-695,"後":535,"業":-697,"相":753,"社":-507,"福":974,"空":-822,"者":1811,"連":463,"郎":1082,"1":-270,"E1":306,"ル":-673,"ン":-496};
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
TinySegmenter.prototype.ctype_ = function(str) {
|
||||||
|
for (var i in this.chartype_) {
|
||||||
|
if (str.match(this.chartype_[i][0])) {
|
||||||
|
return this.chartype_[i][1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "O";
|
||||||
|
}
|
||||||
|
|
||||||
|
TinySegmenter.prototype.ts_ = function(v) {
|
||||||
|
if (v) { return v; }
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
TinySegmenter.prototype.segment = function(input) {
|
||||||
|
if (input == null || input == undefined || input == "") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
var result = [];
|
||||||
|
var seg = ["B3","B2","B1"];
|
||||||
|
var ctype = ["O","O","O"];
|
||||||
|
var o = input.split("");
|
||||||
|
for (i = 0; i < o.length; ++i) {
|
||||||
|
seg.push(o[i]);
|
||||||
|
ctype.push(this.ctype_(o[i]))
|
||||||
|
}
|
||||||
|
seg.push("E1");
|
||||||
|
seg.push("E2");
|
||||||
|
seg.push("E3");
|
||||||
|
ctype.push("O");
|
||||||
|
ctype.push("O");
|
||||||
|
ctype.push("O");
|
||||||
|
var word = seg[3];
|
||||||
|
var p1 = "U";
|
||||||
|
var p2 = "U";
|
||||||
|
var p3 = "U";
|
||||||
|
for (var i = 4; i < seg.length - 3; ++i) {
|
||||||
|
var score = this.BIAS__;
|
||||||
|
var w1 = seg[i-3];
|
||||||
|
var w2 = seg[i-2];
|
||||||
|
var w3 = seg[i-1];
|
||||||
|
var w4 = seg[i];
|
||||||
|
var w5 = seg[i+1];
|
||||||
|
var w6 = seg[i+2];
|
||||||
|
var c1 = ctype[i-3];
|
||||||
|
var c2 = ctype[i-2];
|
||||||
|
var c3 = ctype[i-1];
|
||||||
|
var c4 = ctype[i];
|
||||||
|
var c5 = ctype[i+1];
|
||||||
|
var c6 = ctype[i+2];
|
||||||
|
score += this.ts_(this.UP1__[p1]);
|
||||||
|
score += this.ts_(this.UP2__[p2]);
|
||||||
|
score += this.ts_(this.UP3__[p3]);
|
||||||
|
score += this.ts_(this.BP1__[p1 + p2]);
|
||||||
|
score += this.ts_(this.BP2__[p2 + p3]);
|
||||||
|
score += this.ts_(this.UW1__[w1]);
|
||||||
|
score += this.ts_(this.UW2__[w2]);
|
||||||
|
score += this.ts_(this.UW3__[w3]);
|
||||||
|
score += this.ts_(this.UW4__[w4]);
|
||||||
|
score += this.ts_(this.UW5__[w5]);
|
||||||
|
score += this.ts_(this.UW6__[w6]);
|
||||||
|
score += this.ts_(this.BW1__[w2 + w3]);
|
||||||
|
score += this.ts_(this.BW2__[w3 + w4]);
|
||||||
|
score += this.ts_(this.BW3__[w4 + w5]);
|
||||||
|
score += this.ts_(this.TW1__[w1 + w2 + w3]);
|
||||||
|
score += this.ts_(this.TW2__[w2 + w3 + w4]);
|
||||||
|
score += this.ts_(this.TW3__[w3 + w4 + w5]);
|
||||||
|
score += this.ts_(this.TW4__[w4 + w5 + w6]);
|
||||||
|
score += this.ts_(this.UC1__[c1]);
|
||||||
|
score += this.ts_(this.UC2__[c2]);
|
||||||
|
score += this.ts_(this.UC3__[c3]);
|
||||||
|
score += this.ts_(this.UC4__[c4]);
|
||||||
|
score += this.ts_(this.UC5__[c5]);
|
||||||
|
score += this.ts_(this.UC6__[c6]);
|
||||||
|
score += this.ts_(this.BC1__[c2 + c3]);
|
||||||
|
score += this.ts_(this.BC2__[c3 + c4]);
|
||||||
|
score += this.ts_(this.BC3__[c4 + c5]);
|
||||||
|
score += this.ts_(this.TC1__[c1 + c2 + c3]);
|
||||||
|
score += this.ts_(this.TC2__[c2 + c3 + c4]);
|
||||||
|
score += this.ts_(this.TC3__[c3 + c4 + c5]);
|
||||||
|
score += this.ts_(this.TC4__[c4 + c5 + c6]);
|
||||||
|
// score += this.ts_(this.TC5__[c4 + c5 + c6]);
|
||||||
|
score += this.ts_(this.UQ1__[p1 + c1]);
|
||||||
|
score += this.ts_(this.UQ2__[p2 + c2]);
|
||||||
|
score += this.ts_(this.UQ3__[p3 + c3]);
|
||||||
|
score += this.ts_(this.BQ1__[p2 + c2 + c3]);
|
||||||
|
score += this.ts_(this.BQ2__[p2 + c3 + c4]);
|
||||||
|
score += this.ts_(this.BQ3__[p3 + c2 + c3]);
|
||||||
|
score += this.ts_(this.BQ4__[p3 + c3 + c4]);
|
||||||
|
score += this.ts_(this.TQ1__[p2 + c1 + c2 + c3]);
|
||||||
|
score += this.ts_(this.TQ2__[p2 + c2 + c3 + c4]);
|
||||||
|
score += this.ts_(this.TQ3__[p3 + c1 + c2 + c3]);
|
||||||
|
score += this.ts_(this.TQ4__[p3 + c2 + c3 + c4]);
|
||||||
|
var p = "O";
|
||||||
|
if (score > 0) {
|
||||||
|
result.push(word);
|
||||||
|
word = "";
|
||||||
|
p = "B";
|
||||||
|
}
|
||||||
|
p1 = p2;
|
||||||
|
p2 = p3;
|
||||||
|
p3 = p;
|
||||||
|
word += seg[i];
|
||||||
|
}
|
||||||
|
result.push(word);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
lunr.TinySegmenter = TinySegmenter;
|
||||||
|
};
|
||||||
|
|
||||||
|
}));
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["src/templates/assets/stylesheets/palette/_scheme.scss","../../../../src/templates/assets/stylesheets/palette.scss","src/templates/assets/stylesheets/palette/_accent.scss","src/templates/assets/stylesheets/palette/_primary.scss","src/templates/assets/stylesheets/utilities/_break.scss"],"names":[],"mappings":"AA2BA,cAGE,6BAME,sDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CACA,mDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CAGA,mDAAA,CACA,gDAAA,CACA,yDAAA,CACA,4DAAA,CAGA,0BAAA,CACA,mCAAA,CAGA,iCAAA,CACA,kCAAA,CACA,mCAAA,CACA,mCAAA,CACA,kCAAA,CACA,iCAAA,CACA,+CAAA,CACA,6DAAA,CACA,gEAAA,CACA,4DAAA,CACA,4DAAA,CACA,6DAAA,CAGA,6CAAA,CAGA,+CAAA,CAGA,uDAAA,CACA,6DAAA,CACA,2DAAA,CAGA,iCAAA,CAGA,yDAAA,CACA,iEAAA,CAGA,mDAAA,CACA,mDAAA,CAGA,qDAAA,CACA,uDAAA,CAGA,8DAAA,CAKA,8DAAA,CAKA,0DAAA,CAzEA,iBCiBF,CD6DE,kHAEE,YC3DJ,CDkFE,yDACE,4BChFJ,CD+EE,2DACE,4BC7EJ,CD4EE,gEACE,4BC1EJ,CDyEE,2DACE,4BCvEJ,CDsEE,yDACE,4BCpEJ,CDmEE,0DACE,4BCjEJ,CDgEE,gEACE,4BC9DJ,CD6DE,0DACE,4BC3DJ,CD0DE,2OACE,4BC/CJ,CDsDA,+FAGE,iCCpDF,CACF,CCjDE,2BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD6CN,CCvDE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDoDN,CC9DE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD2DN,CCrEE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDkEN,CC5EE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDyEN,CCnFE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDgFN,CC1FE,kCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDuFN,CCjGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD8FN,CCxGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDqGN,CC/GE,6BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD4GN,CCtHE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDmHN,CC7HE,4BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD6HN,CCpIE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDoIN,CC3IE,6BACE,yBAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD2IN,CClJE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDkJN,CCzJE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDsJN,CE3JE,4BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwJN,CEnKE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgKN,CE3KE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwKN,CEnLE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgLN,CE3LE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwLN,CEnME,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgMN,CE3ME,mCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwMN,CEnNE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgNN,CE3NE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwNN,CEnOE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgON,CE3OE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwON,CEnPE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFmPN,CE3PE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCF2PN,CEnQE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFmQN,CE3QE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCF2QN,CEnRE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgRN,CE3RE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwRN,CEnSE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BF4RN,CE5SE,kCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BFqSN,CEtRE,sEACE,4BFyRJ,CE1RE,+DACE,4BF6RJ,CE9RE,iEACE,4BFiSJ,CElSE,gEACE,4BFqSJ,CEtSE,iEACE,4BFySJ,CEhSA,8BACE,mDAAA,CACA,4DAAA,CACA,0DAAA,CACA,oDAAA,CACA,2DAAA,CAGA,4BFiSF,CE9RE,yCACE,+BFgSJ,CE7RI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCFiSN,CG7MI,mCD1EA,+CACE,8CF0RJ,CEvRI,qDACE,8CFyRN,CEpRE,iEACE,mCFsRJ,CACF,CGxNI,sCDvDA,uCACE,oCFkRJ,CACF,CEzQA,8BACE,kDAAA,CACA,4DAAA,CACA,wDAAA,CACA,oDAAA,CACA,6DAAA,CAGA,4BF0QF,CEvQE,yCACE,+BFyQJ,CEtQI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCF0QN,CEnQE,yCACE,6CFqQJ,CG9NI,0CDhCA,8CACE,gDFiQJ,CACF,CGnOI,0CDvBA,iFACE,6CF6PJ,CACF,CG3PI,sCDKA,uCACE,6CFyPJ,CACF","file":"palette.css"}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
PORTAINER_URL=Your_Portainer_Server_Adress (optional)
|
|
||||||
PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
|
|
||||||
SUPERUSER_USERNAME=Your_Superuser_Username (optional)
|
|
||||||
SUPERUSER_EMAIL=Your_Superuser_Email (optional)
|
|
||||||
SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
|
|
||||||
SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
import { db } from '../db/index.js';
|
|
||||||
|
|
||||||
export const SUPERUSER_GROUP_NAME = 'superuser';
|
|
||||||
|
|
||||||
export const AVATAR_COLORS = [
|
|
||||||
'bg-arcticBlue-600',
|
|
||||||
'bg-copperRust-500',
|
|
||||||
'bg-sunsetCoral-600',
|
|
||||||
'bg-mintTea-400',
|
|
||||||
'bg-lavenderSmoke-500',
|
|
||||||
'bg-emeraldMist-500',
|
|
||||||
'bg-roseQuartz-500',
|
|
||||||
'bg-auroraTeal-500',
|
|
||||||
'bg-citrusPunch-500',
|
|
||||||
'bg-mossGreen-400'
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_AVATAR_COLOR = 'bg-mossGreen-500';
|
|
||||||
const AVATAR_COLOR_SET = new Set([...AVATAR_COLORS, DEFAULT_AVATAR_COLOR]);
|
|
||||||
|
|
||||||
export const pickRandomAvatarColor = () => {
|
|
||||||
if (!Array.isArray(AVATAR_COLORS) || AVATAR_COLORS.length === 0) {
|
|
||||||
return DEFAULT_AVATAR_COLOR;
|
|
||||||
}
|
|
||||||
const index = Math.floor(Math.random() * AVATAR_COLORS.length);
|
|
||||||
return AVATAR_COLORS[index] ?? DEFAULT_AVATAR_COLOR;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizeAvatarColor = (value) => {
|
|
||||||
if (!value) return null;
|
|
||||||
const candidate = String(value).trim();
|
|
||||||
return AVATAR_COLOR_SET.has(candidate) ? candidate : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectGroupByName = db.prepare('SELECT * FROM user_groups WHERE name = ?');
|
|
||||||
const insertGroup = db.prepare('INSERT INTO user_groups (name, description) VALUES (?, ?)');
|
|
||||||
|
|
||||||
const selectSuperuser = db.prepare(`
|
|
||||||
SELECT u.*
|
|
||||||
FROM users u
|
|
||||||
INNER JOIN user_group_memberships m ON m.user_id = u.id
|
|
||||||
INNER JOIN user_groups g ON g.id = m.group_id
|
|
||||||
WHERE g.name = ?
|
|
||||||
LIMIT 1
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
|
|
||||||
const selectUserByEmail = db.prepare('SELECT * FROM users WHERE email = ?');
|
|
||||||
const selectUserById = db.prepare('SELECT * FROM users WHERE id = ?');
|
|
||||||
const updateLastLogin = db.prepare(`
|
|
||||||
UPDATE users
|
|
||||||
SET last_login = CURRENT_TIMESTAMP,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const insertUser = db.prepare(`
|
|
||||||
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const updateUser = db.prepare(`
|
|
||||||
UPDATE users
|
|
||||||
SET username = ?, email = ?, password_hash = ?, password_salt = ?, is_active = 1, avatar_color = COALESCE(?, avatar_color), updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const insertMembership = db.prepare(`
|
|
||||||
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
|
|
||||||
VALUES (?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const removeMembershipsForGroup = db.prepare(`
|
|
||||||
DELETE FROM user_group_memberships
|
|
||||||
WHERE group_id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectUserIdsForGroup = db.prepare(`
|
|
||||||
SELECT u.id
|
|
||||||
FROM users u
|
|
||||||
INNER JOIN user_group_memberships m ON m.user_id = u.id
|
|
||||||
WHERE m.group_id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const deleteUserById = db.prepare('DELETE FROM users WHERE id = ?');
|
|
||||||
const deleteGroupById = db.prepare('DELETE FROM user_groups WHERE id = ?');
|
|
||||||
|
|
||||||
const countSuperusers = db.prepare(`
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM user_group_memberships m
|
|
||||||
INNER JOIN user_groups g ON g.id = m.group_id
|
|
||||||
WHERE g.name = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const creationTransaction = db.transaction(({ username, email, passwordHash, passwordSalt, groupId, avatarColor }) => {
|
|
||||||
const existingUser = selectUserByUsername.get(username) || selectUserByEmail.get(email);
|
|
||||||
|
|
||||||
let userId;
|
|
||||||
if (existingUser) {
|
|
||||||
const existingColor = normalizeAvatarColor(existingUser.avatar_color);
|
|
||||||
const normalizedNewColor = normalizeAvatarColor(avatarColor);
|
|
||||||
const colorToPersist = existingColor || normalizedNewColor || DEFAULT_AVATAR_COLOR;
|
|
||||||
updateUser.run(username, email, passwordHash, passwordSalt, colorToPersist, existingUser.id);
|
|
||||||
userId = existingUser.id;
|
|
||||||
} else {
|
|
||||||
const colorToPersist = normalizeAvatarColor(avatarColor) || DEFAULT_AVATAR_COLOR;
|
|
||||||
const result = insertUser.run(username, email, passwordHash, passwordSalt, colorToPersist);
|
|
||||||
userId = result.lastInsertRowid;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeMembershipsForGroup.run(groupId);
|
|
||||||
insertMembership.run(userId, groupId);
|
|
||||||
|
|
||||||
return selectUserById.get(userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
export function hashPassword(password) {
|
|
||||||
if (!password || typeof password !== 'string') {
|
|
||||||
const err = new Error('INVALID_PASSWORD');
|
|
||||||
err.code = 'INVALID_PASSWORD';
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const trimmed = password.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
const err = new Error('INVALID_PASSWORD');
|
|
||||||
err.code = 'INVALID_PASSWORD';
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (trimmed.length < 8) {
|
|
||||||
const err = new Error('PASSWORD_TOO_SHORT');
|
|
||||||
err.code = 'PASSWORD_TOO_SHORT';
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const salt = crypto.randomBytes(16).toString('hex');
|
|
||||||
const hash = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
|
|
||||||
return { salt, hash };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyPassword(password, hash, salt) {
|
|
||||||
if (!hash || !salt) return false;
|
|
||||||
if (!password || typeof password !== 'string') return false;
|
|
||||||
const trimmed = password.trim();
|
|
||||||
if (!trimmed) return false;
|
|
||||||
|
|
||||||
const derived = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
|
|
||||||
const storedBuffer = Buffer.from(hash, 'hex');
|
|
||||||
const derivedBuffer = Buffer.from(derived, 'hex');
|
|
||||||
if (storedBuffer.length !== derivedBuffer.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return crypto.timingSafeEqual(storedBuffer, derivedBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureGroup(name, description = null) {
|
|
||||||
let group = selectGroupByName.get(name);
|
|
||||||
if (group) return group;
|
|
||||||
insertGroup.run(name, description);
|
|
||||||
group = selectGroupByName.get(name);
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasSuperuser() {
|
|
||||||
const { count } = countSuperusers.get(SUPERUSER_GROUP_NAME);
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findUserByIdentifier(identifier) {
|
|
||||||
const value = String(identifier || '').trim();
|
|
||||||
if (!value) return null;
|
|
||||||
if (value.includes('@')) {
|
|
||||||
return selectUserByEmail.get(value.toLowerCase());
|
|
||||||
}
|
|
||||||
return selectUserByUsername.get(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findUserById(id) {
|
|
||||||
if (!id) return null;
|
|
||||||
return selectUserById.get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function markUserLogin(userId) {
|
|
||||||
if (!userId) return;
|
|
||||||
updateLastLogin.run(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOrUpdateSuperuser({ username, email, password }) {
|
|
||||||
const normalizedUsername = String(username || '').trim();
|
|
||||||
const normalizedEmail = String(email || '').trim().toLowerCase();
|
|
||||||
|
|
||||||
if (!normalizedUsername) {
|
|
||||||
throw new Error('USERNAME_REQUIRED');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizedEmail || !normalizedEmail.includes('@')) {
|
|
||||||
throw new Error('EMAIL_INVALID');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { hash, salt } = hashPassword(password);
|
|
||||||
const group = ensureGroup(SUPERUSER_GROUP_NAME, 'System Superuser');
|
|
||||||
const avatarColor = pickRandomAvatarColor();
|
|
||||||
|
|
||||||
const user = creationTransaction({
|
|
||||||
username: normalizedUsername,
|
|
||||||
email: normalizedEmail,
|
|
||||||
passwordHash: hash,
|
|
||||||
passwordSalt: salt,
|
|
||||||
groupId: group.id,
|
|
||||||
avatarColor
|
|
||||||
});
|
|
||||||
|
|
||||||
const persistedColor = normalizeAvatarColor(user.avatar_color) || avatarColor || DEFAULT_AVATAR_COLOR;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
avatarColor: persistedColor
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletionTransaction = db.transaction((groupId) => {
|
|
||||||
const members = selectUserIdsForGroup.all(groupId);
|
|
||||||
|
|
||||||
members.forEach(({ id }) => {
|
|
||||||
deleteUserById.run(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { changes: groupChanges } = deleteGroupById.run(groupId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
usersRemoved: members.length,
|
|
||||||
groupRemoved: groupChanges > 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ensureSuperuserFromEnv() {
|
|
||||||
const username = process.env.SUPERUSER_USERNAME;
|
|
||||||
const email = process.env.SUPERUSER_EMAIL;
|
|
||||||
const password = process.env.SUPERUSER_PASSWORD;
|
|
||||||
|
|
||||||
if (hasSuperuser()) {
|
|
||||||
if (username || email || password) {
|
|
||||||
console.log('ℹ️ Superuser existiert bereits - Umgebungsvariablen werden ignoriert.');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!username || !email || !password) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
createOrUpdateSuperuser({ username, email, password });
|
|
||||||
console.log('✅ Superuser aus Umgebungsvariablen angelegt.');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('⚠️ Superuser konnte nicht aus Umgebungsvariablen erzeugt werden:', error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerSuperuser({ username, email, password }) {
|
|
||||||
if (hasSuperuser()) {
|
|
||||||
const err = new Error('SUPERUSER_ALREADY_EXISTS');
|
|
||||||
err.code = 'SUPERUSER_ALREADY_EXISTS';
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return createOrUpdateSuperuser({ username, email, password });
|
|
||||||
} catch (error) {
|
|
||||||
if (error.message === 'USERNAME_REQUIRED' || error.message === 'EMAIL_INVALID' || error.message === 'PASSWORD_TOO_SHORT') {
|
|
||||||
error.code = error.message;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeSuperuser() {
|
|
||||||
const group = selectGroupByName.get(SUPERUSER_GROUP_NAME);
|
|
||||||
|
|
||||||
if (!group) {
|
|
||||||
const error = new Error('SUPERUSER_NOT_FOUND');
|
|
||||||
error.code = 'SUPERUSER_NOT_FOUND';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletionTransaction(group.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSuperuserSummary() {
|
|
||||||
const user = selectSuperuser.get(SUPERUSER_GROUP_NAME);
|
|
||||||
if (!user) return null;
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
stackpulse.db
|
|
||||||
-245
@@ -1,245 +0,0 @@
|
|||||||
-- Stackpulse database schema blueprint
|
|
||||||
-- This document captures the expected structure of all tables, indexes and core seed requirements.
|
|
||||||
|
|
||||||
CREATE TABLE event_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
category TEXT NOT NULL,
|
|
||||||
event_type TEXT,
|
|
||||||
action TEXT,
|
|
||||||
status TEXT,
|
|
||||||
severity TEXT,
|
|
||||||
entity_type TEXT,
|
|
||||||
entity_id TEXT,
|
|
||||||
entity_name TEXT,
|
|
||||||
actor_type TEXT,
|
|
||||||
actor_id TEXT,
|
|
||||||
actor_name TEXT,
|
|
||||||
source TEXT,
|
|
||||||
context_type TEXT,
|
|
||||||
context_id TEXT,
|
|
||||||
context_label TEXT,
|
|
||||||
message TEXT,
|
|
||||||
metadata TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_event_logs_timestamp ON event_logs (timestamp DESC);
|
|
||||||
CREATE INDEX idx_event_logs_category ON event_logs (category);
|
|
||||||
CREATE INDEX idx_event_logs_event_type ON event_logs (event_type);
|
|
||||||
CREATE INDEX idx_event_logs_entity ON event_logs (entity_type, entity_id);
|
|
||||||
CREATE INDEX idx_event_logs_context ON event_logs (context_type, context_id);
|
|
||||||
|
|
||||||
CREATE TABLE settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT,
|
|
||||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
email TEXT UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
password_salt TEXT,
|
|
||||||
avatar_color TEXT,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
last_login DATETIME,
|
|
||||||
security_phrase_content TEXT,
|
|
||||||
security_phrase_iv TEXT,
|
|
||||||
security_phrase_tag TEXT,
|
|
||||||
security_phrase_downloaded_at DATETIME,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_groups (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
description TEXT,
|
|
||||||
avatar_color TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_group_memberships (
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
group_id INTEGER NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (user_id, group_id),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_server_assignments (
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
server_id INTEGER NOT NULL,
|
|
||||||
group_id INTEGER,
|
|
||||||
use_global_group INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (user_id, server_id),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_user_server_assignments_user ON user_server_assignments (user_id);
|
|
||||||
CREATE INDEX idx_user_server_assignments_server ON user_server_assignments (server_id);
|
|
||||||
|
|
||||||
CREATE TABLE permissions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
key TEXT NOT NULL UNIQUE,
|
|
||||||
description TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_group_permissions (
|
|
||||||
group_id INTEGER NOT NULL,
|
|
||||||
permission_id INTEGER NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (group_id, permission_id),
|
|
||||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE permission_sections (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
key TEXT NOT NULL UNIQUE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
has_navigation_flag INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE permission_groups (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
section_id INTEGER NOT NULL,
|
|
||||||
parent_group_id INTEGER,
|
|
||||||
key TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(section_id, key),
|
|
||||||
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (parent_group_id) REFERENCES permission_groups(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE permission_items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
section_id INTEGER NOT NULL,
|
|
||||||
group_id INTEGER,
|
|
||||||
key TEXT NOT NULL UNIQUE,
|
|
||||||
label TEXT NOT NULL,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
default_level TEXT NOT NULL,
|
|
||||||
available_levels TEXT NOT NULL,
|
|
||||||
is_global_scope INTEGER NOT NULL DEFAULT 1,
|
|
||||||
is_required INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE permission_dependencies (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
permission_id INTEGER NOT NULL,
|
|
||||||
depends_on_permission_id INTEGER NOT NULL,
|
|
||||||
required_level TEXT DEFAULT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (depends_on_permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(permission_id, depends_on_permission_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_permission_dependencies_required
|
|
||||||
ON permission_dependencies (permission_id, depends_on_permission_id);
|
|
||||||
|
|
||||||
CREATE TABLE group_permission_values (
|
|
||||||
group_id INTEGER NOT NULL,
|
|
||||||
permission_id INTEGER NOT NULL,
|
|
||||||
level TEXT NOT NULL,
|
|
||||||
effective_level TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (group_id, permission_id),
|
|
||||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE servers (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL UNIQUE,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_server_permission_overrides (
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
server_id INTEGER NOT NULL,
|
|
||||||
permission_id INTEGER NOT NULL,
|
|
||||||
change_type TEXT NOT NULL CHECK (change_type IN ('ADD', 'REMOVE')),
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE (user_id, server_id, permission_id),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_user_server_permission_overrides_user_server
|
|
||||||
ON user_server_permission_overrides (user_id, server_id);
|
|
||||||
|
|
||||||
CREATE TABLE server_api_keys (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_id INTEGER NOT NULL UNIQUE,
|
|
||||||
key_cipher TEXT NOT NULL,
|
|
||||||
key_iv TEXT NOT NULL,
|
|
||||||
key_tag TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE server_ssh_configs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_id INTEGER NOT NULL UNIQUE,
|
|
||||||
host TEXT NOT NULL,
|
|
||||||
port INTEGER NOT NULL DEFAULT 22,
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
password_cipher TEXT,
|
|
||||||
password_iv TEXT,
|
|
||||||
password_tag TEXT,
|
|
||||||
extra_args TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_server_ssh_configs_server ON server_ssh_configs (server_id);
|
|
||||||
|
|
||||||
CREATE TABLE server_update_scripts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_id INTEGER NOT NULL UNIQUE,
|
|
||||||
custom_script TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_server_update_scripts_server ON server_update_scripts (server_id);
|
|
||||||
|
|
||||||
CREATE TABLE user_settings (
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
setting_key TEXT NOT NULL,
|
|
||||||
setting_value TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (user_id, setting_key),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Core seed expectations (keys only – maintained via schemaEnsure)
|
|
||||||
-- permission_sections: stacks, logs, users, user-groups, maintenance
|
|
||||||
-- permission_items: includes users-security-phrase (available_levels ["full","none"])
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { ensureDatabaseSchema } from './schemaEnsure.js';
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
try {
|
|
||||||
ensureDatabaseSchema();
|
|
||||||
console.log('✅ Datenbankschema abgeglichen.');
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('⚠️ Schema-Abgleich fehlgeschlagen:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import Database from 'better-sqlite3';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
const dataDir = path.join(__dirname, '..', 'data');
|
|
||||||
const dbFile = path.join(dataDir, 'stackpulse.db');
|
|
||||||
|
|
||||||
if (!fs.existsSync(dataDir)) {
|
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new Database(dbFile);
|
|
||||||
db.pragma('journal_mode = WAL');
|
|
||||||
export { db, dbFile };
|
|
||||||
@@ -1,772 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { db } from './index.js';
|
|
||||||
|
|
||||||
const BLUEPRINT_FILENAME = 'dbs';
|
|
||||||
const PERMISSION_BLUEPRINT = [
|
|
||||||
{
|
|
||||||
key: 'stacks',
|
|
||||||
title: 'Stacks',
|
|
||||||
sortOrder: 0,
|
|
||||||
hasNavigation: 0,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'stacks-redeploy-single',
|
|
||||||
label: 'Redeploy einzeln',
|
|
||||||
sortOrder: 0,
|
|
||||||
global: false,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'stacks-redeploy-selection',
|
|
||||||
label: 'Redeploy Auswahl',
|
|
||||||
sortOrder: 1,
|
|
||||||
global: false,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'stacks-redeploy-all',
|
|
||||||
label: 'Redeploy Alle',
|
|
||||||
sortOrder: 2,
|
|
||||||
global: false,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'logs',
|
|
||||||
title: 'Logs',
|
|
||||||
sortOrder: 1,
|
|
||||||
hasNavigation: 1,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'logs-access',
|
|
||||||
label: 'Bereich & Navigation',
|
|
||||||
sortOrder: 0,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
isRequired: 1,
|
|
||||||
dependencies: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'logs-export',
|
|
||||||
label: 'Logs Exportieren',
|
|
||||||
sortOrder: 1,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'logs-delete',
|
|
||||||
label: 'Logs löschen',
|
|
||||||
sortOrder: 2,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'users',
|
|
||||||
title: 'Benutzer',
|
|
||||||
sortOrder: 2,
|
|
||||||
hasNavigation: 1,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'users-access',
|
|
||||||
label: 'Bereich & Navigation',
|
|
||||||
sortOrder: 0,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
isRequired: 1,
|
|
||||||
dependencies: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'users-edit',
|
|
||||||
label: 'Benutzer bearbeiten',
|
|
||||||
sortOrder: 1,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'read',
|
|
||||||
levels: ['full', 'read'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'users-delete',
|
|
||||||
label: 'Benutzer löschen',
|
|
||||||
sortOrder: 2,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'users-security-phrase',
|
|
||||||
label: 'Sicherheitsschlüssel',
|
|
||||||
sortOrder: 3,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'user-groups',
|
|
||||||
title: 'Benutzergruppen',
|
|
||||||
sortOrder: 3,
|
|
||||||
hasNavigation: 1,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'user-groups-access',
|
|
||||||
label: 'Bereich & Navigation',
|
|
||||||
sortOrder: 0,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
isRequired: 1,
|
|
||||||
dependencies: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'user-groups-edit',
|
|
||||||
label: 'Benutzergruppen bearbeiten',
|
|
||||||
sortOrder: 1,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'read',
|
|
||||||
levels: ['full', 'read'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'user-groups-delete',
|
|
||||||
label: 'Benutzergruppen löschen',
|
|
||||||
sortOrder: 2,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maintenance',
|
|
||||||
title: 'Wartung',
|
|
||||||
sortOrder: 4,
|
|
||||||
hasNavigation: 1,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'maintenance-access',
|
|
||||||
label: 'Bereich & Navigation',
|
|
||||||
sortOrder: 0,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
isRequired: 1,
|
|
||||||
dependencies: []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'maintenance-server-group',
|
|
||||||
title: 'Server',
|
|
||||||
sortOrder: 0,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'maintenance-server-manage',
|
|
||||||
label: 'Server-Sektion',
|
|
||||||
sortOrder: 0,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maintenance-server-edit',
|
|
||||||
label: 'Server bearbeiten',
|
|
||||||
sortOrder: 1,
|
|
||||||
global: false,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'read', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
|
||||||
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maintenance-server-delete',
|
|
||||||
label: 'Server löschen',
|
|
||||||
sortOrder: 2,
|
|
||||||
global: false,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
|
||||||
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: 'full' },
|
|
||||||
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '=full' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
key: 'maintenance-portainer-group',
|
|
||||||
title: 'Portainer',
|
|
||||||
sortOrder: 1,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'maintenance-ssh-update',
|
|
||||||
label: 'SSH/Update-Skript',
|
|
||||||
sortOrder: 0,
|
|
||||||
global: false,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'read', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
|
||||||
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maintenance-update',
|
|
||||||
label: 'Update durchführen',
|
|
||||||
sortOrder: 1,
|
|
||||||
global: false,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
|
||||||
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maintenance-duplicates-group',
|
|
||||||
title: 'Doppelte Stacks',
|
|
||||||
sortOrder: 2,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'maintenance-duplicates',
|
|
||||||
label: 'Doppelte Stacks',
|
|
||||||
sortOrder: 0,
|
|
||||||
global: false,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'read', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
|
|
||||||
{ dependsOnKey: 'maintenance-server-edit', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maintenance-superuser-group',
|
|
||||||
title: 'Superuser',
|
|
||||||
sortOrder: 1,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'maintenance-superuser-delete',
|
|
||||||
label: 'Superuser löschen',
|
|
||||||
sortOrder: 0,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'maintenance-mtls-group',
|
|
||||||
title: 'mTLS',
|
|
||||||
sortOrder: 2,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'maintenance-mtls',
|
|
||||||
label: 'mTLS Sektion',
|
|
||||||
sortOrder: 0,
|
|
||||||
global: true,
|
|
||||||
defaultLevel: 'none',
|
|
||||||
levels: ['full', 'none'],
|
|
||||||
dependencies: [
|
|
||||||
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const TABLE_REGEX = /^CREATE TABLE\s+([^\s(]+)\s*\(/i;
|
|
||||||
const INDEX_REGEX = /^CREATE INDEX\s+([^\s(]+)\s+ON\s+([^\s(]+)\s*\(/i;
|
|
||||||
const COLUMN_SKIP_PREFIXES = ['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK', 'CONSTRAINT'];
|
|
||||||
|
|
||||||
const normalizeIdentifier = (value) => value.replace(/`|"/g, '').trim();
|
|
||||||
|
|
||||||
const loadBlueprintStatements = () => {
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
const filePath = path.join(__dirname, BLUEPRINT_FILENAME);
|
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
|
||||||
const cleanContent = content
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line && !line.startsWith('--'))
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
return cleanContent
|
|
||||||
.split(';')
|
|
||||||
.map((statement) => statement.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseTableDefinitions = (statements) => {
|
|
||||||
const tables = new Map();
|
|
||||||
|
|
||||||
statements.forEach((statement) => {
|
|
||||||
const match = statement.match(TABLE_REGEX);
|
|
||||||
if (!match) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableName = normalizeIdentifier(match[1]);
|
|
||||||
const body = statement.slice(statement.indexOf('(') + 1, statement.lastIndexOf(')'));
|
|
||||||
const lines = body
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const columns = [];
|
|
||||||
lines.forEach((line) => {
|
|
||||||
const cleaned = line.replace(/,$/, '');
|
|
||||||
const upper = cleaned.toUpperCase();
|
|
||||||
if (COLUMN_SKIP_PREFIXES.some((prefix) => upper.startsWith(prefix))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const [rawName, ...restParts] = cleaned.split(/\s+/);
|
|
||||||
if (!rawName || restParts.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const columnName = normalizeIdentifier(rawName);
|
|
||||||
const definition = restParts.join(' ');
|
|
||||||
columns.push({ name: columnName, definition });
|
|
||||||
});
|
|
||||||
|
|
||||||
tables.set(tableName, {
|
|
||||||
createStatement: statement,
|
|
||||||
columns
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return tables;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseIndexStatements = (statements) => {
|
|
||||||
const indexes = [];
|
|
||||||
statements.forEach((statement) => {
|
|
||||||
const match = statement.match(INDEX_REGEX);
|
|
||||||
if (!match) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const indexName = normalizeIdentifier(match[1]);
|
|
||||||
indexes.push({
|
|
||||||
name: indexName,
|
|
||||||
statement
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return indexes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tableExists = (tableName) => {
|
|
||||||
const result = db
|
|
||||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`)
|
|
||||||
.get(tableName);
|
|
||||||
return Boolean(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropDeprecatedArtifacts = () => {
|
|
||||||
const deprecatedTables = [
|
|
||||||
'user_endpoint_permission_overrides',
|
|
||||||
'endpoints',
|
|
||||||
'server_agents',
|
|
||||||
'agent_tls_material',
|
|
||||||
'agent_bootstrap_tokens'
|
|
||||||
];
|
|
||||||
deprecatedTables.forEach((table) => {
|
|
||||||
if (tableExists(table)) {
|
|
||||||
db.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
||||||
console.log(`ℹ️ Veraltete Tabelle ${table} entfernt`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExistingColumns = (tableName) => {
|
|
||||||
const pragma = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
|
||||||
return new Set(pragma.map((column) => column.name));
|
|
||||||
};
|
|
||||||
|
|
||||||
const indexExists = (indexName) => {
|
|
||||||
const result = db
|
|
||||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1`)
|
|
||||||
.get(indexName);
|
|
||||||
return Boolean(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureTablesAndColumns = (tables) => {
|
|
||||||
tables.forEach(({ createStatement, columns }, tableName) => {
|
|
||||||
if (!tableExists(tableName)) {
|
|
||||||
const statementWithGuard = createStatement.replace(/^CREATE TABLE/i, 'CREATE TABLE IF NOT EXISTS');
|
|
||||||
db.exec(`${statementWithGuard};`);
|
|
||||||
console.log(`ℹ️ Tabelle ${tableName} angelegt`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingColumns = getExistingColumns(tableName);
|
|
||||||
const missingColumns = columns.filter((column) => !existingColumns.has(column.name));
|
|
||||||
missingColumns.forEach((column) => {
|
|
||||||
try {
|
|
||||||
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.definition};`);
|
|
||||||
console.log(`ℹ️ Column ${column.name} added to ${tableName}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`⚠️ Column ${column.name} could not be added to ${tableName}: ${error.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureIndexes = (indexes) => {
|
|
||||||
indexes.forEach(({ name, statement }) => {
|
|
||||||
if (indexExists(name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const statementWithGuard = statement.replace(/^CREATE INDEX/i, 'CREATE INDEX IF NOT EXISTS');
|
|
||||||
db.exec(`${statementWithGuard};`);
|
|
||||||
console.log(`ℹ️ Index ${name} angelegt`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensurePermissionSeeds = () => {
|
|
||||||
const selectSection = db.prepare(`
|
|
||||||
SELECT id, title, sort_order, has_navigation_flag
|
|
||||||
FROM permission_sections
|
|
||||||
WHERE key = ?
|
|
||||||
LIMIT 1
|
|
||||||
`);
|
|
||||||
const insertSection = db.prepare(`
|
|
||||||
INSERT INTO permission_sections (key, title, sort_order, has_navigation_flag, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
||||||
`);
|
|
||||||
const updateSection = db.prepare(`
|
|
||||||
UPDATE permission_sections
|
|
||||||
SET title = ?, sort_order = ?, has_navigation_flag = ?, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectGroup = db.prepare(`
|
|
||||||
SELECT id, title, sort_order, parent_group_id
|
|
||||||
FROM permission_groups
|
|
||||||
WHERE section_id = ? AND key = ?
|
|
||||||
LIMIT 1
|
|
||||||
`);
|
|
||||||
const insertGroup = db.prepare(`
|
|
||||||
INSERT INTO permission_groups (section_id, parent_group_id, key, title, sort_order, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
||||||
`);
|
|
||||||
const updateGroup = db.prepare(`
|
|
||||||
UPDATE permission_groups
|
|
||||||
SET title = ?, sort_order = ?, parent_group_id = ?, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
const deleteUnusedGroups = db.prepare(`
|
|
||||||
DELETE FROM permission_groups
|
|
||||||
WHERE key NOT IN (SELECT value FROM json_each(?))
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectItem = db.prepare(`
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
section_id,
|
|
||||||
group_id,
|
|
||||||
label,
|
|
||||||
sort_order,
|
|
||||||
default_level,
|
|
||||||
available_levels,
|
|
||||||
is_global_scope,
|
|
||||||
is_required
|
|
||||||
FROM permission_items
|
|
||||||
WHERE key = ?
|
|
||||||
LIMIT 1
|
|
||||||
`);
|
|
||||||
const insertItem = db.prepare(`
|
|
||||||
INSERT INTO permission_items (
|
|
||||||
section_id,
|
|
||||||
group_id,
|
|
||||||
key,
|
|
||||||
label,
|
|
||||||
sort_order,
|
|
||||||
default_level,
|
|
||||||
available_levels,
|
|
||||||
is_global_scope,
|
|
||||||
is_required,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
const updateItem = db.prepare(`
|
|
||||||
UPDATE permission_items
|
|
||||||
SET
|
|
||||||
section_id = ?,
|
|
||||||
group_id = ?,
|
|
||||||
label = ?,
|
|
||||||
sort_order = ?,
|
|
||||||
default_level = ?,
|
|
||||||
available_levels = ?,
|
|
||||||
is_global_scope = ?,
|
|
||||||
is_required = ?,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
const deleteUnusedItems = db.prepare(`
|
|
||||||
DELETE FROM permission_items
|
|
||||||
WHERE key NOT IN (SELECT value FROM json_each(?))
|
|
||||||
`);
|
|
||||||
const deleteOrphanDependencies = db.prepare(`
|
|
||||||
DELETE FROM permission_dependencies
|
|
||||||
WHERE permission_id NOT IN (SELECT id FROM permission_items)
|
|
||||||
OR depends_on_permission_id NOT IN (SELECT id FROM permission_items)
|
|
||||||
`);
|
|
||||||
const deleteOrphanPermissionValues = db.prepare(`
|
|
||||||
DELETE FROM group_permission_values
|
|
||||||
WHERE permission_id NOT IN (SELECT id FROM permission_items)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectDependency = db.prepare(`
|
|
||||||
SELECT id, required_level
|
|
||||||
FROM permission_dependencies
|
|
||||||
WHERE permission_id = ? AND depends_on_permission_id = ?
|
|
||||||
LIMIT 1
|
|
||||||
`);
|
|
||||||
const insertDependency = db.prepare(`
|
|
||||||
INSERT INTO permission_dependencies (
|
|
||||||
permission_id,
|
|
||||||
depends_on_permission_id,
|
|
||||||
required_level,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
const updateDependency = db.prepare(`
|
|
||||||
UPDATE permission_dependencies
|
|
||||||
SET required_level = ?, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const itemKeyToId = new Map();
|
|
||||||
const dependencyQueue = [];
|
|
||||||
const itemKeys = new Set();
|
|
||||||
const groupKeys = new Set();
|
|
||||||
|
|
||||||
const upsertItem = ({ item, sectionId, groupId = null, sortOrder }) => {
|
|
||||||
const existingItem = selectItem.get(item.key);
|
|
||||||
const itemSortOrder = typeof sortOrder === 'number' ? sortOrder : 0;
|
|
||||||
const levelOptions = Array.isArray(item.levels) && item.levels.length
|
|
||||||
? item.levels
|
|
||||||
: ['full', 'read', 'none'];
|
|
||||||
const availableLevels = JSON.stringify(levelOptions);
|
|
||||||
const isRequired = item.isRequired ? 1 : 0;
|
|
||||||
const isGlobal = item.global === false ? 0 : 1;
|
|
||||||
const label = item.label || item.key || '';
|
|
||||||
|
|
||||||
if (!existingItem) {
|
|
||||||
const result = insertItem.run(
|
|
||||||
sectionId,
|
|
||||||
groupId,
|
|
||||||
item.key,
|
|
||||||
label,
|
|
||||||
itemSortOrder,
|
|
||||||
item.defaultLevel || 'none',
|
|
||||||
availableLevels,
|
|
||||||
isGlobal,
|
|
||||||
isRequired
|
|
||||||
);
|
|
||||||
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
|
|
||||||
console.log(`ℹ️ Berechtigung ${item.key} ${groupId ? `in Gruppe ${groupId}` : ''} angelegt`);
|
|
||||||
} else {
|
|
||||||
const hasChanges =
|
|
||||||
existingItem.section_id !== sectionId ||
|
|
||||||
existingItem.group_id !== groupId ||
|
|
||||||
existingItem.label !== label ||
|
|
||||||
(existingItem.sort_order ?? itemSortOrder) !== itemSortOrder ||
|
|
||||||
existingItem.default_level !== (item.defaultLevel || 'none') ||
|
|
||||||
existingItem.available_levels !== availableLevels ||
|
|
||||||
Number(existingItem.is_global_scope ?? 1) !== isGlobal ||
|
|
||||||
Number(existingItem.is_required) !== isRequired;
|
|
||||||
updateItem.run(
|
|
||||||
sectionId,
|
|
||||||
groupId,
|
|
||||||
label,
|
|
||||||
itemSortOrder,
|
|
||||||
item.defaultLevel || 'none',
|
|
||||||
availableLevels,
|
|
||||||
isGlobal,
|
|
||||||
isRequired,
|
|
||||||
existingItem.id
|
|
||||||
);
|
|
||||||
itemKeyToId.set(item.key, existingItem.id);
|
|
||||||
if (hasChanges) {
|
|
||||||
console.log(`ℹ️ Berechtigung ${item.key} aktualisiert`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
itemKeys.add(item.key);
|
|
||||||
dependencyQueue.push({ itemKey: item.key, dependencies: item.dependencies || [] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const processGroups = (groups, sectionId, parentGroupId = null) => {
|
|
||||||
const list = Array.isArray(groups) ? groups : [];
|
|
||||||
list.forEach((group, groupIdx) => {
|
|
||||||
const sortOrder = typeof group.sortOrder === 'number' ? group.sortOrder : groupIdx;
|
|
||||||
const existingGroup = selectGroup.get(sectionId, group.key);
|
|
||||||
let groupId;
|
|
||||||
if (!existingGroup) {
|
|
||||||
const result = insertGroup.run(sectionId, parentGroupId, group.key, group.title, sortOrder);
|
|
||||||
groupId = Number(result.lastInsertRowid);
|
|
||||||
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${sectionId} angelegt`);
|
|
||||||
} else {
|
|
||||||
const hasGroupChanges =
|
|
||||||
existingGroup.title !== group.title ||
|
|
||||||
(existingGroup.sort_order ?? sortOrder) !== sortOrder ||
|
|
||||||
existingGroup.parent_group_id !== parentGroupId;
|
|
||||||
updateGroup.run(group.title, sortOrder, parentGroupId, existingGroup.id);
|
|
||||||
groupId = existingGroup.id;
|
|
||||||
if (hasGroupChanges) {
|
|
||||||
console.log(`ℹ️ Berechtigungs-Gruppe ${group.key} in Sektion ${sectionId} aktualisiert`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
groupKeys.add(group.key);
|
|
||||||
|
|
||||||
const groupItems = Array.isArray(group.items) ? group.items : [];
|
|
||||||
groupItems.forEach((item, itemIdx) => {
|
|
||||||
upsertItem({ item, sectionId, groupId, sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (group.groups) {
|
|
||||||
processGroups(group.groups, sectionId, groupId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
PERMISSION_BLUEPRINT.forEach((section, sectionIndex) => {
|
|
||||||
const existingSection = selectSection.get(section.key);
|
|
||||||
let sectionId;
|
|
||||||
if (!existingSection) {
|
|
||||||
const result = insertSection.run(
|
|
||||||
section.key,
|
|
||||||
section.title,
|
|
||||||
section.sortOrder ?? sectionIndex,
|
|
||||||
section.hasNavigation ? 1 : 0
|
|
||||||
);
|
|
||||||
sectionId = Number(result.lastInsertRowid);
|
|
||||||
console.log(`ℹ️ Berechtigungs-Sektion ${section.key} angelegt`);
|
|
||||||
} else {
|
|
||||||
updateSection.run(
|
|
||||||
section.title,
|
|
||||||
section.sortOrder ?? sectionIndex,
|
|
||||||
section.hasNavigation ? 1 : 0,
|
|
||||||
existingSection.id
|
|
||||||
);
|
|
||||||
sectionId = existingSection.id;
|
|
||||||
if (
|
|
||||||
existingSection.title !== section.title ||
|
|
||||||
(existingSection.sort_order ?? sectionIndex) !== (section.sortOrder ?? sectionIndex) ||
|
|
||||||
Number(existingSection.has_navigation_flag) !== (section.hasNavigation ? 1 : 0)
|
|
||||||
) {
|
|
||||||
console.log(`ℹ️ Berechtigungs-Sektion ${section.key} aktualisiert`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
|
||||||
sectionItems.forEach((item, itemIdx) => {
|
|
||||||
upsertItem({ item, sectionId, groupId: null, sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx });
|
|
||||||
});
|
|
||||||
|
|
||||||
processGroups(section.groups, sectionId, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
dependencyQueue.forEach(({ itemKey, dependencies }) => {
|
|
||||||
const permissionId = itemKeyToId.get(itemKey);
|
|
||||||
if (!permissionId) return;
|
|
||||||
dependencies.forEach((dependency) => {
|
|
||||||
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
|
||||||
if (!dependsId) return;
|
|
||||||
const existing = selectDependency.get(permissionId, dependsId);
|
|
||||||
const requiredLevel = dependency.requiredLevel ?? null;
|
|
||||||
if (!existing) {
|
|
||||||
insertDependency.run(permissionId, dependsId, requiredLevel);
|
|
||||||
console.log(`ℹ️ Abhängigkeit ${itemKey} -> ${dependency.dependsOnKey} angelegt`);
|
|
||||||
} else if (existing.required_level !== requiredLevel) {
|
|
||||||
updateDependency.run(requiredLevel, existing.id);
|
|
||||||
console.log(`ℹ️ Abhängigkeit ${itemKey} -> ${dependency.dependsOnKey} aktualisiert`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup obsolete entries
|
|
||||||
if (itemKeys.size > 0) {
|
|
||||||
const itemKeyJson = JSON.stringify(Array.from(itemKeys));
|
|
||||||
deleteOrphanDependencies.run();
|
|
||||||
deleteOrphanPermissionValues.run();
|
|
||||||
deleteUnusedItems.run(itemKeyJson);
|
|
||||||
}
|
|
||||||
if (groupKeys.size > 0) {
|
|
||||||
const groupKeyJson = JSON.stringify(Array.from(groupKeys));
|
|
||||||
deleteUnusedGroups.run(groupKeyJson);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ensureDatabaseSchema = () => {
|
|
||||||
try {
|
|
||||||
const statements = loadBlueprintStatements();
|
|
||||||
const tableDefinitions = parseTableDefinitions(statements);
|
|
||||||
const indexDefinitions = parseIndexStatements(statements);
|
|
||||||
|
|
||||||
dropDeprecatedArtifacts();
|
|
||||||
ensureTablesAndColumns(tableDefinitions);
|
|
||||||
ensureIndexes(indexDefinitions);
|
|
||||||
ensurePermissionSeeds();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('⚠️ Schema-Abgleich fehlgeschlagen:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { db } from './index.js';
|
|
||||||
|
|
||||||
const getSettingStmt = db.prepare('SELECT key, value, updated_at FROM settings WHERE key = ?');
|
|
||||||
const setSettingStmt = db.prepare(`
|
|
||||||
INSERT INTO settings (key, value, updated_at)
|
|
||||||
VALUES (@key, @value, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT(key) DO UPDATE SET
|
|
||||||
value = excluded.value,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
`);
|
|
||||||
const deleteSettingStmt = db.prepare('DELETE FROM settings WHERE key = ?');
|
|
||||||
|
|
||||||
export function getSetting(key) {
|
|
||||||
try {
|
|
||||||
return getSettingStmt.get(key) || null;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`❌ [Settings] Fehler beim Lesen des Settings "${key}":`, err.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setSetting(key, value) {
|
|
||||||
try {
|
|
||||||
setSettingStmt.run({ key, value });
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`❌ [Settings] Fehler beim Speichern des Settings "${key}":`, err.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteSetting(key) {
|
|
||||||
try {
|
|
||||||
deleteSettingStmt.run(key);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`❌ [Settings] Fehler beim Löschen des Settings "${key}":`, err.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getJsonSetting(key, defaultValue = null) {
|
|
||||||
const row = getSetting(key);
|
|
||||||
if (!row || row.value === null || row.value === undefined) return defaultValue;
|
|
||||||
try {
|
|
||||||
return JSON.parse(row.value);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`⚠️ [Settings] Konnte JSON für Setting "${key}" nicht parsen:`, err.message);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setJsonSetting(key, value) {
|
|
||||||
try {
|
|
||||||
const payload = value === undefined ? null : JSON.stringify(value);
|
|
||||||
return setSetting(key, payload);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`❌ [Settings] Fehler beim Serialisieren von Setting "${key}":`, err.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🔧 Überprüfe Datenbankschema..."
|
|
||||||
node db/ensure.js
|
|
||||||
|
|
||||||
echo "✅ Schema-Abgleich abgeschlossen. Starte Anwendung..."
|
|
||||||
exec "$@"
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
import { db } from '../db/index.js';
|
|
||||||
import {
|
|
||||||
normalizeAvatarColor,
|
|
||||||
DEFAULT_AVATAR_COLOR,
|
|
||||||
SUPERUSER_GROUP_NAME,
|
|
||||||
pickRandomAvatarColor
|
|
||||||
} from '../auth/superuser.js';
|
|
||||||
import { logEvent } from '../logging/eventLogs.js';
|
|
||||||
|
|
||||||
const selectGroupsWithMembers = db.prepare(`
|
|
||||||
SELECT
|
|
||||||
g.id,
|
|
||||||
g.name,
|
|
||||||
g.description,
|
|
||||||
g.avatar_color,
|
|
||||||
g.created_at,
|
|
||||||
g.updated_at,
|
|
||||||
COUNT(DISTINCT u.id) AS member_count,
|
|
||||||
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
|
|
||||||
FROM user_groups g
|
|
||||||
LEFT JOIN user_group_memberships m ON m.group_id = g.id
|
|
||||||
LEFT JOIN users u ON u.id = m.user_id
|
|
||||||
GROUP BY g.id
|
|
||||||
ORDER BY g.name COLLATE NOCASE
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectGroupWithMembersById = db.prepare(`
|
|
||||||
SELECT
|
|
||||||
g.id,
|
|
||||||
g.name,
|
|
||||||
g.description,
|
|
||||||
g.avatar_color,
|
|
||||||
g.created_at,
|
|
||||||
g.updated_at,
|
|
||||||
COUNT(DISTINCT u.id) AS member_count,
|
|
||||||
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
|
|
||||||
FROM user_groups g
|
|
||||||
LEFT JOIN user_group_memberships m ON m.group_id = g.id
|
|
||||||
LEFT JOIN users u ON u.id = m.user_id
|
|
||||||
WHERE g.id = ?
|
|
||||||
GROUP BY g.id
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectGroupByIdBasic = db.prepare(`
|
|
||||||
SELECT id, name, description, avatar_color
|
|
||||||
FROM user_groups
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectGroupIdByName = db.prepare(`
|
|
||||||
SELECT id
|
|
||||||
FROM user_groups
|
|
||||||
WHERE lower(name) = lower(?)
|
|
||||||
LIMIT 1
|
|
||||||
`);
|
|
||||||
|
|
||||||
const insertGroupStatement = db.prepare(`
|
|
||||||
INSERT INTO user_groups (name, description, avatar_color, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const updateGroupStatement = db.prepare(`
|
|
||||||
UPDATE user_groups
|
|
||||||
SET name = ?,
|
|
||||||
description = ?,
|
|
||||||
avatar_color = ?,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const countGroupMembersStatement = db.prepare(`
|
|
||||||
SELECT COUNT(*) AS member_count
|
|
||||||
FROM user_group_memberships
|
|
||||||
WHERE group_id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const deleteGroupStatement = db.prepare(`
|
|
||||||
DELETE FROM user_groups
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const parseMembers = (rawPairs) => {
|
|
||||||
if (!rawPairs) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawPairs
|
|
||||||
.split('|||')
|
|
||||||
.map((entry) => {
|
|
||||||
const [idPart, usernamePart] = entry.split('::');
|
|
||||||
const numericId = Number(idPart);
|
|
||||||
return {
|
|
||||||
id: Number.isFinite(numericId) ? numericId : idPart,
|
|
||||||
username: (usernamePart || '').trim()
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((member) => member.username);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeGroup = (row) => ({
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
description: row.description || '',
|
|
||||||
avatarColor: row.avatar_color || null,
|
|
||||||
createdAt: row.created_at || null,
|
|
||||||
updatedAt: row.updated_at || null,
|
|
||||||
memberCount: Number(row.member_count) || 0,
|
|
||||||
members: parseMembers(row.member_pairs)
|
|
||||||
});
|
|
||||||
|
|
||||||
export function listGroups() {
|
|
||||||
const rows = selectGroupsWithMembers.all();
|
|
||||||
return rows.map(sanitizeGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGroupById(groupId) {
|
|
||||||
const numericId = Number(groupId);
|
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const row = selectGroupWithMembersById.get(numericId);
|
|
||||||
return row ? sanitizeGroup(row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGroup({ name, description }) {
|
|
||||||
const normalizedName = typeof name === 'string' ? name.trim() : '';
|
|
||||||
if (!normalizedName) {
|
|
||||||
const error = new Error('GROUP_NAME_REQUIRED');
|
|
||||||
error.code = 'GROUP_NAME_REQUIRED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = selectGroupIdByName.get(normalizedName);
|
|
||||||
if (existing) {
|
|
||||||
const error = new Error('GROUP_NAME_TAKEN');
|
|
||||||
error.code = 'GROUP_NAME_TAKEN';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedDescription = typeof description === 'string' ? description.trim() : null;
|
|
||||||
const avatarColor = pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
|
||||||
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null, avatarColor);
|
|
||||||
const groupId = Number(insertResult.lastInsertRowid);
|
|
||||||
const row = selectGroupWithMembersById.get(groupId);
|
|
||||||
const group = row ? sanitizeGroup(row) : null;
|
|
||||||
|
|
||||||
logEvent({
|
|
||||||
category: 'benutzergruppe',
|
|
||||||
eventType: 'gruppe-angelegt',
|
|
||||||
action: 'anlegen',
|
|
||||||
status: 'erfolgreich',
|
|
||||||
entityType: 'gruppe',
|
|
||||||
entityId: String(groupId),
|
|
||||||
entityName: group?.name ?? normalizedName,
|
|
||||||
message: `Benutzergruppe "${normalizedName}" angelegt`,
|
|
||||||
metadata: {
|
|
||||||
description: group?.description ?? normalizedDescription ?? null,
|
|
||||||
avatarColor
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateGroupDetails(groupId, { name, description, avatarColor } = {}) {
|
|
||||||
const numericId = Number(groupId);
|
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
|
||||||
const error = new Error('INVALID_GROUP_ID');
|
|
||||||
error.code = 'INVALID_GROUP_ID';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingRow = selectGroupByIdBasic.get(numericId);
|
|
||||||
if (!existingRow) {
|
|
||||||
const error = new Error('GROUP_NOT_FOUND');
|
|
||||||
error.code = 'GROUP_NOT_FOUND';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedName = typeof name === 'string' ? name.trim() : existingRow.name || '';
|
|
||||||
if (!normalizedName) {
|
|
||||||
const error = new Error('GROUP_NAME_REQUIRED');
|
|
||||||
error.code = 'GROUP_NAME_REQUIRED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameRow = selectGroupIdByName.get(normalizedName);
|
|
||||||
if (nameRow && Number(nameRow.id) !== numericId) {
|
|
||||||
const error = new Error('GROUP_NAME_TAKEN');
|
|
||||||
error.code = 'GROUP_NAME_TAKEN';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalizedDescription;
|
|
||||||
if (description === undefined) {
|
|
||||||
normalizedDescription = existingRow.description ?? null;
|
|
||||||
} else if (description === null) {
|
|
||||||
normalizedDescription = null;
|
|
||||||
} else {
|
|
||||||
const trimmedDescription = typeof description === 'string' ? description.trim() : '';
|
|
||||||
normalizedDescription = trimmedDescription ? trimmedDescription : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
|
||||||
|
|
||||||
if (isSuperuserGroup) {
|
|
||||||
if (name !== undefined) {
|
|
||||||
const incomingName = typeof name === 'string' ? name.trim() : '';
|
|
||||||
if (incomingName && incomingName !== existingRow.name) {
|
|
||||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
|
||||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (description !== undefined) {
|
|
||||||
const existingDescriptionComparable = existingRow.description === null || existingRow.description === undefined
|
|
||||||
? ''
|
|
||||||
: String(existingRow.description).trim();
|
|
||||||
const incomingDescriptionComparable = description === null
|
|
||||||
? ''
|
|
||||||
: typeof description === 'string'
|
|
||||||
? description.trim()
|
|
||||||
: '';
|
|
||||||
|
|
||||||
if (incomingDescriptionComparable !== existingDescriptionComparable) {
|
|
||||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
|
||||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce original values for protected fields
|
|
||||||
normalizedDescription = existingRow.description ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let colorToPersist = existingRow.avatar_color || null;
|
|
||||||
if (avatarColor !== undefined) {
|
|
||||||
const candidate = String(avatarColor || '').trim();
|
|
||||||
if (!candidate) {
|
|
||||||
colorToPersist = DEFAULT_AVATAR_COLOR;
|
|
||||||
} else {
|
|
||||||
const normalized = normalizeAvatarColor(candidate);
|
|
||||||
if (!normalized) {
|
|
||||||
const error = new Error('INVALID_AVATAR_COLOR');
|
|
||||||
error.code = 'INVALID_AVATAR_COLOR';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
colorToPersist = normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateGroupStatement.run(
|
|
||||||
isSuperuserGroup ? existingRow.name : normalizedName,
|
|
||||||
normalizedDescription,
|
|
||||||
colorToPersist,
|
|
||||||
numericId
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedGroup = getGroupById(numericId);
|
|
||||||
|
|
||||||
logEvent({
|
|
||||||
category: 'benutzergruppe',
|
|
||||||
eventType: 'gruppe-aktualisiert',
|
|
||||||
action: 'aktualisieren',
|
|
||||||
status: 'erfolgreich',
|
|
||||||
entityType: 'gruppe',
|
|
||||||
entityId: String(numericId),
|
|
||||||
entityName: updatedGroup?.name ?? normalizedName,
|
|
||||||
message: `Benutzergruppe "${updatedGroup?.name ?? normalizedName}" aktualisiert`,
|
|
||||||
metadata: {
|
|
||||||
previous: {
|
|
||||||
name: existingRow.name,
|
|
||||||
description: existingRow.description ?? null,
|
|
||||||
avatarColor: existingRow.avatar_color ?? null
|
|
||||||
},
|
|
||||||
current: updatedGroup
|
|
||||||
? {
|
|
||||||
name: updatedGroup.name,
|
|
||||||
description: updatedGroup.description ?? null,
|
|
||||||
avatarColor: updatedGroup.avatarColor ?? null,
|
|
||||||
memberCount: updatedGroup.memberCount ?? 0
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
name: normalizedName,
|
|
||||||
description: normalizedDescription ?? null,
|
|
||||||
avatarColor: colorToPersist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteGroup(groupId) {
|
|
||||||
const numericId = Number(groupId);
|
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
|
||||||
const error = new Error('INVALID_GROUP_ID');
|
|
||||||
error.code = 'INVALID_GROUP_ID';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingRow = selectGroupByIdBasic.get(numericId);
|
|
||||||
if (!existingRow) {
|
|
||||||
const error = new Error('GROUP_NOT_FOUND');
|
|
||||||
error.code = 'GROUP_NOT_FOUND';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
|
||||||
if (isSuperuserGroup) {
|
|
||||||
const error = new Error('GROUP_SUPERUSER_PROTECTED');
|
|
||||||
error.code = 'GROUP_SUPERUSER_PROTECTED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { member_count: memberCount } = countGroupMembersStatement.get(numericId);
|
|
||||||
if (Number(memberCount) > 0) {
|
|
||||||
const error = new Error('GROUP_HAS_MEMBERS');
|
|
||||||
error.code = 'GROUP_HAS_MEMBERS';
|
|
||||||
error.memberCount = Number(memberCount);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteGroupStatement.run(numericId);
|
|
||||||
|
|
||||||
logEvent({
|
|
||||||
category: 'benutzergruppe',
|
|
||||||
eventType: 'gruppe-gelöscht',
|
|
||||||
action: 'löschen',
|
|
||||||
status: 'erfolgreich',
|
|
||||||
entityType: 'gruppe',
|
|
||||||
entityId: String(numericId),
|
|
||||||
entityName: existingRow.name ?? `ID ${numericId}`,
|
|
||||||
message: `Benutzergruppe "${existingRow.name ?? numericId}" gelöscht`,
|
|
||||||
metadata: {
|
|
||||||
description: existingRow.description ?? null,
|
|
||||||
avatarColor: existingRow.avatar_color ?? null,
|
|
||||||
memberCountBeforeDelete: Number(memberCount)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+1429
File diff suppressed because it is too large
Load Diff
-4826
File diff suppressed because it is too large
Load Diff
@@ -1,331 +0,0 @@
|
|||||||
import { db } from '../db/index.js';
|
|
||||||
|
|
||||||
const valueToArray = (value) => {
|
|
||||||
if (value === undefined || value === null) return [];
|
|
||||||
const base = Array.isArray(value) ? value : [value];
|
|
||||||
return base
|
|
||||||
.flatMap((entry) => String(entry).split(','))
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter((entry) => entry.length > 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const singleValue = (value) => {
|
|
||||||
if (value === undefined || value === null) return undefined;
|
|
||||||
return Array.isArray(value) ? value[0] : value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const serializeJson = (value) => {
|
|
||||||
if (value === undefined || value === null || value === '') return null;
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('⚠️ Konnte JSON nicht serialisieren:', err.message);
|
|
||||||
return JSON.stringify({ serializationError: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendInFilter = (filters, params, values, column, prefix) => {
|
|
||||||
if (!values.length) return;
|
|
||||||
const placeholders = values.map((_, idx) => {
|
|
||||||
const key = `${prefix}${idx}`;
|
|
||||||
params[key] = values[idx];
|
|
||||||
return `@${key}`;
|
|
||||||
});
|
|
||||||
filters.push(`${column} IN (${placeholders.join(', ')})`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildEventLogFilter(queryParams = {}) {
|
|
||||||
const filters = [];
|
|
||||||
const params = {};
|
|
||||||
|
|
||||||
const ids = valueToArray(queryParams.ids ?? queryParams.id)
|
|
||||||
.map((entry) => Number(entry))
|
|
||||||
.filter((value) => Number.isFinite(value));
|
|
||||||
if (ids.length) {
|
|
||||||
appendInFilter(filters, params, ids, 'id', 'id');
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories = valueToArray(queryParams.categories ?? queryParams.category);
|
|
||||||
appendInFilter(filters, params, categories, 'category', 'category');
|
|
||||||
|
|
||||||
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
|
|
||||||
appendInFilter(filters, params, statuses, 'status', 'status');
|
|
||||||
|
|
||||||
const actions = valueToArray(queryParams.actions ?? queryParams.action);
|
|
||||||
appendInFilter(filters, params, actions, 'action', 'action');
|
|
||||||
|
|
||||||
const eventTypes = [
|
|
||||||
...valueToArray(queryParams.eventTypes ?? queryParams.eventType),
|
|
||||||
...valueToArray(queryParams.redeployTypes ?? queryParams.redeployType)
|
|
||||||
];
|
|
||||||
if (eventTypes.length) {
|
|
||||||
const unique = Array.from(new Set(eventTypes));
|
|
||||||
appendInFilter(filters, params, unique, 'event_type', 'eventType');
|
|
||||||
}
|
|
||||||
|
|
||||||
const severities = valueToArray(queryParams.severities ?? queryParams.severity);
|
|
||||||
appendInFilter(filters, params, severities, 'severity', 'severity');
|
|
||||||
|
|
||||||
const entityTypes = valueToArray(queryParams.entityTypes ?? queryParams.entityType);
|
|
||||||
appendInFilter(filters, params, entityTypes, 'entity_type', 'entityType');
|
|
||||||
|
|
||||||
const entityIds = valueToArray(queryParams.entityIds ?? queryParams.entityId);
|
|
||||||
appendInFilter(filters, params, entityIds, 'entity_id', 'entityId');
|
|
||||||
|
|
||||||
const actorTypes = valueToArray(queryParams.actorTypes ?? queryParams.actorType);
|
|
||||||
appendInFilter(filters, params, actorTypes, 'actor_type', 'actorType');
|
|
||||||
|
|
||||||
const actorIds = valueToArray(queryParams.actorIds ?? queryParams.actorId);
|
|
||||||
appendInFilter(filters, params, actorIds, 'actor_id', 'actorId');
|
|
||||||
|
|
||||||
const sources = valueToArray(queryParams.sources ?? queryParams.source);
|
|
||||||
appendInFilter(filters, params, sources, 'source', 'source');
|
|
||||||
|
|
||||||
const contextTypes = valueToArray(queryParams.contextTypes ?? queryParams.contextType);
|
|
||||||
appendInFilter(filters, params, contextTypes, 'context_type', 'contextType');
|
|
||||||
|
|
||||||
const contextIds = valueToArray(queryParams.contextIds ?? queryParams.contextId);
|
|
||||||
appendInFilter(filters, params, contextIds, 'context_id', 'contextId');
|
|
||||||
|
|
||||||
const legacyStackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
|
|
||||||
if (legacyStackIds.length) {
|
|
||||||
const placeholders = legacyStackIds.map((_, idx) => {
|
|
||||||
const key = `legacyStackId${idx}`;
|
|
||||||
params[key] = legacyStackIds[idx];
|
|
||||||
return `@${key}`;
|
|
||||||
});
|
|
||||||
filters.push(`(entity_type = 'stack' AND entity_id IN (${placeholders.join(', ')}))`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverContexts = valueToArray(queryParams.servers ?? queryParams.server);
|
|
||||||
if (serverContexts.length) {
|
|
||||||
const placeholders = serverContexts.map((_, idx) => {
|
|
||||||
const key = `serverContext${idx}`;
|
|
||||||
params[key] = serverContexts[idx];
|
|
||||||
return `@${key}`;
|
|
||||||
});
|
|
||||||
filters.push(`(context_type = 'server' AND context_id IN (${placeholders.join(', ')}))`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageQuery = singleValue(queryParams.message ?? queryParams.text);
|
|
||||||
if (messageQuery && String(messageQuery).trim()) {
|
|
||||||
filters.push('message LIKE @message');
|
|
||||||
params.message = `%${String(messageQuery).trim()}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = singleValue(queryParams.search ?? queryParams.q);
|
|
||||||
if (search && String(search).trim()) {
|
|
||||||
filters.push('(message LIKE @search OR entity_name LIKE @search OR metadata LIKE @search)');
|
|
||||||
params.search = `%${String(search).trim()}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const from = singleValue(queryParams.from ?? queryParams.start);
|
|
||||||
if (from) {
|
|
||||||
filters.push('timestamp >= @from');
|
|
||||||
params.from = from;
|
|
||||||
}
|
|
||||||
|
|
||||||
const to = singleValue(queryParams.to ?? queryParams.end);
|
|
||||||
if (to) {
|
|
||||||
filters.push('timestamp <= @to');
|
|
||||||
params.to = to;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
|
|
||||||
params
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertEventLogStmt = db.prepare(`
|
|
||||||
INSERT INTO event_logs (
|
|
||||||
timestamp,
|
|
||||||
category,
|
|
||||||
event_type,
|
|
||||||
action,
|
|
||||||
status,
|
|
||||||
severity,
|
|
||||||
entity_type,
|
|
||||||
entity_id,
|
|
||||||
entity_name,
|
|
||||||
actor_type,
|
|
||||||
actor_id,
|
|
||||||
actor_name,
|
|
||||||
source,
|
|
||||||
context_type,
|
|
||||||
context_id,
|
|
||||||
context_label,
|
|
||||||
message,
|
|
||||||
metadata
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
COALESCE(@timestamp, CURRENT_TIMESTAMP),
|
|
||||||
@category,
|
|
||||||
@eventType,
|
|
||||||
@action,
|
|
||||||
@status,
|
|
||||||
@severity,
|
|
||||||
@entityType,
|
|
||||||
@entityId,
|
|
||||||
@entityName,
|
|
||||||
@actorType,
|
|
||||||
@actorId,
|
|
||||||
@actorName,
|
|
||||||
@source,
|
|
||||||
@contextType,
|
|
||||||
@contextId,
|
|
||||||
@contextLabel,
|
|
||||||
@message,
|
|
||||||
@metadata
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
export function logEvent(event = {}) {
|
|
||||||
if (!event.category) {
|
|
||||||
console.error('⚠️ logEvent wurde ohne Kategorie aufgerufen');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
insertEventLogStmt.run({
|
|
||||||
timestamp: event.timestamp ?? null,
|
|
||||||
category: event.category,
|
|
||||||
eventType: event.eventType ?? null,
|
|
||||||
action: event.action ?? null,
|
|
||||||
status: event.status ?? null,
|
|
||||||
severity: event.severity ?? null,
|
|
||||||
entityType: event.entityType ?? null,
|
|
||||||
entityId: event.entityId !== undefined && event.entityId !== null ? String(event.entityId) : null,
|
|
||||||
entityName: event.entityName ?? null,
|
|
||||||
actorType: event.actorType ?? null,
|
|
||||||
actorId: event.actorId !== undefined && event.actorId !== null ? String(event.actorId) : null,
|
|
||||||
actorName: event.actorName ?? null,
|
|
||||||
source: event.source ?? null,
|
|
||||||
contextType: event.contextType ?? null,
|
|
||||||
contextId: event.contextId !== undefined && event.contextId !== null ? String(event.contextId) : null,
|
|
||||||
contextLabel: event.contextLabel ?? null,
|
|
||||||
message: event.message ?? null,
|
|
||||||
metadata: serializeJson(event.metadata)
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ Fehler beim Speichern des Event-Logs:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteEventLogById(id) {
|
|
||||||
const stmt = db.prepare('DELETE FROM event_logs WHERE id = ?');
|
|
||||||
const info = stmt.run(id);
|
|
||||||
return info.changes || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteEventLogsByFilters(queryParams = {}) {
|
|
||||||
const { whereClause, params } = buildEventLogFilter(queryParams);
|
|
||||||
const stmt = db.prepare(`DELETE FROM event_logs ${whereClause}`);
|
|
||||||
const info = stmt.run(params);
|
|
||||||
return info.changes || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exportEventLogsByFilters(queryParams = {}, format = 'txt') {
|
|
||||||
const { whereClause, params } = buildEventLogFilter(queryParams);
|
|
||||||
const rows = db.prepare(`
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
timestamp,
|
|
||||||
category,
|
|
||||||
event_type AS eventType,
|
|
||||||
action,
|
|
||||||
status,
|
|
||||||
severity,
|
|
||||||
entity_type AS entityType,
|
|
||||||
entity_id AS entityId,
|
|
||||||
entity_name AS entityName,
|
|
||||||
actor_type AS actorType,
|
|
||||||
actor_id AS actorId,
|
|
||||||
actor_name AS actorName,
|
|
||||||
source,
|
|
||||||
context_type AS contextType,
|
|
||||||
context_id AS contextId,
|
|
||||||
context_label AS contextLabel,
|
|
||||||
message,
|
|
||||||
metadata
|
|
||||||
FROM event_logs
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY datetime(timestamp) DESC
|
|
||||||
`).all(params);
|
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
||||||
|
|
||||||
if (format === 'sql') {
|
|
||||||
const columns = [
|
|
||||||
'id',
|
|
||||||
'timestamp',
|
|
||||||
'category',
|
|
||||||
'event_type',
|
|
||||||
'action',
|
|
||||||
'status',
|
|
||||||
'severity',
|
|
||||||
'entity_type',
|
|
||||||
'entity_id',
|
|
||||||
'entity_name',
|
|
||||||
'actor_type',
|
|
||||||
'actor_id',
|
|
||||||
'actor_name',
|
|
||||||
'source',
|
|
||||||
'context_type',
|
|
||||||
'context_id',
|
|
||||||
'context_label',
|
|
||||||
'message',
|
|
||||||
'metadata'
|
|
||||||
];
|
|
||||||
|
|
||||||
const statements = rows.map((row) => {
|
|
||||||
const values = columns.map((column) => {
|
|
||||||
const key = {
|
|
||||||
event_type: 'eventType',
|
|
||||||
entity_type: 'entityType',
|
|
||||||
entity_id: 'entityId',
|
|
||||||
entity_name: 'entityName',
|
|
||||||
actor_type: 'actorType',
|
|
||||||
actor_id: 'actorId',
|
|
||||||
actor_name: 'actorName',
|
|
||||||
context_type: 'contextType',
|
|
||||||
context_id: 'contextId',
|
|
||||||
context_label: 'contextLabel'
|
|
||||||
}[column] ?? column;
|
|
||||||
const value = row[key];
|
|
||||||
if (value === null || value === undefined) return 'NULL';
|
|
||||||
return `'${String(value).replace(/'/g, "''")}'`;
|
|
||||||
});
|
|
||||||
return `INSERT INTO event_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
filename: `event-logs-${timestamp}.sql`,
|
|
||||||
contentType: 'application/sql; charset=utf-8',
|
|
||||||
content: statements.join('\n')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((row) => {
|
|
||||||
const parts = [
|
|
||||||
`[${row.id}]`,
|
|
||||||
row.timestamp,
|
|
||||||
`Kategorie: ${row.category}`,
|
|
||||||
row.eventType ? `Typ: ${row.eventType}` : null,
|
|
||||||
row.action ? `Aktion: ${row.action}` : null,
|
|
||||||
row.status ? `Status: ${row.status}` : null,
|
|
||||||
row.entityName ? `Entität: ${row.entityName}${row.entityId ? ` (#${row.entityId})` : ''}` : row.entityId ? `Entität-ID: ${row.entityId}` : null,
|
|
||||||
row.contextType ? `Kontext: ${row.contextType}${row.contextId ? ` (#${row.contextId}${row.contextLabel ? ` – ${row.contextLabel}` : ''})` : ''}` : null,
|
|
||||||
row.message ? `Nachricht: ${row.message}` : null,
|
|
||||||
row.metadata ? `Metadaten: ${row.metadata}` : null
|
|
||||||
].filter(Boolean);
|
|
||||||
return parts.join(' | ');
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
filename: `event-logs-${timestamp}.txt`,
|
|
||||||
contentType: 'text/plain; charset=utf-8',
|
|
||||||
content: lines.join('\n')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
|
|
||||||
import { logEvent } from '../logging/eventLogs.js';
|
|
||||||
|
|
||||||
const MAINTENANCE_KEY = 'maintenance_mode';
|
|
||||||
|
|
||||||
const DEFAULT_STATE = {
|
|
||||||
active: false,
|
|
||||||
message: null,
|
|
||||||
activatedAt: null,
|
|
||||||
updatedAt: null,
|
|
||||||
extra: null
|
|
||||||
};
|
|
||||||
|
|
||||||
let maintenanceState = loadState();
|
|
||||||
|
|
||||||
function loadState() {
|
|
||||||
const saved = getJsonSetting(MAINTENANCE_KEY, null);
|
|
||||||
if (!saved) {
|
|
||||||
const initial = { ...DEFAULT_STATE, updatedAt: new Date().toISOString() };
|
|
||||||
setJsonSetting(MAINTENANCE_KEY, initial);
|
|
||||||
return initial;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...DEFAULT_STATE,
|
|
||||||
...saved
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistState(state) {
|
|
||||||
maintenanceState = {
|
|
||||||
...DEFAULT_STATE,
|
|
||||||
...state,
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
setJsonSetting(MAINTENANCE_KEY, maintenanceState);
|
|
||||||
return maintenanceState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMaintenanceState() {
|
|
||||||
return { ...maintenanceState };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMaintenanceModeActive() {
|
|
||||||
return Boolean(maintenanceState?.active);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
|
|
||||||
logEvent({
|
|
||||||
category: 'wartung',
|
|
||||||
eventType: 'Wartungsmodus',
|
|
||||||
action: 'aktivieren',
|
|
||||||
status: 'gestartet',
|
|
||||||
entityType: 'system',
|
|
||||||
entityId: 'wartung',
|
|
||||||
entityName: 'StackPulse Wartung',
|
|
||||||
contextType: 'System',
|
|
||||||
contextId: 'System',
|
|
||||||
message: 'Wartungsmodus aktiviert',
|
|
||||||
source: 'system'
|
|
||||||
});
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
return persistState({
|
|
||||||
active: true,
|
|
||||||
message,
|
|
||||||
extra,
|
|
||||||
activatedAt: now
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deactivateMaintenanceMode({ message = null } = {}) {
|
|
||||||
logEvent({
|
|
||||||
category: 'wartung',
|
|
||||||
eventType: 'wartungsmodus',
|
|
||||||
action: 'deaktivieren',
|
|
||||||
status: 'erfolgreich',
|
|
||||||
entityType: 'system',
|
|
||||||
entityId: 'wartung',
|
|
||||||
entityName: 'StackPulse Wartung',
|
|
||||||
contextType: 'System',
|
|
||||||
contextId: 'System',
|
|
||||||
message: 'Wartungsmodus deaktiviert',
|
|
||||||
source: 'system'
|
|
||||||
});
|
|
||||||
return persistState({
|
|
||||||
active: false,
|
|
||||||
message,
|
|
||||||
extra: null,
|
|
||||||
activatedAt: null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Generated
-1956
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "backend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node index.js",
|
|
||||||
"migrate": "node db/ensure.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"axios": "^1.7.0",
|
|
||||||
"better-sqlite3": "^9.6.0",
|
|
||||||
"dockerode": "^4.0.7",
|
|
||||||
"dotenv": "^16.0.0",
|
|
||||||
"express": "^4.18.0",
|
|
||||||
"socket.io": "^4.8.1",
|
|
||||||
"@scure/bip39": "^2.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
import { db } from '../db/index.js';
|
|
||||||
|
|
||||||
const LEVEL_PRIORITY = {
|
|
||||||
none: 0,
|
|
||||||
read: 1,
|
|
||||||
full: 2
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectSections = db.prepare(`
|
|
||||||
SELECT id, key, title, sort_order, has_navigation_flag
|
|
||||||
FROM permission_sections
|
|
||||||
ORDER BY sort_order ASC, id ASC
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectGroups = db.prepare(`
|
|
||||||
SELECT id, section_id, parent_group_id, key, title, sort_order
|
|
||||||
FROM permission_groups
|
|
||||||
ORDER BY section_id ASC, sort_order ASC, id ASC
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectItems = db.prepare(`
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
section_id,
|
|
||||||
group_id,
|
|
||||||
key,
|
|
||||||
label,
|
|
||||||
sort_order,
|
|
||||||
default_level,
|
|
||||||
available_levels,
|
|
||||||
is_global_scope,
|
|
||||||
is_required
|
|
||||||
FROM permission_items
|
|
||||||
ORDER BY section_id ASC, COALESCE(group_id, 0) ASC, sort_order ASC, id ASC
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectDependencies = db.prepare(`
|
|
||||||
SELECT permission_id, depends_on_permission_id, required_level
|
|
||||||
FROM permission_dependencies
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectPermissionItemsForMap = db.prepare(`
|
|
||||||
SELECT id, key, available_levels, default_level, is_global_scope
|
|
||||||
FROM permission_items
|
|
||||||
`);
|
|
||||||
|
|
||||||
const selectGroupPermissionValues = db.prepare(`
|
|
||||||
SELECT gp.permission_id, gp.level, gp.effective_level, pi.key
|
|
||||||
FROM group_permission_values gp
|
|
||||||
JOIN permission_items pi ON pi.id = gp.permission_id
|
|
||||||
WHERE gp.group_id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const deleteGroupPermissionValuesStmt = db.prepare(`
|
|
||||||
DELETE FROM group_permission_values
|
|
||||||
WHERE group_id = ?
|
|
||||||
`);
|
|
||||||
|
|
||||||
const insertGroupPermissionValueStmt = db.prepare(`
|
|
||||||
INSERT INTO group_permission_values (group_id, permission_id, level, effective_level, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const parseAvailableLevels = (raw) => {
|
|
||||||
if (typeof raw !== 'string' || !raw.trim()) {
|
|
||||||
return ['full', 'read', 'none'];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('⚠️ Konnte available_levels nicht parsen:', error.message);
|
|
||||||
}
|
|
||||||
return ['full', 'read', 'none'];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLevelPriority = (level) => {
|
|
||||||
if (!level) return 0;
|
|
||||||
return LEVEL_PRIORITY[level] ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
let cachedPermissionItems = null;
|
|
||||||
let cachedServerScopedKeys = null;
|
|
||||||
let cachedScopeByKey = null;
|
|
||||||
|
|
||||||
const getPermissionItems = () => {
|
|
||||||
if (!cachedPermissionItems) {
|
|
||||||
cachedPermissionItems = selectPermissionItemsForMap.all();
|
|
||||||
}
|
|
||||||
return cachedPermissionItems;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetPermissionItemsCache = () => {
|
|
||||||
cachedPermissionItems = null;
|
|
||||||
cachedServerScopedKeys = null;
|
|
||||||
cachedScopeByKey = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getScopeCache = () => {
|
|
||||||
if (!cachedScopeByKey || !cachedServerScopedKeys) {
|
|
||||||
const items = getPermissionItems();
|
|
||||||
cachedScopeByKey = new Map();
|
|
||||||
cachedServerScopedKeys = new Set();
|
|
||||||
items.forEach((item) => {
|
|
||||||
const isGlobal = item.is_global_scope !== 0;
|
|
||||||
cachedScopeByKey.set(item.key, { isGlobal });
|
|
||||||
if (!isGlobal) {
|
|
||||||
cachedServerScopedKeys.add(item.key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { scopeByKey: cachedScopeByKey, serverScopedKeys: cachedServerScopedKeys };
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getPermissionStructure() {
|
|
||||||
const sections = selectSections.all();
|
|
||||||
const groups = selectGroups.all();
|
|
||||||
const items = selectItems.all();
|
|
||||||
const dependencies = selectDependencies.all();
|
|
||||||
|
|
||||||
const itemIdToKey = new Map(items.map((item) => [item.id, item.key]));
|
|
||||||
|
|
||||||
const dependenciesByItemId = new Map();
|
|
||||||
dependencies.forEach((dependency) => {
|
|
||||||
const list = dependenciesByItemId.get(dependency.permission_id) || [];
|
|
||||||
list.push(dependency);
|
|
||||||
dependenciesByItemId.set(dependency.permission_id, list);
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupsBySectionId = new Map();
|
|
||||||
const groupsByParentId = new Map();
|
|
||||||
const sortedGroups = [...groups].sort((a, b) => {
|
|
||||||
const sortDiff = (a.sort_order ?? 0) - (b.sort_order ?? 0);
|
|
||||||
if (sortDiff !== 0) return sortDiff;
|
|
||||||
return a.id - b.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
sortedGroups.forEach((group) => {
|
|
||||||
if (group.parent_group_id) {
|
|
||||||
const list = groupsByParentId.get(group.parent_group_id) || [];
|
|
||||||
list.push(group);
|
|
||||||
groupsByParentId.set(group.parent_group_id, list);
|
|
||||||
} else {
|
|
||||||
const list = groupsBySectionId.get(group.section_id) || [];
|
|
||||||
list.push(group);
|
|
||||||
groupsBySectionId.set(group.section_id, list);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const itemsBySectionId = new Map();
|
|
||||||
const itemsByGroupId = new Map();
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (item.group_id) {
|
|
||||||
const list = itemsByGroupId.get(item.group_id) || [];
|
|
||||||
list.push(item);
|
|
||||||
itemsByGroupId.set(item.group_id, list);
|
|
||||||
} else {
|
|
||||||
const list = itemsBySectionId.get(item.section_id) || [];
|
|
||||||
list.push(item);
|
|
||||||
itemsBySectionId.set(item.section_id, list);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatItem = (item) => {
|
|
||||||
const availableLevels = parseAvailableLevels(item.available_levels);
|
|
||||||
|
|
||||||
const dependencyEntries = dependenciesByItemId.get(item.id) || [];
|
|
||||||
const formattedDependencies = dependencyEntries
|
|
||||||
.map((dependency) => {
|
|
||||||
const dependsOnKey = itemIdToKey.get(dependency.depends_on_permission_id);
|
|
||||||
if (!dependsOnKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key: dependsOnKey,
|
|
||||||
requiredLevel: dependency.required_level || null
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: item.key,
|
|
||||||
label: item.label,
|
|
||||||
sortOrder: item.sort_order ?? 0,
|
|
||||||
defaultLevel: item.default_level || 'none',
|
|
||||||
availableLevels,
|
|
||||||
isRequired: Boolean(item.is_required),
|
|
||||||
isGlobal: item.is_global_scope !== 0,
|
|
||||||
dependencies: formattedDependencies
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildGroupTree = (group) => {
|
|
||||||
const children = groupsByParentId.get(group.id) || [];
|
|
||||||
return {
|
|
||||||
key: group.key,
|
|
||||||
title: group.title,
|
|
||||||
sortOrder: group.sort_order ?? 0,
|
|
||||||
items: (itemsByGroupId.get(group.id) || []).map(formatItem),
|
|
||||||
groups: children.map(buildGroupTree)
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const formattedSections = sections.map((section) => {
|
|
||||||
const sectionItems = (itemsBySectionId.get(section.id) || []).map(formatItem);
|
|
||||||
const rootGroups = groupsBySectionId.get(section.id) || [];
|
|
||||||
const sectionGroups = rootGroups.map(buildGroupTree);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: section.key,
|
|
||||||
title: section.title,
|
|
||||||
sortOrder: section.sort_order ?? 0,
|
|
||||||
hasNavigation: Boolean(section.has_navigation_flag),
|
|
||||||
items: sectionItems,
|
|
||||||
groups: sectionGroups
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return formattedSections;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPermissionValuesByGroup(groupId) {
|
|
||||||
const numericId = Number(groupId);
|
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = selectGroupPermissionValues.all(numericId);
|
|
||||||
const values = {};
|
|
||||||
rows.forEach((row) => {
|
|
||||||
if (row?.key && typeof row.level === 'string') {
|
|
||||||
values[row.key] = row.level;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearGroupPermissionValues(groupId) {
|
|
||||||
const numericId = Number(groupId);
|
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteGroupPermissionValuesStmt.run(numericId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveGroupPermissionValues(groupId, values = {}) {
|
|
||||||
const numericId = Number(groupId);
|
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
|
||||||
const error = new Error('INVALID_GROUP_ID');
|
|
||||||
error.code = 'INVALID_GROUP_ID';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values === null || typeof values !== 'object' || Array.isArray(values)) {
|
|
||||||
const error = new Error('PERMISSION_INVALID_PAYLOAD');
|
|
||||||
error.code = 'PERMISSION_INVALID_PAYLOAD';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = getPermissionItems();
|
|
||||||
const itemMap = new Map();
|
|
||||||
items.forEach((item) => {
|
|
||||||
itemMap.set(item.key, {
|
|
||||||
id: item.id,
|
|
||||||
key: item.key,
|
|
||||||
defaultLevel: item.default_level || 'none',
|
|
||||||
availableLevels: parseAvailableLevels(item.available_levels)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const entries = Object.entries(values);
|
|
||||||
const normalizedEntries = [];
|
|
||||||
|
|
||||||
entries.forEach(([key, rawValue]) => {
|
|
||||||
const item = itemMap.get(key);
|
|
||||||
if (!item) {
|
|
||||||
const error = new Error('PERMISSION_UNKNOWN_KEY');
|
|
||||||
error.code = 'PERMISSION_UNKNOWN_KEY';
|
|
||||||
error.permissionKey = key;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
let level = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue ?? '').trim();
|
|
||||||
if (!level) {
|
|
||||||
level = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.availableLevels.includes(level)) {
|
|
||||||
const error = new Error('PERMISSION_INVALID_LEVEL');
|
|
||||||
error.code = 'PERMISSION_INVALID_LEVEL';
|
|
||||||
error.permissionKey = key;
|
|
||||||
error.level = level;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedEntries.push({ item, level });
|
|
||||||
});
|
|
||||||
|
|
||||||
const getSubmittedLevel = (permissionKey) => {
|
|
||||||
const entry = normalizedEntries.find((entry) => entry.item.key === permissionKey);
|
|
||||||
if (entry) {
|
|
||||||
return entry.level;
|
|
||||||
}
|
|
||||||
const fallback = itemMap.get(permissionKey);
|
|
||||||
return fallback ? fallback.defaultLevel || 'none' : 'none';
|
|
||||||
};
|
|
||||||
|
|
||||||
const serverManageLevel = getSubmittedLevel('maintenance-server-manage');
|
|
||||||
const serverEditLevel = getSubmittedLevel('maintenance-server-edit');
|
|
||||||
const serverDeleteLevel = getSubmittedLevel('maintenance-server-delete');
|
|
||||||
|
|
||||||
// Enforce dependency: "server löschen" darf maximal so hoch sein wie manage/edit.
|
|
||||||
const deletePriority = getLevelPriority(serverDeleteLevel);
|
|
||||||
const allowedDeletePriority = Math.min(
|
|
||||||
getLevelPriority(serverManageLevel),
|
|
||||||
getLevelPriority(serverEditLevel)
|
|
||||||
);
|
|
||||||
if (deletePriority > allowedDeletePriority) {
|
|
||||||
const allowedLevelEntry = Object.entries(LEVEL_PRIORITY).find(([, priority]) => priority === allowedDeletePriority);
|
|
||||||
const allowedLevel = allowedLevelEntry ? allowedLevelEntry[0] : 'none';
|
|
||||||
const deleteEntry = normalizedEntries.find((entry) => entry.item.key === 'maintenance-server-delete');
|
|
||||||
if (deleteEntry) {
|
|
||||||
deleteEntry.level = allowedLevel;
|
|
||||||
} else {
|
|
||||||
normalizedEntries.push({
|
|
||||||
item: itemMap.get('maintenance-server-delete'),
|
|
||||||
level: allowedLevel
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runSave = db.transaction(() => {
|
|
||||||
deleteGroupPermissionValuesStmt.run(numericId);
|
|
||||||
normalizedEntries.forEach(({ item, level }) => {
|
|
||||||
if (level === (item.defaultLevel || 'none')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
insertGroupPermissionValueStmt.run(numericId, item.id, level, level);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
runSave();
|
|
||||||
resetPermissionItemsCache();
|
|
||||||
|
|
||||||
return getPermissionValuesByGroup(numericId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultPermissionMap() {
|
|
||||||
const items = getPermissionItems();
|
|
||||||
const defaults = {};
|
|
||||||
items.forEach((item) => {
|
|
||||||
defaults[item.key] = item.default_level || 'none';
|
|
||||||
});
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSuperuserPermissionMap() {
|
|
||||||
const items = getPermissionItems();
|
|
||||||
const result = {};
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
const available = parseAvailableLevels(item.available_levels);
|
|
||||||
let chosen = 'full';
|
|
||||||
if (!available.includes('full')) {
|
|
||||||
chosen = available.reduce(
|
|
||||||
(best, candidate) =>
|
|
||||||
getLevelPriority(candidate) > getLevelPriority(best) ? candidate : best,
|
|
||||||
'none'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
result[item.key] = chosen;
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServerScopedPermissionKeys() {
|
|
||||||
const { serverScopedKeys } = getScopeCache();
|
|
||||||
return Array.from(serverScopedKeys);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isServerScopedPermission(permissionKey) {
|
|
||||||
if (!permissionKey) return false;
|
|
||||||
const { serverScopedKeys, scopeByKey } = getScopeCache();
|
|
||||||
if (serverScopedKeys.has(permissionKey)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const scope = scopeByKey.get(permissionKey);
|
|
||||||
return scope ? scope.isGlobal === false : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyServerScopedOverride(basePermissions = {}, overridePermissions = {}) {
|
|
||||||
const merged = { ...basePermissions };
|
|
||||||
const { serverScopedKeys } = getScopeCache();
|
|
||||||
serverScopedKeys.forEach((key) => {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(overridePermissions, key)) {
|
|
||||||
merged[key] = overridePermissions[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEffectivePermissionsForGroup(groupId) {
|
|
||||||
const defaults = getDefaultPermissionMap();
|
|
||||||
const numericId = Number(groupId);
|
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overrides = getPermissionValuesByGroup(numericId);
|
|
||||||
const map = { ...defaults };
|
|
||||||
|
|
||||||
Object.keys(overrides).forEach((key) => {
|
|
||||||
const value = overrides[key];
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
map[key] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEffectivePermissionsForGroups(groupIds = []) {
|
|
||||||
const defaults = getDefaultPermissionMap();
|
|
||||||
const finalMap = { ...defaults };
|
|
||||||
|
|
||||||
const iterableIds = Array.isArray(groupIds) ? groupIds : [];
|
|
||||||
iterableIds
|
|
||||||
.map((value) => Number(value))
|
|
||||||
.filter((value) => Number.isFinite(value) && value > 0)
|
|
||||||
.forEach((groupId) => {
|
|
||||||
const map = getEffectivePermissionsForGroup(groupId);
|
|
||||||
Object.entries(map).forEach(([key, level]) => {
|
|
||||||
if (getLevelPriority(level) > getLevelPriority(finalMap[key] ?? 'none')) {
|
|
||||||
finalMap[key] = level;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return finalMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasRequiredPermission(permissions = {}, permissionKey, requiredLevel = 'full') {
|
|
||||||
if (!permissionKey) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (requiredLevel === 'none' || requiredLevel === null || requiredLevel === undefined) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const current = typeof permissions[permissionKey] === 'string' ? permissions[permissionKey] : 'none';
|
|
||||||
const required = typeof requiredLevel === 'string' ? requiredLevel : 'full';
|
|
||||||
return getLevelPriority(current) >= getLevelPriority(required);
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 156 KiB |
@@ -1,44 +0,0 @@
|
|||||||
<!--
|
|
||||||
=========================================================
|
|
||||||
* Material Tailwind Dashboard React - v2.1.0
|
|
||||||
=========================================================
|
|
||||||
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
|
|
||||||
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
|
|
||||||
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
|
|
||||||
* Coded by Creative Tim
|
|
||||||
=========================================================
|
|
||||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
-->
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/img/favicon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Material Tailwind Dashboard React | By Creative Tim</title>
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css"
|
|
||||||
integrity="sha512-xh6O/CkQoPOWDdYTDqeRdPCVd1SpvCA9XXcUnZS2FmJNp1coAFzvtCN9BmamE+4aHK8yyUHUSCcJHgXloTyT2A=="
|
|
||||||
crossorigin="anonymous"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
/>
|
|
||||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
|
||||||
|
|
||||||
<!-- Nepcha Analytics (nepcha.com) -->
|
|
||||||
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
|
|
||||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
|
||||||
|
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-c3010de9.js"></script>
|
|
||||||
<link rel="stylesheet" href="/assets/index-c7ee792b.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
import { db } from '../db/index.js';
|
|
||||||
import { hasSuperuser } from '../auth/superuser.js';
|
|
||||||
|
|
||||||
const selectAllServers = db.prepare('SELECT * FROM servers ORDER BY id ASC');
|
|
||||||
const selectServerById = db.prepare('SELECT * FROM servers WHERE id = ?');
|
|
||||||
const selectServerByUrl = db.prepare('SELECT * FROM servers WHERE url = ?');
|
|
||||||
const insertServer = db.prepare(`
|
|
||||||
INSERT INTO servers (name, url)
|
|
||||||
VALUES (?, ?)
|
|
||||||
`);
|
|
||||||
const updateServer = db.prepare(`
|
|
||||||
UPDATE servers
|
|
||||||
SET name = ?, url = ?, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
`);
|
|
||||||
const deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
|
|
||||||
|
|
||||||
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
|
|
||||||
const countApiKeysStmt = db.prepare('SELECT COUNT(*) as count FROM server_api_keys');
|
|
||||||
const selectAllApiKeys = db.prepare('SELECT server_id, created_at, updated_at FROM server_api_keys');
|
|
||||||
const upsertApiKey = db.prepare(`
|
|
||||||
INSERT INTO server_api_keys (server_id, key_cipher, key_iv, key_tag, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT(server_id) DO UPDATE SET
|
|
||||||
key_cipher = excluded.key_cipher,
|
|
||||||
key_iv = excluded.key_iv,
|
|
||||||
key_tag = excluded.key_tag,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
`);
|
|
||||||
const deleteApiKeyByServerId = db.prepare('DELETE FROM server_api_keys WHERE server_id = ?');
|
|
||||||
|
|
||||||
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
|
|
||||||
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
|
|
||||||
|
|
||||||
function listServers() {
|
|
||||||
return selectAllServers.all();
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_KEY_SECRET = crypto.createHash('sha256')
|
|
||||||
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
|
|
||||||
.digest();
|
|
||||||
|
|
||||||
function encryptApiKey(apiKey) {
|
|
||||||
if (!apiKey || typeof apiKey !== 'string') return null;
|
|
||||||
const trimmed = apiKey.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
const iv = crypto.randomBytes(12);
|
|
||||||
const cipher = crypto.createCipheriv('aes-256-gcm', API_KEY_SECRET, iv);
|
|
||||||
const encrypted = Buffer.concat([cipher.update(trimmed, 'utf8'), cipher.final()]);
|
|
||||||
const tag = cipher.getAuthTag();
|
|
||||||
return {
|
|
||||||
iv: iv.toString('base64'),
|
|
||||||
content: encrypted.toString('base64'),
|
|
||||||
tag: tag.toString('base64')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function decryptApiKey(row) {
|
|
||||||
if (!row) return '';
|
|
||||||
try {
|
|
||||||
const iv = Buffer.from(row.key_iv, 'base64');
|
|
||||||
const content = Buffer.from(row.key_cipher, 'base64');
|
|
||||||
const tag = Buffer.from(row.key_tag, 'base64');
|
|
||||||
const decipher = crypto.createDecipheriv('aes-256-gcm', API_KEY_SECRET, iv);
|
|
||||||
decipher.setAuthTag(tag);
|
|
||||||
const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
|
|
||||||
return decrypted.toString('utf8');
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('⚠️ [Setup] API-Key konnte nicht entschlüsselt werden:', error.message);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUrl(url) {
|
|
||||||
if (!url || typeof url !== 'string') return null;
|
|
||||||
const trimmed = url.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
try {
|
|
||||||
const hasProtocol = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
|
|
||||||
const candidate = hasProtocol ? trimmed : `https://${trimmed}`;
|
|
||||||
const normalized = new URL(candidate);
|
|
||||||
const pathname = normalized.pathname.replace(/\/+$/, '');
|
|
||||||
normalized.pathname = pathname || '/';
|
|
||||||
normalized.hash = '';
|
|
||||||
return normalized.toString().replace(/\/$/, '');
|
|
||||||
} catch (err) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveServerName(url) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return parsed.hostname || url;
|
|
||||||
} catch (err) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureServer({ name, url }) {
|
|
||||||
const normalizedUrl = normalizeUrl(url);
|
|
||||||
if (!normalizedUrl) {
|
|
||||||
const error = new Error('SERVER_URL_REQUIRED');
|
|
||||||
error.code = 'SERVER_URL_REQUIRED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const normalizedName = (name || deriveServerName(normalizedUrl)).trim();
|
|
||||||
if (!normalizedName) {
|
|
||||||
const error = new Error('SERVER_NAME_REQUIRED');
|
|
||||||
error.code = 'SERVER_NAME_REQUIRED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = selectServerByUrl.get(normalizedUrl);
|
|
||||||
if (existing) {
|
|
||||||
if (existing.name !== normalizedName) {
|
|
||||||
updateServer.run(normalizedName, normalizedUrl, existing.id);
|
|
||||||
const updated = selectServerById.get(existing.id);
|
|
||||||
console.log(`ℹ️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = insertServer.run(normalizedName, normalizedUrl);
|
|
||||||
const created = selectServerById.get(result.lastInsertRowid);
|
|
||||||
console.log(`✅ [Setup] Server angelegt: ${created.name} (${created.id})`);
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerById(serverId) {
|
|
||||||
const id = Number(serverId);
|
|
||||||
if (!Number.isFinite(id)) {
|
|
||||||
const error = new Error('SERVER_ID_INVALID');
|
|
||||||
error.code = 'SERVER_ID_INVALID';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = selectServerById.get(id);
|
|
||||||
if (!server) {
|
|
||||||
const error = new Error('SERVER_NOT_FOUND');
|
|
||||||
error.code = 'SERVER_NOT_FOUND';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateServerDetails(serverId, { name, url }) {
|
|
||||||
const existing = getServerById(serverId);
|
|
||||||
const normalizedUrl = normalizeUrl(url || existing.url);
|
|
||||||
if (!normalizedUrl) {
|
|
||||||
const error = new Error('SERVER_URL_REQUIRED');
|
|
||||||
error.code = 'SERVER_URL_REQUIRED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedName = (name || deriveServerName(normalizedUrl)).trim();
|
|
||||||
if (!normalizedName) {
|
|
||||||
const error = new Error('SERVER_NAME_REQUIRED');
|
|
||||||
error.code = 'SERVER_NAME_REQUIRED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const other = selectServerByUrl.get(normalizedUrl);
|
|
||||||
if (other && other.id !== existing.id) {
|
|
||||||
const error = new Error('SERVER_URL_TAKEN');
|
|
||||||
error.code = 'SERVER_URL_TAKEN';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateServer.run(normalizedName, normalizedUrl, existing.id);
|
|
||||||
const updated = selectServerById.get(existing.id);
|
|
||||||
console.log(`ℹ️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setServerApiKey({ serverId, apiKey }) {
|
|
||||||
const id = Number(serverId);
|
|
||||||
if (!Number.isFinite(id)) {
|
|
||||||
const error = new Error('SERVER_ID_INVALID');
|
|
||||||
error.code = 'SERVER_ID_INVALID';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = selectServerById.get(id);
|
|
||||||
if (!server) {
|
|
||||||
const error = new Error('SERVER_NOT_FOUND');
|
|
||||||
error.code = 'SERVER_NOT_FOUND';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedKey = typeof apiKey === 'string' ? apiKey.trim() : '';
|
|
||||||
if (!normalizedKey) {
|
|
||||||
const error = new Error('API_KEY_REQUIRED');
|
|
||||||
error.code = 'API_KEY_REQUIRED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const encrypted = encryptApiKey(normalizedKey);
|
|
||||||
if (!encrypted) {
|
|
||||||
const error = new Error('API_KEY_ENCRYPT_FAILED');
|
|
||||||
error.code = 'API_KEY_ENCRYPT_FAILED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = selectApiKeyByServerId.get(id);
|
|
||||||
upsertApiKey.run(id, encrypted.content, encrypted.iv, encrypted.tag);
|
|
||||||
console.log(`${existing ? 'ℹ️' : '🔐'} [Setup] API-Key ${existing ? 'aktualisiert' : 'angelegt'}: Server ${server.name} (${server.id})`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
serverId: server.id,
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerConnection(serverId) {
|
|
||||||
const server = getServerById(serverId);
|
|
||||||
const apiKeyRow = selectApiKeyByServerId.get(server.id);
|
|
||||||
const apiKey = decryptApiKey(apiKeyRow);
|
|
||||||
return { server, apiKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveApiKey() {
|
|
||||||
const firstServer = selectFirstServerStmt.get();
|
|
||||||
if (firstServer) {
|
|
||||||
const row = selectApiKeyByServerId.get(firstServer.id);
|
|
||||||
const key = decryptApiKey(row);
|
|
||||||
if (key) return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
const envKey = process.env.PORTAINER_API_KEY ? process.env.PORTAINER_API_KEY.trim() : '';
|
|
||||||
return envKey || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveServerUrl() {
|
|
||||||
const firstServer = selectFirstServerStmt.get();
|
|
||||||
if (firstServer?.url) {
|
|
||||||
const normalizedUrl = normalizeUrl(firstServer.url);
|
|
||||||
if (normalizedUrl) {
|
|
||||||
return normalizedUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const envUrl = process.env.PORTAINER_URL ? process.env.PORTAINER_URL.trim() : '';
|
|
||||||
const normalizedEnvUrl = envUrl ? normalizeUrl(envUrl) : '';
|
|
||||||
return normalizedEnvUrl || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasServer() {
|
|
||||||
const { count } = countServersStmt.get();
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeServer(serverId) {
|
|
||||||
const id = Number(serverId);
|
|
||||||
if (!Number.isFinite(id)) {
|
|
||||||
const error = new Error('SERVER_ID_INVALID');
|
|
||||||
error.code = 'SERVER_ID_INVALID';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = selectServerById.get(id);
|
|
||||||
if (!server) {
|
|
||||||
const error = new Error('SERVER_NOT_FOUND');
|
|
||||||
error.code = 'SERVER_NOT_FOUND';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingApiKey = selectApiKeyByServerId.get(id);
|
|
||||||
|
|
||||||
deleteServerStmt.run(id);
|
|
||||||
|
|
||||||
if (existingApiKey) {
|
|
||||||
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id})`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
server,
|
|
||||||
removed: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasApiKey() {
|
|
||||||
const { count } = countApiKeysStmt.get();
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasCompleteSetup() {
|
|
||||||
return hasSuperuser() && hasServer() && hasApiKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureDefaultsFromEnv() {
|
|
||||||
const envServerUrlRaw = process.env.PORTAINER_URL;
|
|
||||||
const envServerName = process.env.PORTAINER_SERVER_NAME;
|
|
||||||
let server = null;
|
|
||||||
if (envServerUrlRaw) {
|
|
||||||
try {
|
|
||||||
server = ensureServer({ name: envServerName, url: envServerUrlRaw });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('⚠️ [Setup] Konnte Server aus Umgebungsvariablen nicht anlegen:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const envApiKeyRaw = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
|
||||||
if (envApiKeyRaw.trim()) {
|
|
||||||
const targetServer = server || selectFirstServerStmt.get();
|
|
||||||
if (targetServer) {
|
|
||||||
try {
|
|
||||||
setServerApiKey({ serverId: targetServer.id, apiKey: envApiKeyRaw.trim() });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('⚠️ [Setup] Konnte API-Key aus Umgebungsvariablen nicht speichern:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerStatus(serverId) {
|
|
||||||
const server = getServerById(serverId);
|
|
||||||
const apiKeyMeta = selectApiKeyByServerId.get(server.id) || null;
|
|
||||||
return {
|
|
||||||
server,
|
|
||||||
apiKey: {
|
|
||||||
hasKey: Boolean(apiKeyMeta),
|
|
||||||
updatedAt: apiKeyMeta?.updated_at ?? null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSetupStatus() {
|
|
||||||
const servers = selectAllServers.all();
|
|
||||||
const apiKeyRecords = selectAllApiKeys.all();
|
|
||||||
const apiKeyMap = new Map(apiKeyRecords.map((entry) => [entry.server_id, entry]));
|
|
||||||
|
|
||||||
const rawEnvServerName = typeof process.env.PORTAINER_SERVER_NAME === 'string' ? process.env.PORTAINER_SERVER_NAME : '';
|
|
||||||
const envServerName = rawEnvServerName.trim();
|
|
||||||
const rawEnvServerUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL : '';
|
|
||||||
const envServerUrl = rawEnvServerUrl.trim();
|
|
||||||
const rawEnvApiKey = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
|
|
||||||
const envApiKeyTrimmed = rawEnvApiKey.trim();
|
|
||||||
const envApiKeyProvided = Boolean(envApiKeyTrimmed);
|
|
||||||
const envSuperuserUsernameRaw = typeof process.env.SUPERUSER_USERNAME === 'string' ? process.env.SUPERUSER_USERNAME : '';
|
|
||||||
const envSuperuserEmailRaw = typeof process.env.SUPERUSER_EMAIL === 'string' ? process.env.SUPERUSER_EMAIL : '';
|
|
||||||
const envSuperuserPasswordRaw = typeof process.env.SUPERUSER_PASSWORD === 'string' ? process.env.SUPERUSER_PASSWORD : '';
|
|
||||||
const envSuperuserUsername = envSuperuserUsernameRaw.trim();
|
|
||||||
const envSuperuserEmail = envSuperuserEmailRaw.trim();
|
|
||||||
|
|
||||||
const serverRequired = servers.length === 0;
|
|
||||||
|
|
||||||
const apiKeyItems = servers.map((server) => {
|
|
||||||
const keyMeta = apiKeyMap.get(server.id) || null;
|
|
||||||
return {
|
|
||||||
serverId: server.id,
|
|
||||||
serverName: server.name,
|
|
||||||
hasKey: Boolean(keyMeta),
|
|
||||||
updatedAt: keyMeta?.updated_at ?? null
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const apiKeyCount = apiKeyItems.filter((item) => item.hasKey).length;
|
|
||||||
const apiKeyRequired = servers.length > 0 && apiKeyCount === 0;
|
|
||||||
|
|
||||||
const superuserExists = hasSuperuser();
|
|
||||||
const setupComplete = superuserExists && !serverRequired && !apiKeyRequired;
|
|
||||||
|
|
||||||
return {
|
|
||||||
superuser: {
|
|
||||||
exists: superuserExists,
|
|
||||||
envProvided: Boolean(envSuperuserUsername || envSuperuserEmail || envSuperuserPasswordRaw),
|
|
||||||
env: {
|
|
||||||
username: envSuperuserUsernameRaw,
|
|
||||||
email: envSuperuserEmailRaw,
|
|
||||||
password: envSuperuserPasswordRaw
|
|
||||||
}
|
|
||||||
},
|
|
||||||
servers: {
|
|
||||||
count: servers.length,
|
|
||||||
items: servers,
|
|
||||||
requireInput: serverRequired,
|
|
||||||
envProvided: Boolean(envServerUrl)
|
|
||||||
},
|
|
||||||
apiKeys: {
|
|
||||||
count: apiKeyCount,
|
|
||||||
items: apiKeyItems,
|
|
||||||
requireInput: apiKeyRequired,
|
|
||||||
envProvided: envApiKeyProvided,
|
|
||||||
envValue: rawEnvApiKey
|
|
||||||
},
|
|
||||||
requirements: {
|
|
||||||
superuser: !superuserExists,
|
|
||||||
server: serverRequired,
|
|
||||||
apiKey: apiKeyRequired
|
|
||||||
},
|
|
||||||
setupComplete,
|
|
||||||
envDefaults: {
|
|
||||||
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
|
|
||||||
serverNameFromEnv: rawEnvServerName,
|
|
||||||
serverUrl: envServerUrl,
|
|
||||||
apiKeyProvided: envApiKeyProvided,
|
|
||||||
apiKeyValue: rawEnvApiKey,
|
|
||||||
superuserUsername: envSuperuserUsernameRaw,
|
|
||||||
superuserEmail: envSuperuserEmailRaw,
|
|
||||||
superuserPassword: envSuperuserPasswordRaw
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function completeSetup({ server: serverInput }) {
|
|
||||||
let serverRecord = null;
|
|
||||||
|
|
||||||
if (serverInput && typeof serverInput.url === 'string' && serverInput.url.trim()) {
|
|
||||||
serverRecord = ensureServer(serverInput);
|
|
||||||
} else {
|
|
||||||
serverRecord = selectFirstServerStmt.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!serverRecord) {
|
|
||||||
const error = new Error('SERVER_DETAILS_REQUIRED');
|
|
||||||
error.code = 'SERVER_DETAILS_REQUIRED';
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
server: serverRecord
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
ensureDefaultsFromEnv,
|
|
||||||
ensureServer,
|
|
||||||
getServerById,
|
|
||||||
listServers,
|
|
||||||
setServerApiKey,
|
|
||||||
getServerConnection,
|
|
||||||
updateServerDetails,
|
|
||||||
getServerStatus,
|
|
||||||
getActiveApiKey,
|
|
||||||
getActiveServerUrl,
|
|
||||||
hasServer,
|
|
||||||
hasApiKey,
|
|
||||||
hasCompleteSetup,
|
|
||||||
getSetupStatus,
|
|
||||||
completeSetup,
|
|
||||||
removeServer
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,136 +0,0 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
import { wordlist as englishWordlist } from '@scure/bip39/wordlists/english.js';
|
|
||||||
|
|
||||||
const FALLBACK_SECRET = 'stackpulse-security-phrase-secret';
|
|
||||||
|
|
||||||
const WORD_LIST = englishWordlist;
|
|
||||||
|
|
||||||
|
|
||||||
const SECURITY_PHRASE_KEY = crypto
|
|
||||||
.createHash('sha256')
|
|
||||||
.update(String(process.env.SECURITY_PHRASE_SECRET || FALLBACK_SECRET))
|
|
||||||
.digest();
|
|
||||||
|
|
||||||
const encryptValue = (value) => {
|
|
||||||
const iv = crypto.randomBytes(12);
|
|
||||||
const cipher = crypto.createCipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, iv);
|
|
||||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
|
||||||
const tag = cipher.getAuthTag();
|
|
||||||
return {
|
|
||||||
iv: iv.toString('base64'),
|
|
||||||
content: encrypted.toString('base64'),
|
|
||||||
tag: tag.toString('base64')
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const decryptValue = ({ content, iv, tag }) => {
|
|
||||||
if (!content || !iv || !tag) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const decipher = crypto.createDecipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, Buffer.from(iv, 'base64'));
|
|
||||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
|
||||||
const decrypted = Buffer.concat([
|
|
||||||
decipher.update(Buffer.from(content, 'base64')),
|
|
||||||
decipher.final()
|
|
||||||
]);
|
|
||||||
return decrypted.toString('utf8');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateSecurityPhraseWords = (count = 8) => {
|
|
||||||
const words = [];
|
|
||||||
for (let i = 0; i < count; i += 1) {
|
|
||||||
const index = crypto.randomInt(0, WORD_LIST.length);
|
|
||||||
words.push(WORD_LIST[index]);
|
|
||||||
}
|
|
||||||
return words;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const encodeWords = (words = []) => {
|
|
||||||
return words
|
|
||||||
.map((word) => {
|
|
||||||
const index = WORD_LIST.indexOf(word);
|
|
||||||
if (index === -1) {
|
|
||||||
throw new Error(`SECURITY_PHRASE_WORD_UNKNOWN:${word}`);
|
|
||||||
}
|
|
||||||
return String(index);
|
|
||||||
})
|
|
||||||
.join('-');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decodeWords = (encoded) => {
|
|
||||||
if (!encoded) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return encoded.split('-').map((entry) => {
|
|
||||||
const index = Number(entry);
|
|
||||||
if (!Number.isFinite(index) || index < 0 || index >= WORD_LIST.length) {
|
|
||||||
throw new Error('SECURITY_PHRASE_DECODE_FAILED');
|
|
||||||
}
|
|
||||||
return WORD_LIST[index];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const encryptSecurityPhrase = (words) => {
|
|
||||||
const encoded = encodeWords(words);
|
|
||||||
const encrypted = encryptValue(encoded);
|
|
||||||
return {
|
|
||||||
encrypted,
|
|
||||||
encoded
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decryptSecurityPhrase = (record) => {
|
|
||||||
if (!record) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const encoded = decryptValue(record);
|
|
||||||
if (!encoded) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return decodeWords(encoded);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatPhraseForDisplay = (words) => {
|
|
||||||
const rows = [];
|
|
||||||
for (let i = 0; i < words.length; i += 4) {
|
|
||||||
rows.push(words.slice(i, i + 4));
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeWord = (value) => {
|
|
||||||
if (!value && value !== 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return String(value).trim().toLowerCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const canonicalizeWords = (words = []) => {
|
|
||||||
return words
|
|
||||||
.map(normalizeWord)
|
|
||||||
.filter((word) => word.length > 0)
|
|
||||||
.join('');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractWordsFromString = (input) => {
|
|
||||||
if (!input && input !== 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const normalized = String(input)
|
|
||||||
.replace(/[\r\n\t]+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return normalized.split(/\s+/).filter(Boolean);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const canonicalizePhraseInput = (input) => {
|
|
||||||
if (Array.isArray(input)) {
|
|
||||||
return canonicalizeWords(input);
|
|
||||||
}
|
|
||||||
return canonicalizeWords(extractWordsFromString(input));
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WORD_LIST;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
ports:
|
|
||||||
- "4001:4001"
|
|
||||||
volumes:
|
|
||||||
- stackpulse_data:/app/backend/data
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- PORTAINER_URL=Your_Portainer_Server_Adress (optional)
|
|
||||||
- PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
|
|
||||||
- SUPERUSER_USERNAME=Your_Superuser_Username (optional)
|
|
||||||
- SUPERUSER_EMAIL=Your_Superuser_Email (optional)
|
|
||||||
- SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
|
|
||||||
- SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
|
|
||||||
volumes:
|
|
||||||
stackpulse_data:
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Architektur
|
|
||||||
|
|
||||||
StackPulse folgt einer klassischen Client/Server-Architektur, bei der das Backend den Single-Source-of-Truth darstellt und das Frontend ausschließlich über wohldefinierte REST- und Socket.IO-Schnittstellen kommuniziert.
|
|
||||||
|
|
||||||
```text
|
|
||||||
┌────────────┐ HTTPS + WebSocket ┌───────────────┐ HTTPS ┌────────────────┐
|
|
||||||
│ Frontend │ ─────────────────────────▶ │ Backend │ ───────────────▶ │ Portainer API │
|
|
||||||
│ (React) │ ◀───── Auth-Cookie + JSON ─ │ (Express) │ ◀─────────────── │ Business Ed. │
|
|
||||||
└────────────┘ Redeploy Events └───────────────┘ (Axios Proxy) └────────────────┘
|
|
||||||
│
|
|
||||||
mTLS (optional)│
|
|
||||||
▼
|
|
||||||
StackPulse Agent
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
SQLite Datenbank
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hauptschichten
|
|
||||||
- **Frontend** (Vite + Material Tailwind): stellt Views, Tabellen, Setup-Wizard und Echtzeit-Widgets bereit. `AuthProvider` verwaltet Session, Permissions und Socket-Verbindungen.
|
|
||||||
- **Backend** (Express + better-sqlite3): aggregiert Daten aus Portainer, persistiert Benutzer/Gruppen/Logs, verwaltet Sessions, Redeploy-Queue und Maintenance/Agent-Funktionen.
|
|
||||||
- **Agent (optional)**: kleiner Node-Dienst, der lokal auf dem Portainer-Host läuft. Liefert Stack-/Portainer-Metadaten per REST, authentifiziert via `X-Agent-Token` und kann mTLS-Zertifikate aus dem Backend beziehen.
|
|
||||||
- **Persistenz** (SQLite): Datei `backend/data/stackpulse.db`, WAL-Modus aktiviert. Tabellen umfassen u. a. `users`, `groups`, `permissions`, `servers`, `server_api_keys`, `event_logs`, `settings` sowie Hilfstabellen für Tokens und Sicherheitspassphrasen.
|
|
||||||
|
|
||||||
## Datenflüsse
|
|
||||||
1. **Setup-Phase**: `/api/setup/*` nimmt Serverdaten entgegen, verschlüsselt API-Keys (`aes-256-gcm` via `PORTAINER_API_SECRET`) und erstellt Einträge in `servers`/`server_api_keys`.
|
|
||||||
2. **Authentication**: `/api/auth/login` erstellt Sessions (JWT-ähnliche Tokens in Cookies), `AuthProvider` ruft `/api/auth/session` zyklisch ab und speichert Berechtigungen.
|
|
||||||
3. **Stack-Übersicht**: Frontend ruft `/api/stacks` auf. Das Backend proxyt `Portainer /api/endpoints/:id/docker/stacks`, ergänzt Caching, interne Metadaten und liefert Redeploy-Status.
|
|
||||||
4. **Redeploy**: UI stößt `/api/stacks/:id/redeploy`, `/api/stacks/redeploy-selection` oder `/api/stacks/redeploy-all` an. Das Backend ruft Portainer-Endpoints, protokolliert Events und broadcastet Fortschritt via Socket.IO `redeployStatus`.
|
|
||||||
5. **Logging**: Jeder relevante Vorgang erzeugt Einträge in `event_logs` (siehe `logging/eventLogs.js`). Frontend filtert/paginiert über `/api/logs` und kann Exporte `/api/logs/export` herunterladen.
|
|
||||||
6. **Wartung**: `/api/maintenance/*` verwaltet Maintenance-Mode, SSH-Zugänge, Update-Skripte sowie weitere Wartungsfunktionen.
|
|
||||||
|
|
||||||
## Verzeichnis-Referenz
|
|
||||||
| Pfad | Inhalt |
|
|
||||||
|------|--------|
|
|
||||||
| `backend/index.js` | Herzstück der API inkl. Routing, Socket.IO, Redeploy, Setup, Auth. |
|
|
||||||
| `backend/auth/` | Superuser-Verwaltung, Passwort- und Tokenfunktionen, Sicherheitsphrasen. |
|
|
||||||
| `backend/db/` | SQLite-Initialisierung (`ensure.js`/`schemaEnsure.js`), Settings-Layer, DB-Blueprints. |
|
|
||||||
| `backend/logging/` | Event-Log Modellierung, Filter/Export/Delete. |
|
|
||||||
| `backend/groups/`, `backend/users/`, `backend/permissions/` | Domain-Logik für Benutzer- & Rechteverwaltung. |
|
|
||||||
| `backend/maintenance/` | Maintenance-Mode sowie SSH-/Update-Konfiguration. |
|
|
||||||
| `frontend/src/` | React-App (Layouts, Pages, Widgets, Provider). `routes.jsx` definiert Navigation & Permissions. |
|
|
||||||
| `scripts/` | Hilfsskripte (z. B. `start-dev.sh`). |
|
|
||||||
| `Dockerfile`, `docker-compose*.yml` | Build- & Deployment-Spezifikation. |
|
|
||||||
|
|
||||||
## Konfiguration & Secrets
|
|
||||||
- API-Keys und Passwörter werden verschlüsselt in SQLite abgelegt (`server_api_keys`, `settings`).
|
|
||||||
- Settings (Maintenance, Update-Skripte, SSH) nutzen `db/settings.js` mit JSON-Serialisierung.
|
|
||||||
- Das Backend erzwingt HTTPS-Requests Richtung Portainer (`https.Agent` mit deaktivierter Zertifikatsprüfung für interne Netze).
|
|
||||||
- Socket.IO lauscht auf demselben Port wie Express (`/socket.io`). CORS ist aktuell für alle Origins offen – für produktive Deployments empfiehlt sich ein Reverse Proxy mit Authentifizierung.
|
|
||||||
|
|
||||||
## Skalierung & Grenzen
|
|
||||||
- SQLite eignet sich für Einzelinstanzen. Für höhere Last empfiehlt sich eine Umstellung auf Postgres/MySQL (der Code kapselt Zugriffe weitgehend in `db/*`).
|
|
||||||
- Redeploy-Operationen werden seriell behandelt (`redeployingStacks` + Status-Caching). Parallele Ausführung ist möglich, solange Portainer dies unterstützt.
|
|
||||||
- Logging wird ausschließlich serverseitig betrieben; Client-seitige Filterung erfolgt über Query-Parameter.
|
|
||||||
|
|
||||||
Die weiteren Kapitel vertiefen Backend- und Frontend-spezifische Details.
|
|
||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
# Backend & API
|
|
||||||
|
|
||||||
Das Backend implementiert alle Anwendungsfälle über Express-Routen, Socket.IO für Echtzeit-Events und eine SQLite-Datenbank als Persistenzschicht. Dieser Abschnitt fasst die wichtigsten Module, Entitäten und Endpunkte zusammen.
|
|
||||||
|
|
||||||
## Laufzeit & Einstiegspunkte
|
|
||||||
- `backend/index.js` initialisiert Express, Socket.IO, lädt `.env`, sorgt für Schema-Validierung (`ensureDatabaseSchema`) und Default-Daten (`ensureSuperuserFromEnv`, `ensureDefaultsFromEnv`).
|
|
||||||
- Start via `npm start` (siehe `package.json`); Migrationen über `npm run migrate`.
|
|
||||||
- Datenbankdatei: `backend/data/stackpulse.db`, WAL-Modus aktiv.
|
|
||||||
|
|
||||||
## Module
|
|
||||||
| Modul | Pfad | Aufgabe |
|
|
||||||
|-------|------|---------|
|
|
||||||
| Auth/Superuser | `auth/superuser.js` | Registrieren/Verifizieren von Superusern, Login, Passwortvalidierung, Sicherheitsphrasen. |
|
|
||||||
| Users & Groups | `users/*`, `groups/*`, `permissions/*` | CRUD für Benutzer, Gruppen, Rechtezuweisung, Ableitung effektiver Berechtigungen. Rechte-Labels z. B. `Bereich & Navigation`, `Benutzergruppen bearbeiten`, `Benutzer löschen`. |
|
|
||||||
| Setup | `setup/index.js` | Verwaltung registrierter Portainer-Server, sichere Ablage der API-Keys, Setup-Status. |
|
|
||||||
| Logging | `logging/eventLogs.js` | Einheitliches Ereignislogging (Insert, Filter, Delete, Export). |
|
|
||||||
| Maintenance | `maintenance/state.js` + Helper in `index.js` | Wartungsmodus, SSH-Konfiguration, Update-Skripte und Ausführung. |
|
|
||||||
| Portainer Proxy | Funktionen in `index.js` | Kommunikation mit Portainer (`axiosInstance` + dynamische `baseURL`), Environment-Erkennung und Redeploy-Strecke. |
|
|
||||||
|
|
||||||
## API-Überblick (Auszug)
|
|
||||||
### Setup & Server
|
|
||||||
| Methode | Route | Beschreibung |
|
|
||||||
|---------|-------|--------------|
|
|
||||||
| GET | `/api/setup/status` | Gibt zurück, ob Server/API-Key/Superuser vorhanden sind. |
|
|
||||||
| POST | `/api/setup/test-portainer` | Verifiziert URL + API-Key gegen Portainer. |
|
|
||||||
| POST | `/api/setup/portainer-stacks` | Listet Stacks (für Preview im Wizard). |
|
|
||||||
| POST | `/api/setup/complete` | Markiert Setup als abgeschlossen und hinterlegt Server/API-Key. |
|
|
||||||
| DELETE | `/api/setup/servers/:id` | Entfernt einen registrierten Portainer-Server. |
|
|
||||||
| PUT | `/api/setup/servers/:id/api-key` | Überschreibt API-Key. |
|
|
||||||
| PUT | `/api/setup/self-stack` | Speichert die ID des StackPulse-Stacks, damit dieser nicht redeployed wird.
|
|
||||||
|
|
||||||
### Authentifizierung
|
|
||||||
| Methode | Route | Beschreibung |
|
|
||||||
|---------|-------|--------------|
|
|
||||||
| POST | `/api/auth/login` | Username/Passwort-Login, setzt HTTP-only Cookie. |
|
|
||||||
| POST | `/api/auth/logout` | Session beenden & Cookie löschen. |
|
|
||||||
| GET | `/api/auth/session` | Liefert eingeloggten Benutzer + Permissions. |
|
|
||||||
| POST | `/api/auth/recover/verify` & `/reset` | Passwortreset über Sicherheitsphrase. |
|
|
||||||
| GET/POST/DELETE | `/api/auth/superuser/*` | Abfrage, Registrierung oder Entfernen des Superusers.
|
|
||||||
|
|
||||||
### Benutzer & Gruppen
|
|
||||||
| Methode | Route | Beschreibung |
|
|
||||||
|---------|-------|--------------|
|
|
||||||
| GET/POST | `/api/users` | Liste anlegen, neue Benutzer erstellen. |
|
|
||||||
| GET/PUT/DELETE | `/api/users/:id` | Details lesen, bearbeiten oder löschen. |
|
|
||||||
| PUT | `/api/users/:id/groups` | Gruppenmitgliedschaften aktualisieren. |
|
|
||||||
| PUT | `/api/users/:id/active` | Benutzer aktivieren/deaktivieren. |
|
|
||||||
| GET/POST | `/api/users/:id/security-phrase`, `/renew` | Sicherheitsphrase anzeigen bzw. neu generieren. |
|
|
||||||
| GET/POST | `/api/groups` | Gruppen auflisten oder neu anlegen. |
|
|
||||||
| PUT/DELETE | `/api/groups/:id` | Gruppe editieren oder entfernen. |
|
|
||||||
| GET/PUT | `/api/groups/:id/permissions` | Berechtigungen lesen/setzen.
|
|
||||||
|
|
||||||
### Stacks & Redeploy
|
|
||||||
| Methode | Route | Beschreibung |
|
|
||||||
|---------|-------|--------------|
|
|
||||||
| GET | `/api/stacks` | Aggregierte Liste inkl. Filter, Self-Stack-Blockade und Redeploy-Status. |
|
|
||||||
| PUT | `/api/stacks/:id/redeploy` | Redeploy eines einzelnen Stacks. |
|
|
||||||
| PUT | `/api/stacks/redeploy-selection` | Redeploy einer Liste übermittelter `stackIds`. |
|
|
||||||
| PUT | `/api/stacks/redeploy-all` | Redeploy aller Stacks (Recht `Redeploy Alle`). |
|
|
||||||
|
|
||||||
Das Backend veröffentlicht den Redeploy-Fortschritt parallel über Socket.IO (`redeployStatus` Event) und unterscheidet die Phasen `queued`, `started`, `success`, `error`, `info`.
|
|
||||||
|
|
||||||
### Logging & Wartung
|
|
||||||
| Methode | Route | Beschreibung |
|
|
||||||
|---------|-------|--------------|
|
|
||||||
| GET | `/api/logs` | Filterbare/Paginierbare Event-Logs. |
|
|
||||||
| DELETE | `/api/logs/:id` oder `/api/logs` | Einzelne oder gefilterte Logs löschen. |
|
|
||||||
| GET | `/api/logs/export` | CSV-Export anhand der Query-Filter. |
|
|
||||||
| GET/POST | `/api/maintenance/mode` | Status abfragen oder toggeln. |
|
|
||||||
| GET | `/api/maintenance/config` | Liefert Maintenance-Status, Update-Skript & SSH-Config. |
|
|
||||||
| PUT/DELETE | `/api/maintenance/ssh-config` | SSH-Credentials speichern/entfernen. |
|
|
||||||
| POST | `/api/maintenance/test-ssh` | Verbindungstest mit optionalem Override. |
|
|
||||||
| PUT/DELETE | `/api/maintenance/update-script` | Custom Update-Skript pflegen. |
|
|
||||||
| POST | `/api/maintenance/portainer-update` | Update-Kommando per SSH ausführen (mit Custom/Default-Skript). |
|
|
||||||
|
|
||||||
## Datenbank & Schema
|
|
||||||
- `ensureDatabaseSchema()` erzeugt Tabellen bei jedem Start, inklusive Default-Gruppen, Permissions, Settings, Server-Einträge und Trigger.
|
|
||||||
- Permissions werden anhand der Blueprint-Datei `backend/db/dbs` eingelesen. Jede Permission definiert Key, Label (z. B. `Bereich & Navigation`, `Server bearbeiten`, `SSH/Update-Skript`, `Update durchführen`, `Doppelte Stacks`), Level (`full`, `read`, `none`) und Abhängigkeiten.
|
|
||||||
- Events werden in `event_logs` gespeichert. Relevante Felder:
|
|
||||||
- `category`, `event_type`, `action`, `status`, `severity`
|
|
||||||
- `entity_type`, `entity_id`, `entity_name`
|
|
||||||
- `actor_type`, `actor_id`, `actor_name`
|
|
||||||
- `context_type`, `context_id`, `context_label`
|
|
||||||
- `message`, `metadata (JSON)`
|
|
||||||
- Filter-Parameter (`ids`, `categories`, `eventTypes`, `status`, `stackIds`, Zeitfenster) werden in `buildEventLogFilter` zentral verarbeitet.
|
|
||||||
|
|
||||||
## Berechtigungssystem
|
|
||||||
- Jeder Benutzer gehört zu beliebig vielen Gruppen; die effektiven Rechte ergeben sich aus der höchsten Ausprägung pro Permission (Labels wie `Bereich & Navigation`, `Server bearbeiten`, `SSH/Update-Skript`, `Update durchführen`, `Doppelte Stacks`).
|
|
||||||
- Superuser erhalten alle Berechtigungen und können nicht über das UI entzogen werden.
|
|
||||||
- `PermissionGate` im Frontend spiegelt die gleichen Keys. Wichtigste Bereiche:
|
|
||||||
- `Redeploy einzeln/Auswahl/Alle`
|
|
||||||
- `Bereich & Navigation (Logs)`, `Logs Exportieren`, `Logs löschen`
|
|
||||||
- `Bereich & Navigation (Benutzer)`, `Benutzer bearbeiten/löschen`, `Sicherheitsschlüssel`
|
|
||||||
- `Bereich & Navigation (Benutzergruppen)`, `Benutzergruppen bearbeiten/löschen`
|
|
||||||
- Wartung: `Bereich & Navigation (Wartung)`, `Server-Sektion`, `Server bearbeiten/löschen`, `SSH/Update-Skript`, `Update durchführen`, `Doppelte Stacks`
|
|
||||||
|
|
||||||
## Redeploy-Workflow
|
|
||||||
1. Eingehender Request wird mit `maintenanceGuard` geprüft (Maintenance-Modus blockiert optional Redeploys).
|
|
||||||
2. Das Backend ermittelt den zugehörigen Portainer-Endpoint (`resolvePortainerEnvironmentId`).
|
|
||||||
3. Der Redeploy wird über Portainer ausgeführt (`/api/stacks/:id/redeploy`), Log-Einträge werden erstellt.
|
|
||||||
4. `broadcastRedeployStatus()` aktualisiert das In-Memory-Tracking (`redeployingStacks`) und sendet Socket.IO Events.
|
|
||||||
5. Fehler führen zu `event_logs`-Einträgen mit `severity=error`, UI zeigt Toasts via `ToastProvider`.
|
|
||||||
|
|
||||||
## Wartungs- und Updatepfad
|
|
||||||
- `maintenance/state.js` persistiert Aktivierung & Nachricht im Setting `maintenance_mode`.
|
|
||||||
- SSH-Konfigurationen (Host, Port, User, Passwort, extra args) werden verschlüsselt gespeichert (`getPortainerSshConfig`).
|
|
||||||
- Update-Skripte lassen sich überschreiben, Standard-Skript liegt in `DEFAULT_PORTAINER_UPDATE_SCRIPT` (Bash-Befehle).
|
|
||||||
- Update-Status hält Laufzeiten, letzte Ergebnisse und Logauszüge in-memory & Settings bereit.
|
|
||||||
|
|
||||||
## Erweiterungstipps
|
|
||||||
- Neue API-Routen sollten `requirePermission(..)` nutzen, Logging via `logEvent()` hinzufügen und – falls Portainer-Aufrufe stattfinden – das `axiosInstance` verwenden.
|
|
||||||
- Einstellungen stets über `db/settings.js` verwalten, damit UI & CLI konsistent bleiben.
|
|
||||||
- Für breaking DB-Änderungen existiert kein Migrations-Framework – erweitere `ensureDatabaseSchema` inkl. Versionsflag, um Idempotenz sicherzustellen.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Umgebungsvariablen
|
|
||||||
|
|
||||||
Diese Seite sammelt alle relevanten Umgebungsvariablen für StackPulse (Backend) und den optionalen StackPulse Agent. Werte ohne Default sind in der Regel optional, sofern sie nicht explizit als „erforderlich“ markiert sind.
|
|
||||||
|
|
||||||
## StackPulse Backend
|
|
||||||
| Variable | Default | Erforderlich | Beschreibung |
|
|
||||||
|----------|---------|--------------|--------------|
|
|
||||||
| `PORTAINER_URL` | – | Nein | Basis-URL zu Portainer. Kann im Setup-UI hinterlegt werden; ohne Wert schlägt Portainer-Zugriff fehl, bis ein Server gespeichert ist. |
|
|
||||||
| `PORTAINER_API_KEY` | – | Nein | API-Key für Portainer. Kann im Setup-UI gespeichert (verschlüsselt) werden; ohne Wert keine API-Aufrufe. |
|
|
||||||
| `PORTAINER_SERVER_NAME` | – | Nein | Anzeigename für den initialen Servereintrag (nur kosmetisch). |
|
|
||||||
| `PORTAINER_API_SECRET` | `stackpulse-portainer-api-key` | Nein | AES-Schlüssel für gespeicherte API-Keys. Stabil halten, sonst müssen Keys neu eingetragen werden. |
|
|
||||||
| `SUPERUSER_USERNAME` | – | Nein | Legt beim Start einen Superuser an, falls noch keiner existiert; alternativ im Setup-UI anlegen. |
|
|
||||||
| `SUPERUSER_EMAIL` | – | Nein | E-Mail des initialen Superusers. |
|
|
||||||
| `SUPERUSER_PASSWORD` | – | Nein | Passwort des initialen Superusers. |
|
|
||||||
| `SELF_STACK_ID` | – | Nein | ID des StackPulse-Stacks; verhindert Self-Redeploy im UI. |
|
|
||||||
| `AUTH_COOKIE_NAME` | `sp_auth_token` | Nein | Name des Auth-Cookies. |
|
|
||||||
| `AUTH_SESSION_TTL_MS` | `43200000` (12h) | Nein | Session-Lebensdauer in Millisekunden. |
|
|
||||||
| `AUTH_COOKIE_SECURE` | `false` | Nein | Setzt Cookie nur über HTTPS, wenn `true`. |
|
|
||||||
| `SECURITY_PHRASE_SECRET` | `stackpulse-security-phrase-secret` | Nein | Secret für verschlüsselte Sicherheitsphrasen; stabil halten, sonst Phrasen neu verteilen. |
|
|
||||||
| `PORTAINER_SSH_SECRET` | – (Fallback API-Key/Default) | Nein | Secret für verschlüsselte SSH-Passwörter; stabil halten, sonst Passwort neu setzen. |
|
|
||||||
| `AGENT_MTLS_KEY` | generiert | Nein | Optional: eigenes PEM-Key für internen mTLS-Server, falls nicht automatisch erzeugt. |
|
|
||||||
| `AGENT_MTLS_CERT` | generiert | Nein | Optional: eigenes PEM-Zertifikat für internen mTLS-Server. |
|
|
||||||
| `AGENT_MTLS_PORT` | `4441` | Nein | Port des internen mTLS-Endpunkts (Agent). |
|
|
||||||
| `DISPLAY` | `:9999` | Nein | Wird an Child-Prozesse (Update-Skript) weitergereicht. |
|
|
||||||
|
|
||||||
## StackPulse Agent
|
|
||||||
| Variable | Default | Erforderlich | Beschreibung |
|
|
||||||
|----------|---------|--------------|--------------|
|
|
||||||
| `AGENT_PORT` | `7070` | Nein | Listenport des Agents (HTTPS mit mTLS). |
|
|
||||||
| `AGENT_TOKEN` | – | Ja | Shared Secret für Auth (`X-Agent-Token`) bei allen Agent-Endpoints. |
|
|
||||||
| `DOCKER_SOCKET` | `/var/run/docker.sock` | Nein | Pfad zum Docker-Socket; nötig für Stack-/Image-Infos. |
|
|
||||||
| `CONTROL_PLANE_URL` | – | Ja (für Bootstrap/Restore) | Basis-URL des StackPulse-Backends für Zertifikatsabruf ohne mTLS (z. B. `https://stackpulse:4001`). |
|
|
||||||
| `CONTROL_PLANE_MTLS_URL` | – | Nein (empfohlen) | Bevorzugte mTLS-URL des Backends (z. B. `https://stackpulse:4441`). Wird für alle Agent→Backend-Calls genutzt, sobald Zertifikate vorliegen; fehlt sie, wird aus `CONTROL_PLANE_URL` + `AGENT_MTLS_PORT` abgeleitet. |
|
|
||||||
| `AGENT_BOOTSTRAP_TOKEN` | – | Nein (für Erstbootstrap) | Einmal-Token aus dem Wartungsbereich, um mTLS-Zertifikate zu ziehen. Pflicht, wenn keine Zertifikate vorliegen und das Backend noch kein Material für den Agent gespeichert hat. |
|
|
||||||
| `AGENT_MTLS_PORT` | `4441` | Nein | Fallback-Port, falls `CONTROL_PLANE_MTLS_URL` keinen Port enthält. |
|
|
||||||
| `AGENT_SERVER_KEY` / `AGENT_SERVER_CERT` | – | Nein | PEM-Material für den Agent-Server; nur nötig bei eigenen Zertifikaten statt Backend-CA. |
|
|
||||||
| `AGENT_CLIENT_KEY` / `AGENT_CLIENT_CERT` / `AGENT_CA_CERT` | – | Nein | PEM-Material für Client/CA; nur bei eigener PKI nötig. |
|
|
||||||
| `REGISTRY_TOKEN` | – | Nein | Bearer-Token für Registry-Abfragen (Digest-Vergleich). |
|
|
||||||
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | – | Nein | Basic-Auth-Creds für Registries. |
|
|
||||||
| `GITHUB_USERNAME` / `GITHUB_TOKEN` / `GITHUB_PAT` | – | Nein | Alternative Credential-Quellen für GitHub Packages. |
|
|
||||||
|
|
||||||
|
|
||||||
Agent-Betrieb (wähle eine Variante):
|
|
||||||
|
|
||||||
- **Dynamisch über Backend (empfohlen)**
|
|
||||||
- Pflicht: `AGENT_TOKEN` + `CONTROL_PLANE_URL` (optional `CONTROL_PLANE_MTLS_URL`).
|
|
||||||
- Falls keine Zertifikate existieren: einmaliges `AGENT_BOOTSTRAP_TOKEN` setzen.
|
|
||||||
- Der Agent zieht Zertifikate vom Backend, schaltet auf mTLS um und braucht danach kein Bootstrap-Token mehr.
|
|
||||||
- **Statisch per ENV**
|
|
||||||
- Liefere PEMs: `AGENT_SERVER_KEY`/`AGENT_SERVER_CERT` + `AGENT_CLIENT_KEY`/`AGENT_CLIENT_CERT`/`AGENT_CA_CERT`.
|
|
||||||
- Dann kein `CONTROL_PLANE_URL`/Bootstrap nötig, da mTLS-Material bereits vorhanden ist.
|
|
||||||
- `AGENT_TOKEN` bleibt erforderlich.
|
|
||||||
|
|
||||||
Hinweise:
|
|
||||||
|
|
||||||
- Server-URL und API-Key kannst du im Setup-UI pflegen; Umgebungsvariablen setzen nur den Initialzustand.
|
|
||||||
- Secrets (`SECURITY_PHRASE_SECRET`, `PORTAINER_SSH_SECRET`, `PORTAINER_API_SECRET`) in Produktion einmalig setzen und stabil halten – sonst werden bestehende verschlüsselte Werte unlesbar.
|
|
||||||
|
|
||||||
- `CONTROL_PLANE_MTLS_URL`: bevorzugte mTLS-Zieladresse. Fehlt sie, leitet der Agent sie aus `CONTROL_PLANE_URL` + `AGENT_MTLS_PORT` ab und nutzt sie für alle Aufrufe, sobald Zertifikate vorliegen.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Frontend
|
|
||||||
|
|
||||||
Das Frontend basiert auf React 18, Vite und dem Material Tailwind Dashboard. Es nutzt Context Provider für Authentifizierung, Seitenstatus, Toaster und Wartungsinformationen.
|
|
||||||
|
|
||||||
## Build & Skripte
|
|
||||||
| Befehl | Beschreibung |
|
|
||||||
|--------|--------------|
|
|
||||||
| `npm run dev` | Startet den Vite Dev-Server (Port 5173, HMR aktiviert). |
|
|
||||||
| `npm run build` | Erstellt ein Production-Bundle (`frontend/dist`). Das Dockerfile kopiert dieses Paket ins Backend (`backend/public`). |
|
|
||||||
| `npm run preview` | Vorschau auf das gebaute Bundle.
|
|
||||||
|
|
||||||
## Projektstruktur
|
|
||||||
```
|
|
||||||
frontend/src
|
|
||||||
├── App.jsx # Entry-Point, registriert Layouts & Provider
|
|
||||||
├── components/ # AuthProvider, PermissionGate, Toast/Maintenance/PageProvider
|
|
||||||
├── layouts/ # Dashboard-Layout (Sidebar, Navbar) & Auth-Layout
|
|
||||||
├── pages/
|
|
||||||
│ ├── auth/ # SignIn, SignUp, ForgotPassword, SignOut
|
|
||||||
│ ├── dashboard/ # Stacks, Logs, Maintenance, Users, Usergroups, Detailseiten
|
|
||||||
│ └── setup/ # Setup-Wizard & Helper Screens
|
|
||||||
├── routes.jsx # zentrale Navigationsdefinition inkl. Permissions
|
|
||||||
├── widgets/, data/, configs/, assets/
|
|
||||||
└── tailwind.css # Tailwind Layer & Custom Styles
|
|
||||||
```
|
|
||||||
|
|
||||||
## Routing & Navigation
|
|
||||||
- `routes.jsx` beschreibt sowohl Dashboard- als auch Auth-Routen. Jede Dashboard-Seite besitzt optional ein `permission`-Objekt, das vom `PermissionGate` ausgewertet wird.
|
|
||||||
- Der Layout-Switch erfolgt über `layouts/index.jsx`, das wiederum Sidebar/Topbar-Komponenten aus dem Material Tailwind Dashboard nutzt.
|
|
||||||
- Auth-Routen (`/sign-in`, `/sign-up`, `/forgot-password`, `/logout`) werden im Auth-Layout ohne Sidebar dargestellt.
|
|
||||||
|
|
||||||
## State-Management & Contexts
|
|
||||||
| Provider | Aufgabe |
|
|
||||||
|----------|---------|
|
|
||||||
| `AuthProvider` | Speichert User, Permissions, Session-Status. Stellt `refreshSession`, `logout`, `hasPermission` bereit. |
|
|
||||||
| `PageProvider` | Zentrale UI-Zustände (z. B. Ladeanzeigen, modale Dialoge) für Dashboard-Komponenten. |
|
|
||||||
| `ToastProvider` | Globale Notifications (z. B. Redeploy-Erfolg/Fehler). |
|
|
||||||
| `MaintenanceProvider` | Cached Maintenance-Status, erkennt aktive Maintenance Modes und zeigt Hinweise. |
|
|
||||||
|
|
||||||
## Wichtige Seiten
|
|
||||||
- **Stacks (`pages/dashboard/stacks.jsx`)**: Hauptübersicht mit Tabellen, Suche, Filtern, Bulk- und Einzelredeploy. Bindet `socket.io-client` für Live-Updates (`redeployStatus`).
|
|
||||||
- **Logs (`pages/dashboard/logs.jsx`)**: Tabellenansicht mit Statusfarben, Filterchips, Export-/Delete-Aktionen. Nutzt axios-Queries gegen `/api/logs`.
|
|
||||||
- **Maintenance (`pages/dashboard/maintenance.jsx`)**: Formulare zur Verwaltung von Update-Skript, SSH-Konfiguration, Maintenance-Mode-Toggle und Portainer-Statusprüfung.
|
|
||||||
- **Users / Usergroups**: CRUD-Oberfläche für Benutzer, Gruppen, Berechtigungszuweisung inkl. Sicherheitsphrasen-Handling.
|
|
||||||
- **Setup (`pages/setup`)**: Schrittweiser Wizard (Server -> API-Key -> Superuser). Wird solange angezeigt, bis `/api/setup/status` vollständig ist.
|
|
||||||
|
|
||||||
## Datenzugriff
|
|
||||||
- HTTP-Anfragen erfolgen via `axios` (global konfiguriert in `frontend/src/components/AuthProvider` bzw. direkt in den Seiten). Cookies werden automatisch vom Browser beigesteuert (`credentials: 'include'`).
|
|
||||||
- Socket.IO wird nur im Stack-Dashboard verwendet; Verbindungsaufbau erfolgt in `stacks.jsx` (`io('/', { path: '/socket.io' })`).
|
|
||||||
- Fehlerbehandlung: Jede Seite zeigt Toasts und/oder Inline-Warnungen. Auth-Fehler leiten zur Loginseite um.
|
|
||||||
|
|
||||||
## Styling & Komponenten
|
|
||||||
- Tailwind Utility-Klassen liefert Material Tailwind bereits vordefiniert (Theme in `configs`).
|
|
||||||
- Custom-Komponenten wie Tabellen, Filterchips oder Formulare sind überwiegend in `widgets/` ausgelagert.
|
|
||||||
- Icons stammen aus `@heroicons/react`. Farben/Typografie folgen Creative Tim Vorgaben, können aber zentral in `frontend/src/configs` angepasst werden.
|
|
||||||
|
|
||||||
## Erweiterungshinweise
|
|
||||||
1. **Neue Seiten**: Route in `routes.jsx` ergänzen, optional `PermissionGate` konfigurieren und Seite unter `pages/dashboard` oder `pages/auth` anlegen.
|
|
||||||
2. **API-Anbindungen**: Verwende `axios` und achte auf `try/catch`, damit 401/403 sauber abgefangen werden. Bei kritischen Anforderungen `refreshSession` aufrufen.
|
|
||||||
3. **State teilen**: Nutze bestehende Provider bzw. erzeuge einen eigenen Context in `components/`.
|
|
||||||
4. **Deployment**: `npm run build` liefert ein Self-Contained Bundle. Das Backend dient anschließend als Static Host (`express.static`).
|
|
||||||
|
|
||||||
Damit bietet das Frontend eine anpassbare, klar strukturierte Oberfläche, die eng mit den Backend-Permissions verzahnt ist.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Getting Started
|
|
||||||
|
|
||||||
Dieser Leitfaden beschreibt ausschließlich den Docker-Deploy über die bereitgestellten GHCR-Images und die anschließende Einrichtung im UI.
|
|
||||||
|
|
||||||
## Voraussetzungen
|
|
||||||
- Docker & Docker Compose
|
|
||||||
- Portainer-Instanz (Business Edition empfohlen; bei Community/Edge zusätzlich Agent einsetzen)
|
|
||||||
|
|
||||||
## Zentrale Umgebungsvariablen (Kurzfassung)
|
|
||||||
| Variable | Erforderlich | Beschreibung |
|
|
||||||
|----------|--------------|--------------|
|
|
||||||
| `PORTAINER_URL` | Nein | Basis-URL zu Portainer; kann im Setup-UI hinterlegt werden, falls nicht gesetzt. |
|
|
||||||
| `PORTAINER_API_KEY` | Nein | API-Key; kann im Setup gespeichert werden (wird verschlüsselt). |
|
|
||||||
| `PORTAINER_SERVER_NAME` | Nein | Anzeige-Name für den initialen Servereintrag. |
|
|
||||||
| `PORTAINER_API_SECRET` | Nein | AES-Schlüssel für gespeicherte API-Keys; setze stabilen Wert, sonst muss der Key neu eingegeben werden. |
|
|
||||||
| `SUPERUSER_USERNAME`, `SUPERUSER_EMAIL`, `SUPERUSER_PASSWORD` | Nein | Legt automatisch einen Superuser an, falls noch keiner existiert; sonst im Setup-UI anlegen. |
|
|
||||||
| `SELF_STACK_ID` | Nein | ID des StackPulse-Stacks; schützt vor Self-Redeploy. |
|
|
||||||
| `AUTH_COOKIE_NAME`, `AUTH_SESSION_TTL_MS`, `AUTH_COOKIE_SECURE` | Nein | Session/Cookie-Tuning (Name, Dauer, Secure-Flag). |
|
|
||||||
| `SECURITY_PHRASE_SECRET` | Nein | Secret für verschlüsselte Sicherheitsphrasen; stabil halten, sonst Phrasen neu verteilen. |
|
|
||||||
| `PORTAINER_SSH_SECRET` | Nein | Secret für verschlüsselte SSH-Passwörter; stabil halten, sonst Passwort neu setzen. |
|
|
||||||
| `DISPLAY` | Nein | Wird an Update-Childprozesse weitergereicht (Default `:9999`). |
|
|
||||||
|
|
||||||
Weitere Variablen (Agent, Registry, mTLS) findest du im Detail unter [Umgebungsvariablen](environment.md). Einstellungen wie Server-URL/API-Key, SSH-Config oder Update-Skript kannst du alternativ im UI (Setup/Wartung) verwalten.
|
|
||||||
|
|
||||||
**Hinweis zu Secrets:**
|
|
||||||
- `SECURITY_PHRASE_SECRET` und `PORTAINER_SSH_SECRET` steuern die AES-256-GCM-Verschlüsselung von Sicherheitsphrasen bzw. hinterlegten SSH-Passwörtern.
|
|
||||||
- Wenn sie nicht gesetzt sind, nutzt StackPulse fixe Fallbacks (`stackpulse-security-phrase-secret` bzw. `stackpulse-portainer-ssh-secret` / API-Key). Setze in Produktion eigene, stabile Werte – ein späterer Secret-Wechsel macht zuvor gespeicherte Phrasen/Passwörter unlesbar.
|
|
||||||
- Eine vollständige Übersicht aller Variablen (inkl. Agent) findest du unter „Technik → Umgebungsvariablen“.
|
|
||||||
|
|
||||||
## Ersteinrichtung im UI
|
|
||||||
1. Öffne das Frontend.
|
|
||||||
2. Lege den Portainer-Server und den zugehörigen API-Key an (der Wizard führt durch den Test).
|
|
||||||
3. Registriere einen Superuser oder verwende den per Umgebungsvariablen automatisch angelegten Account.
|
|
||||||
4. Nach erfolgreicher Einrichtung kannst du dich anmelden und erhältst Zugriff auf Dashboard, Logs, Benutzer & Wartung.
|
|
||||||
|
|
||||||
## Docker Run (Single Container)
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
-p 4001:4001 \
|
|
||||||
-e PORTAINER_URL=https://portainer.example.com \
|
|
||||||
-e PORTAINER_API_KEY=xxxx \
|
|
||||||
-e SUPERUSER_USERNAME=admin \
|
|
||||||
-e SUPERUSER_EMAIL=admin@example.com \
|
|
||||||
-e SUPERUSER_PASSWORD=changeme \
|
|
||||||
-e SELF_STACK_ID=123 \
|
|
||||||
-e SECURITY_PHRASE_SECRET=$(openssl rand -hex 32) \
|
|
||||||
-e PORTAINER_SSH_SECRET=$(openssl rand -hex 32) \
|
|
||||||
-v stackpulse_data:/app/backend/data \
|
|
||||||
ghcr.io/mboehmlaender/stackpulse:latest
|
|
||||||
```
|
|
||||||
Das Volume `stackpulse_data` persistiert die SQLite-Datenbank. Passen Sie Secrets und Ports an Ihre Umgebung an; weitere Variablen siehe Kapitel „Umgebungsvariablen“.
|
|
||||||
|
|
||||||
## Docker-Deployment (Compose)
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml (Auszug)
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
image: ghcr.io/mboehmlaender/stackpulse:latest
|
|
||||||
ports:
|
|
||||||
- "4001:4001"
|
|
||||||
volumes:
|
|
||||||
- stackpulse_data:/app/backend/data
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- PORTAINER_URL=https://portainer.example.com
|
|
||||||
- PORTAINER_API_KEY=xxxx
|
|
||||||
- SUPERUSER_USERNAME=admin
|
|
||||||
- SUPERUSER_EMAIL=admin@example.com
|
|
||||||
- SUPERUSER_PASSWORD=changeme
|
|
||||||
- SELF_STACK_ID=123
|
|
||||||
volumes:
|
|
||||||
stackpulse_data:
|
|
||||||
```
|
|
||||||
|
|
||||||
1. `docker compose up -d`
|
|
||||||
2. Das Frontend ist anschließend unter Port 4001 erreichbar.
|
|
||||||
3. Datenbank & Einstellungen liegen im benannten Volume `stackpulse_data`.
|
|
||||||
|
|
||||||
Damit bist du bereit für die tieferen Kapitel zu Architektur sowie Backend- und Frontend-Details.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# StackPulse
|
|
||||||
|
|
||||||
StackPulse ist eine eigenständige Web-Anwendung, die die Portainer Business Edition über deren API steuert und dadurch Redeploys, Wartungsskripte sowie das Berechtigungs- und Logging-Konzept zentralisiert. Die Lösung besteht aus einem Node.js/Express Backend, einem React-Frontend auf Basis von Material Tailwind sowie einer eingebetteten SQLite-Datenbank, die ohne zusätzliche Infrastruktur betrieben werden kann.
|
|
||||||
|
|
||||||
## Warum StackPulse?
|
|
||||||
- **Schneller Überblick** über sämtliche Stacks einer Portainer-Instanz inkl. Status, Filter und Suche
|
|
||||||
- **Redeploy-Orchestrierung** aus einem UI heraus – einzelne Stacks, Auswahlen oder komplette Umgebungen
|
|
||||||
- **Auditierbare Historie** durch strukturierte Event-Logs mit Export-, Lösch- und Filterfunktionen
|
|
||||||
- **Feingranulare Rechteverwaltung** mit Gruppenrechten, Superuser-Handling und Sicherheitsphrasen
|
|
||||||
- **Wartungsautomatisierung** (SSH Update-Skripte, Maintenance-Mode) ohne direkten Portainer-Zugang
|
|
||||||
- **Agent-Integration** (mTLS) für Portainer Community/Edge, um Stacks & Versionen auch ohne Business-API auszulesen
|
|
||||||
- **Bereitstellung als Docker-Image** sowie als lokale Dev-Umgebung via `scripts/start-dev.sh`
|
|
||||||
|
|
||||||
## Technische Eckdaten
|
|
||||||
| Baustein | Technologie | Beschreibung |
|
|
||||||
|-------------|-------------|--------------|
|
|
||||||
| Backend | Node.js 20, Express, Axios, Socket.IO, better-sqlite3 | Stellt REST-API, Authentifizierung, Rechte, Logging, Redeploy-Queue und Portainer-Proxy bereit. |
|
|
||||||
| Datenhaltung| SQLite (Datei `backend/data/stackpulse.db`) | Persistente Speicherung von Benutzern, Gruppen, Servern, API-Keys, Logs und Einstellungen. |
|
|
||||||
| Frontend | React 18, Vite, Material Tailwind Dashboard | Dashboard mit Auth-Flow, Stacks, Logs, Benutzer- und Wartungsansichten. |
|
|
||||||
| Container | Dockerfile + Compose | Multi-Stage Build für Frontend + Backend, Datenvolumen für SQLite. |
|
|
||||||
|
|
||||||
## Release-Status
|
|
||||||
- **v0.5 (aktuell)** – vollständiges Auth/Berechtigungssystem, Benutzer- & Gruppenverwaltung, erweiterte Logs
|
|
||||||
- **v0.4** – neues UI auf Basis des Material Tailwind Dashboards
|
|
||||||
- **v0.3** – Such- & Filterfunktionen, Konfliktbehandlung für Stack-IDs, UI-Benachrichtigungen
|
|
||||||
- **v0.2** – SQLite-Logging inkl. Export/Pagination, Redeploy-Selektor
|
|
||||||
- **v0.1** – Grundstruktur, API-Anbindung, erstes Redeploy
|
|
||||||
|
|
||||||
Weitere geplante Features (Notifications, Monitoring, Multi-Server, Portainer CE) findest du in der [Roadmap im README](../README.md).
|
|
||||||
|
|
||||||
## Wie geht es weiter?
|
|
||||||
- [Getting Started](getting-started.md) erläutert Installation, Konfiguration und erste Schritte.
|
|
||||||
- [Architektur](architecture.md) beschreibt Aufbau, Verzeichnisse und Datenflüsse.
|
|
||||||
- [Backend](backend.md) fasst API, Datenbank und Sicherheitsschichten zusammen.
|
|
||||||
- [Frontend](frontend.md) dokumentiert Layout, Routen und Komponenten.
|
|
||||||
- [Operations & Wartung](operations.md) enthält Betriebshinweise und Troubleshooting.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# Lokale Entwicklung & Migration
|
|
||||||
|
|
||||||
Dieser Abschnitt richtet sich an Contributor:innen, die StackPulse lokal bauen oder erweitern möchten. Für produktive Deployments nutze die GHCR-Images wie im Getting Started beschrieben.
|
|
||||||
|
|
||||||
## Voraussetzungen
|
|
||||||
- Node.js **>= 20** und npm
|
|
||||||
- Shell-Zugang zum Host
|
|
||||||
- Docker (optional) für lokale Tests
|
|
||||||
|
|
||||||
## Repository vorbereiten
|
|
||||||
```bash
|
|
||||||
# Repository klonen
|
|
||||||
# git clone <repo-url>
|
|
||||||
cd stackpulse
|
|
||||||
|
|
||||||
# Startskript ausführbar machen
|
|
||||||
chmod +x scripts/start-dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Schnellstart (Dev-Skript)
|
|
||||||
Das Skript `scripts/start-dev.sh` richtet Backend und Frontend ein und startet beide im Dev-Modus:
|
|
||||||
1. Backend: `npm install`, `npm run migrate`, `npm start`
|
|
||||||
2. Frontend: `npm install`, `npm run dev` (Port 5173) und einmalig `npm run build` für statische Assets
|
|
||||||
|
|
||||||
Nach dem Start:
|
|
||||||
- Frontend Dev Server: http://localhost:5173
|
|
||||||
- Backend API & statische Assets: http://localhost:4001
|
|
||||||
|
|
||||||
Stoppen mit `STRG+C` in der Shell, in der das Skript läuft.
|
|
||||||
|
|
||||||
## Manuelles Starten
|
|
||||||
```bash
|
|
||||||
# Backend
|
|
||||||
cd backend
|
|
||||||
npm install
|
|
||||||
npm run migrate
|
|
||||||
PORTAINER_URL=https://portainer.local npm start
|
|
||||||
|
|
||||||
# Frontend (zweite Shell)
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
Das Backend liest Umgebungsvariablen beim Start. Alternativ kannst du eine `.env` neben `backend/index.js` anlegen.
|
|
||||||
|
|
||||||
## Datenbank & Migration
|
|
||||||
- Datei: `backend/data/stackpulse.db` (SQLite, WAL aktiviert)
|
|
||||||
- Migration: `npm run migrate` (führt `backend/db/ensure.js` aus, erzeugt/aktualisiert Schema & Defaults)
|
|
||||||
- Backup: Datei oder Volume regelmäßig sichern; enthält Benutzer, Logs und gespeicherte Secrets
|
|
||||||
|
|
||||||
## Hinweise
|
|
||||||
- Die lokalen Builds sind für Entwicklung gedacht. Für Deployments verwende `ghcr.io/mboehmlaender/stackpulse`.
|
|
||||||
- Einige Einstellungen (z. B. Server-URL, API-Key, SSH-Config) kannst du im UI setzen; für lokale Tests reichen die ENV-Defaults und das Setup-UI.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# Operations & Wartung
|
|
||||||
|
|
||||||
Dieser Abschnitt deckt typische Aufgaben für Betriebsteams ab – von Backups über Wartungsmodus bis zu Störungssuche.
|
|
||||||
|
|
||||||
**Relevante Rechte**
|
|
||||||
- `Superuser löschen`
|
|
||||||
- `Bereich & Navigation (Wartung)`
|
|
||||||
|
|
||||||
## Laufender Betrieb
|
|
||||||
- **Services überwachen**: StackPulse stellt nur einen Prozess bereit (Node.js/Express). Nutze Systemd/Docker Healthchecks, indem du regelmäßig `/api/auth/session` (für Auth) oder `/api/maintenance/update-status` (für Backend + DB) abfragst.
|
|
||||||
- **Ports**: Standardmäßig lauscht nur Port 4001 (HTTP + Socket.IO). Hinter einem Reverse Proxy sollte TLS terminiert werden.
|
|
||||||
- **Protokolle**: Das Backend loggt ausführlich auf STDOUT/STDERR (`console.log`). Sammle diese via Docker-Logs oder Journal.
|
|
||||||
|
|
||||||
## Backups & Wiederherstellung
|
|
||||||
1. **Container-Setup**: Mount `backend/data` via benanntem Volume (siehe `docker-compose.yml`).
|
|
||||||
2. **Backup**: Stoppe kurz den Container oder nutze `sqlite3 stackpulse.db ".backup 'stackpulse.db.bak'"` innerhalb der laufenden Instanz.
|
|
||||||
3. **Restore**: Ersetze die Datei im Volume und starte die App neu. Beim ersten Start führt `ensureDatabaseSchema` automatisch eventuelle Nachmigrationen aus.
|
|
||||||
|
|
||||||
## Benutzer- & Rechteverwaltung
|
|
||||||
- Superuser können über `/api/auth/superuser/*` oder im UI registriert werden. Entfernen ist nur mit `Superuser löschen` möglich.
|
|
||||||
- Benutzerkonten lassen sich temporär deaktivieren (`/api/users/:id/active`). Die Historie bleibt erhalten.
|
|
||||||
- Sicherheitsphrasen sind notwendig für Passwort-Reset. Admins können neue Phrasen erzeugen (`/api/users/:id/security-phrase/renew`), müssen diese aber dem Benutzer sicher zustellen.
|
|
||||||
|
|
||||||
## Wartungsmodus & Updates
|
|
||||||
- Maintenance Mode aktivieren: `curl -X POST http://host:4001/api/maintenance/mode -H 'Content-Type: application/json' -d '{"active":true,"message":"Cluster Upgrade"}'` (Recht `Bereich & Navigation (Wartung)`). Das UI informiert alle Nutzer und blockiert Redeploys.
|
|
||||||
- SSH-/Update-Setup: Hinterlege Host, Port, Benutzer, Passwort (optional) und zusätzliche SSH-Argumente. Passwörter werden verschlüsselt gespeichert (`PORTAINER_SSH_SECRET`).
|
|
||||||
- Update-Skript: Das Default-Skript (`DEFAULT_PORTAINER_UPDATE_SCRIPT`) führt `docker stop`, `docker run` etc. aus. Du kannst im UI oder via API ein eigenes Skript setzen – wird erst aktualisiert, wenn kein Update läuft.
|
|
||||||
- Update anstoßen: `POST /api/maintenance/portainer-update` triggert die SSH-Kommandos. Status & Logausgaben können über `/api/maintenance/update-status` überwacht werden.
|
|
||||||
|
|
||||||
## Logs & Compliance
|
|
||||||
- Ereignislogs enthalten jede Redeploy-, Auth- und Verwaltungsaktion. Export über `GET /api/logs/export` (CSV) oder automatisiert mittels Cron (Query-Parameter übernehmen Filter).
|
|
||||||
- Datenbereinigung: Nutze `DELETE /api/logs/:id` oder `DELETE /api/logs?before=<ISO>` um alte Einträge zu entfernen. Achtung: Aktionen sind nicht reversibel.
|
|
||||||
- Für Offload kannst du per Script regelmäßig Exporte ziehen und anschließend löschen.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
| Symptom | Prüfschritte |
|
|
||||||
|---------|--------------|
|
|
||||||
| **Setup schlägt fehl (PORTAINER_URL_NOT_CONFIGURED)** | Stelle sicher, dass entweder `PORTAINER_URL` gesetzt ist oder im Setup-UI ein Server gespeichert wurde (`servers`-Tabelle prüfen). |
|
|
||||||
| **Redeploy hängt in `queued`** | `/api/maintenance/update-status` auf laufende Updates prüfen, `redeployingStacks` im Log verfolgen; ggf. Portainer-API erreichbar? |
|
|
||||||
| **Login nicht möglich** | Über `backend/logs` nach Auth-Fehlern suchen, Cookie-Konfiguration prüfen (`AUTH_COOKIE_SECURE` bei HTTP?). |
|
|
||||||
| **SSH-Test fehlgeschlagen** | `POST /api/maintenance/test-ssh` mit temporären Credentials testen, Container-Logs auf `SSH Test fehlgeschlagen` untersuchen. |
|
|
||||||
| **SQLite Locked** | Stelle sicher, dass kein paralleler Backup-Prozess läuft. WAL-Modus erlaubt mehrere Leser, blockiert aber bei langen Schreibtransaktionen (Check Logs auf `SQLITE_BUSY`). |
|
|
||||||
|
|
||||||
## Sicherheitsempfehlungen
|
|
||||||
- Setze `AUTH_COOKIE_SECURE=true` hinter TLS und konfiguriere einen Reverse Proxy (z. B. Traefik, Nginx).
|
|
||||||
- Isoliere das Admin-Interface durch VPN oder IP-Filter.
|
|
||||||
- Drehe API-Keys regelmäßig: `/api/setup/servers/:id/api-key` aktualisiert Schlüssel ohne Neuinstallation.
|
|
||||||
- Nutze dedizierte StackPulse-Benutzer in Portainer, damit Berechtigungen klar getrennt bleiben.
|
|
||||||
|
|
||||||
Mit diesen Richtlinien lässt sich StackPulse sicher betreiben und warten.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Benutzergruppen & Rechte
|
|
||||||
|
|
||||||
Dieser Bereich verwaltet Rollenmodelle und bestimmt, welche Aktionen Benutzer:innen durchführen dürfen.
|
|
||||||
|
|
||||||
**Relevante Rechte**
|
|
||||||
- `Bereich & Navigation (Benutzergruppen)`
|
|
||||||
- `Benutzergruppen bearbeiten`
|
|
||||||
- `Benutzergruppen löschen`
|
|
||||||
|
|
||||||
## Gruppen anlegen
|
|
||||||
1. Button **Gruppe erstellen** wählen.
|
|
||||||
2. Pflichtfelder:
|
|
||||||
- **Name** (z. B. „Ops Team“)
|
|
||||||
- **Beschreibung** (Hilfestellung für Kolleg:innen)
|
|
||||||
3. Optional Tag-Farbe oder Icon setzen (für bessere Übersicht in der Benutzerliste).
|
|
||||||
4. Speichern – die Gruppe erscheint sofort in der Übersicht.
|
|
||||||
|
|
||||||
## Gruppen bearbeiten
|
|
||||||
- Zeile anklicken, um das Detailpanel zu öffnen.
|
|
||||||
- Du kannst Name, Beschreibung und Zugehörige Benutzer (nur Anzeige) sehen.
|
|
||||||
- Zum Bearbeiten der Rechte klicke auf **Berechtigungen bearbeiten**.
|
|
||||||
|
|
||||||
## Rechte vergeben
|
|
||||||
Der Rechte-Dialog zeigt die gesamte Permissionstruktur in Kategorien (Stacks, Logs, Benutzer, Wartung etc.).
|
|
||||||
|
|
||||||
1. Wähle die gewünschte Kategorie aus.
|
|
||||||
2. Setze für jede Permission einen Level:
|
|
||||||
- **none** – kein Zugriff
|
|
||||||
- **read** – Zugriff auf Ansicht/Lesen
|
|
||||||
- **full** – volle Bearbeitungsrechte
|
|
||||||
3. Abhängigkeiten werden automatisch hervorgehoben. Beispiel: Um `Logs löschen` nutzen zu dürfen, muss `Bereich & Navigation (Logs)` mindestens `Vollzugriff` sein.
|
|
||||||
4. Speichern bestätigt die Anpassungen. StackPulse aktualisiert sofort die effektiven Rechte aller Benutzer in dieser Gruppe.
|
|
||||||
|
|
||||||
## Benutzer zu Gruppen hinzufügen
|
|
||||||
- Dies geschieht im Benutzer-Detailpanel (siehe [Benutzerverwaltung](users.md)).
|
|
||||||
- Die Liste zeigt alle vorhandenen Gruppen; ein Benutzer kann mehreren Gruppen angehören.
|
|
||||||
- Die effektiven Rechte sind die jeweils höchsten Levels aller Gruppen.
|
|
||||||
|
|
||||||
## Gruppen löschen
|
|
||||||
- Nur möglich, wenn keine Benutzer ausschließlich von dieser Gruppe abhängen (es erfolgt ein Warnhinweis).
|
|
||||||
- Löschen entfernt keine Benutzer, aber deren effektive Rechte können sich dadurch verringern.
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
- Erstelle zunächst eine Basisgruppe (z. B. „Viewer“) mit Leserechten für Stacks und Logs.
|
|
||||||
- Verwende separate Gruppen für sensible Aktionen wie Wartung oder Superuser-Verwaltung.
|
|
||||||
- Nutze sprechende Beschreibungen, damit Kolleg:innen sofort erkennen, wofür eine Gruppe gedacht ist.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Benutzerhandbuch
|
|
||||||
|
|
||||||
Dieses Kapitel beschreibt die Bedienung von StackPulse aus Sicht von Administrator:innen und Operators. Es orientiert sich an den Seiten im Dashboard und erklärt für jeden Bereich typische Abläufe sowie die notwendigen Berechtigungen. Screenshots kannst du später ergänzen; die Texte nennen bereits alle Buttons, Eingabefelder und Statusanzeigen.
|
|
||||||
|
|
||||||
**Reihenfolge**
|
|
||||||
1. [Setup](setup.md) – richtet Portainer-Anbindung und Superuser ein.
|
|
||||||
2. [Stacks](stacks.md) – zeigt alle Stacks, Redeploy-Aktionen und Filter.
|
|
||||||
3. [Logs](logs.md) – Nachvollziehbarkeit, Filter & Export.
|
|
||||||
4. [Wartung](maintenance.md) – Maintenance-Mode, Updates und SSH-Konfiguration.
|
|
||||||
5. [Benutzer](users.md) – Konten verwalten, Phrasen ausgeben, Passwörter setzen.
|
|
||||||
6. [Benutzergruppen & Rechte](groups.md) – Rollen modellieren und Berechtigungen zuweisen.
|
|
||||||
|
|
||||||
Jede Seite nennt die erforderlichen Permissions. Sobald du dich anmeldest, führt dich StackPulse zunächst automatisch durch den Setup-Assistenten; danach landet man auf der Stacks-Übersicht.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Logs-Seite
|
|
||||||
|
|
||||||
Die Logs-Seite dient der Nachvollziehbarkeit aller Aktionen (Redeploy, Login, Wartung, Benutzerverwaltung). Sie steht Benutzer:innen mit der Permission `logs-access` zur Verfügung.
|
|
||||||
|
|
||||||
## Überblick
|
|
||||||
- Filterleiste mit Suchfeld, Zeitspanne, Kategorie und Status.
|
|
||||||
- Tabelle mit Severity-Farben, Nachricht, Zeitstempel und Kontextinformationen.
|
|
||||||
- Aktionen oberhalb der Tabelle: **Exportieren** und **Löschen**.
|
|
||||||
|
|
||||||
## Filtern
|
|
||||||
1. **Zeitfenster:** Datumsfelder „Von“ und „Bis“ setzen. Standardmäßig zeigt StackPulse die letzten 24 Stunden.
|
|
||||||
2. **Kategorie/Status:** Dropdowns nutzen, um z. B. `redeploy` oder `wartung` auszuwählen.
|
|
||||||
3. **Freitext:** Suchfeld durchsucht `message`, `entity_name` und `metadata`.
|
|
||||||
4. **Erweiterte Filter:** Klick auf „Weitere Filter“ öffnet zusätzliche Optionen (Actor, Stack-ID, Severity).
|
|
||||||
|
|
||||||
## Export
|
|
||||||
- Button **CSV exportieren** klickst du erst, nachdem die Filter stehen. StackPulse erstellt eine CSV-Datei mit denselben Parametern.
|
|
||||||
- Der Download startet im Browser; in containerisierten Umgebungen kannst du denselben Endpoint (`/api/logs/export`) auch automatisiert aufrufen.
|
|
||||||
|
|
||||||
## Löschen
|
|
||||||
- Einzelne Logs: Papierkorb in der Zeile (Permission `logs-delete`).
|
|
||||||
- Batch-Löschung: Button **Gefilterte Einträge löschen**; es erscheint ein Sicherheitsdialog mit Zusammenfassung der aktiven Filter.
|
|
||||||
- Beachte, dass gelöschte Logs nicht wiederhergestellt werden können.
|
|
||||||
|
|
||||||
## Detailansicht
|
|
||||||
- Klick auf eine Tabellenzeile, um ein Seitenpanel mit vollständigem JSON (`metadata`), Kontext (Stack, Benutzer, Quelle) und ggf. Redeploy-Timings zu öffnen.
|
|
||||||
- Über **Stack öffnen** gelangst du direkt zur Stacks-Seite mit gesetztem Filter.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Wartungsbereich
|
|
||||||
|
|
||||||
Der Wartungsbereich bündelt Maintenance-Mode, Serververwaltung (inkl. Agent-Integration), Update-Skripte und SSH-Konfiguration. Sichtbar für Benutzer:innen mit `Bereich & Navigation (Wartung)`; Unterbereiche haben eigene Rechte:
|
|
||||||
|
|
||||||
**Relevante Rechte**
|
|
||||||
- `Bereich & Navigation (Wartung)`
|
|
||||||
- `Server-Sektion`
|
|
||||||
- `Server bearbeiten` / `Server löschen`
|
|
||||||
- `SSH/Update-Skript`
|
|
||||||
- `Update durchführen`
|
|
||||||
- `Agent`
|
|
||||||
- `Doppelte Stacks`
|
|
||||||
- `mTLS Sektion`
|
|
||||||
- `Superuser löschen`
|
|
||||||
|
|
||||||
- `Server-Sektion` (lesen) zeigt die Serverübersicht.
|
|
||||||
- `Server bearbeiten` (lesen) zeigt Server-Details inkl. Portainer-Status; Felder bleiben schreibgeschützt. Vollzugriff erlaubt Bearbeiten/Speichern.
|
|
||||||
- `Server löschen` löscht Server (nur bei Vollzugriff).
|
|
||||||
- `SSH/Update-Skript` und `Update durchführen` hängen am Unterpunkt Portainer/SSH und erfordern Vollzugriff auf Server-Edit.
|
|
||||||
- `Agent` (lesen/voll) steuert die Agent-Sektion im Server-Detail.
|
|
||||||
|
|
||||||
## Maintenance-Mode
|
|
||||||
1. Schalter **Wartungsmodus aktivieren** umlegen.
|
|
||||||
2. Jede Aktivierung/Deaktivierung wird in den Logs protokolliert.
|
|
||||||
|
|
||||||
## Serververwaltung & API-Key
|
|
||||||
- Liste zeigt alle eingetragenen Portainer-Server.
|
|
||||||
- **Server hinzufügen**/**bearbeiten**/**löschen** nur mit `Server bearbeiten` (Vollzugriff); löschen zusätzlich `Server löschen`.
|
|
||||||
- Im Server-Detail:
|
|
||||||
- **Server-URL/Name** und **Portainer API-Key**: editierbar nur mit `Server bearbeiten` Vollzugriff; bei Leserecht sichtbar, aber gesperrt.
|
|
||||||
- **Self-Stack-ID**: Stack-ID von StackPulse selbst; blockiert Redeploys dieses Stacks.
|
|
||||||
- **Status prüfen**: Live-Check gegen Portainer (Version, Edition, Update vorhanden). Sichtbar ab `Server bearbeiten` (lesen).
|
|
||||||
|
|
||||||
## Agent-Integration (Portainer CE / Edge)
|
|
||||||
Für die Community Edition von Stackpulse kannst du je Server einen StackPulse Agent hinterlegen. Er liefert Stack- und Portainer-Metadaten, wenn die Business-API fehlt. Hierzu muss auch der Agent auf dem Server der Portainer Instanz laufen. Der Agent wird für die Business Edition nicht benötigt.
|
|
||||||
|
|
||||||
1. Im Server-Detail **Agent konfigurieren** öffnen.
|
|
||||||
2. **Agent URL** eintragen, der **Agent Token** wird automatisch generiert und kann nicht geändert werden (Token = `AGENT_TOKEN` des Agents).
|
|
||||||
StackPulse prüft Erreichbarkeit und zeigt Online-/Fehlerstatus. Sichtbar mit `Agent` lesen, Bearbeitung mit `Agent` voll.
|
|
||||||
3. mTLS-Zertifikate verwalten:
|
|
||||||
- **Bootstrap-Token erzeugen** (Einmal-Token) und im Agent als `AGENT_BOOTSTRAP_TOKEN` setzen.
|
|
||||||
- Agent holt Zertifikate automatisch ab; Status im Zertifikate-Abschnitt.
|
|
||||||
- **Zertifikate anzeigen** (PEM) (nur bei entsprechenden Rechten)
|
|
||||||
4. Portainer-Version kann gelesen werden, wenn der Agent online ist.
|
|
||||||
|
|
||||||
## SSH-Konfiguration
|
|
||||||
Erfordert `SSH/Update-Skript` (sichtbar/bearbeitbar nur bei Vollzugriff auf Server-Edit).
|
|
||||||
1. Host, Port, Benutzername und Passwort sowie eventuell notwendige Extra-Argumente eintragen.
|
|
||||||
2. Passwort wird verschlüsselt gespeichert.
|
|
||||||
3. **Verbindung testen** prüft Erreichbarkeit und meldet Ergebnis direkt.
|
|
||||||
|
|
||||||
## Update-Skript & Portainer-Update
|
|
||||||
- Standard-Skript ist sichtbar und kann überschrieben werden (Recht `SSH/Update-Skript` + Server-Edit Vollzugriff). **Zurücksetzen** stellt den Standard wieder her.
|
|
||||||
- Start eines Updates (`Update durchführen` + Server-Edit Vollzugriff):
|
|
||||||
1) Wartungsmodus wird automatisch aktiviert.
|
|
||||||
2) SSH-Konfiguration und Skript prüfen.
|
|
||||||
3) **Update starten** klicken. Der Fortschritt und die Logausgaben erscheinen im Fenster.
|
|
||||||
4) Während ein Update läuft, sind Änderungen am System gesperrt.
|
|
||||||
|
|
||||||
## Fehlerbehandlung
|
|
||||||
- SSH-/Update-Fehler werden mit Klartextausgabe angezeigt. Bei Bedarf Host/Port/User, Passwort oder Skript anpassen.
|
|
||||||
- Agent offline: URL/Token prüfen, Bootstrap-Token ggf. neu erzeugen und den Agent neu starten.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Setup-Assistent
|
|
||||||
|
|
||||||
Beim ersten Start erscheint automatisch der Setup-Bereich und führt dich durch alle Schritte, bis die Einrichtung abgeschlossen ist.
|
|
||||||
|
|
||||||
## Voraussetzungen
|
|
||||||
- Portainer Business Edition mit gültigem API-Key
|
|
||||||
- Superuser-Zugangsdaten (entweder schon per Umgebungsvariablen gesetzt oder über diesen Assistenten festlegen)
|
|
||||||
|
|
||||||
## Schritt 1: Portainer-Server anlegen
|
|
||||||
1. Formularfelder **Server-URL** und optional **Name** ausfüllen.
|
|
||||||
- URL inkl. Protokoll (z. B. `https://portainer.example.com`).
|
|
||||||
- Bezeichnung dient ausschließlich als Anzeige im Dashboard.
|
|
||||||
2. Auf **Verbindung testen** klicken.
|
|
||||||
- Erfolgreich: grüner Hinweis, die Endpoints werden erkannt.
|
|
||||||
- Fehlgeschlagen: Fehlermeldung prüfen (häufig Zertifikats-/Firewall-Themen).
|
|
||||||
3. Mit **Speichern & Weiter** bestätigst du den Servereintrag. StackPulse legt ihn verschlüsselt in SQLite ab.
|
|
||||||
|
|
||||||
## Schritt 2: API-Key hinterlegen
|
|
||||||
1. API-Key im Feld **Portainer API Key** einfügen.
|
|
||||||
2. Optional kannst du über **Stacks laden** kontrollieren, ob Portainer Stacks zurückliefert.
|
|
||||||
3. Mit **Speichern** wird der Schlüssel verschlüsselt gespeichert. Du kannst ihn später im Maintenance-Bereich austauschen.
|
|
||||||
|
|
||||||
## Schritt 3: Superuser registrieren
|
|
||||||
1. Falls du `SUPERUSER_*` Variablen gesetzt hast, zeigt StackPulse an, dass bereits ein Superuser existiert – du kannst direkt weiter.
|
|
||||||
2. Ansonsten Benutzername, E-Mail und Passwort vergeben. Das Passwort dient nur für diesen Account; weitere Benutzer folgen später.
|
|
||||||
3. Auf **Superuser anlegen** klicken. Nach Erfolg wird der Login-Button eingeblendet.
|
|
||||||
|
|
||||||
## Abschluss & Login
|
|
||||||
- Sobald alle drei Schritte abgeschlossen sind, erscheint die Schaltfläche **Zum Login**.
|
|
||||||
- Melde dich mit dem Superuser an. StackPulse legt im Hintergrund initiale Berechtigungen, Default-Gruppen und Sicherheitspassphrasen an.
|
|
||||||
|
|
||||||
## Setup wiederholen oder ändern
|
|
||||||
- Über das Dashboard → **Wartung** → Abschnitt *Serververwaltung* kannst du zusätzliche Portainer-Server hinzufügen oder entfernen.
|
|
||||||
- Superuser lässt sich im Bereich **Benutzer** löschen, sofern du das Recht `Superuser löschen` besitzt.
|
|
||||||
- Der Self-Stack (StackPulse-eigener Stack) kann in der Wartung gepflegt werden, damit er nicht versehentlich redeployed wird.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Stacks-Seite
|
|
||||||
|
|
||||||
Die Stacks-Ansicht ist der Startpunkt nach dem Login und liefert einen Überblick über alle Stacks der angebundenen Portainer-Instanz.
|
|
||||||
|
|
||||||
**Relevante Rechte**
|
|
||||||
- `Redeploy einzeln`
|
|
||||||
- `Redeploy Auswahl`
|
|
||||||
- `Redeploy Alle`
|
|
||||||
|
|
||||||
## Aufbau
|
|
||||||
- **Header** mit Suchfeld, Filterchips für Status sowie Buttons für Redeploy-Aktionen.
|
|
||||||
- **Tabelle** mit Name, Endpoint, Status, letzte Aktion, Redeploy-Status und verfügbaren Buttons.
|
|
||||||
- **Live-Status**: Über Socket.IO wird jede Veränderung (queued/started/success/error) sofort eingeblendet.
|
|
||||||
|
|
||||||
## Filter & Suche
|
|
||||||
1. Verwende das Suchfeld oben rechts, um nach Namen oder IDs zu filtern.
|
|
||||||
2. Statuschips (z. B. „running“, „warning“) toggeln per Klick und lassen sich kombinieren.
|
|
||||||
3. Über die Spaltenköpfe kannst du sortieren; ein erneuter Klick kehrt die Reihenfolge um.
|
|
||||||
|
|
||||||
## Redeploy-Aktionen
|
|
||||||
| Aktion | Voraussetzung | Vorgehen |
|
|
||||||
|--------|---------------|----------|
|
|
||||||
| **Einzelner Stack** | Recht `Redeploy einzeln` | Button **Redeploy** in der jeweiligen Zeile klicken und bestätigen. |
|
|
||||||
| **Mehrere Stacks** | Recht `Redeploy Auswahl` | Checkboxen der gewünschten Stacks aktivieren, anschließend **Redeploy Auswahl**. |
|
|
||||||
| **Alle Stacks** | Recht `Redeploy Alle` | Button **Redeploy alle** auslösen. Nutze vorher Filter, um sicherzugehen, dass alle Stacks korrekt angezeigt werden. |
|
|
||||||
|
|
||||||
Während eines Redeploys erscheint pro Stack ein Fortschrittsbadge. Du kannst das Panel schließen; beim nächsten Besuch wird der aktuelle Status aus dem Backend gelesen.
|
|
||||||
|
|
||||||
## Redeploy-Status verstehen
|
|
||||||
- **Queued** – Auftrag ist vorgemerkt.
|
|
||||||
- **Started** – Portainer verarbeitet Redeploy.
|
|
||||||
- **Success** – Vorgang erfolgreich abgeschlossen.
|
|
||||||
- **Error** – Fehlerdetails findest du in der Logs-Seite.
|
|
||||||
- **Info** – Informative Hinweise, etwa wenn Portainer einen Stack überspringt.
|
|
||||||
|
|
||||||
## Selbstschutz
|
|
||||||
- Falls du die Variable `SELF_STACK_ID` gesetzt hast, markiert StackPulse den eigenen Stack als „geschützt“. Redeploy-Buttons werden deaktiviert und mit einem Info-Tooltip versehen.
|
|
||||||
|
|
||||||
## Troubleshooting aus der Ansicht
|
|
||||||
- Klicke auf das Warnsymbol in der Tabellenzeile, um Fehlerdetails einzublenden.
|
|
||||||
- Nutze den Link „Zu Logs springen“, der direkt eine gefilterte Ansicht in der Log-Seite öffnet (Stack-ID ist vorausgewählt).
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Benutzerverwaltung
|
|
||||||
|
|
||||||
Die Benutzerseite ermöglicht das Anlegen, Bearbeiten und Deaktivieren von Konten. Du benötigst mindestens die Permission `users-access` (lesen) bzw. `users-edit`/`users-delete` für Änderungen.
|
|
||||||
|
|
||||||
## Benutzer anlegen
|
|
||||||
1. Button **Benutzer anlegen** klicken.
|
|
||||||
2. Formularfelder ausfüllen:
|
|
||||||
- Benutzername (eindeutig, wird für Login genutzt)
|
|
||||||
- Anzeigename (optional)
|
|
||||||
- E-Mail-Adresse
|
|
||||||
- Passwort (kann später geändert werden)
|
|
||||||
- Gruppen auswählen (siehe Abschnitt „Benutzergruppen“)
|
|
||||||
3. Optional: Benutzer sofort aktivieren/deaktivieren.
|
|
||||||
4. Mit **Speichern** bestätigen. Der Benutzer erscheint in der Tabelle und erhält automatisch eine Sicherheitspassphrase.
|
|
||||||
|
|
||||||
## Benutzer bearbeiten
|
|
||||||
- In der Tabelle auf eine Zeile klicken, um das Detailpanel zu öffnen.
|
|
||||||
- Du kannst Anzeigename, E-Mail, Passwort und Gruppen ändern.
|
|
||||||
- Änderungen an Gruppen erfordern `users-edit` sowie Gruppenrechte (`user-groups-access`).
|
|
||||||
|
|
||||||
## Konto deaktivieren/reaktivieren
|
|
||||||
- Toggle **Aktiv** im Detailpanel.
|
|
||||||
- Deaktivierte Konten können sich nicht mehr anmelden, bleiben aber in der Historie.
|
|
||||||
|
|
||||||
## Sicherheitspassphrase
|
|
||||||
1. Button **Sicherheitspassphrase anzeigen** im Detailpanel.
|
|
||||||
2. StackPulse zeigt einen QR-Code und den alphanumerischen Code. Dieser wird nur einmal angezeigt, bis du bestätigst, dass er heruntergeladen wurde.
|
|
||||||
3. Über **Neu generieren** kannst du eine neue Phrase erstellen (Permission `users-security-phrase`). Informiere den Benutzer über die neue Phrase und markiere anschließend „heruntergeladen“.
|
|
||||||
|
|
||||||
## Passwort zurücksetzen
|
|
||||||
- Admins können direkt im Detailpanel ein neues Passwort setzen.
|
|
||||||
- Benutzer:innen selbst nutzen die Auth-Seite „Passwort vergessen“: Sie geben Username + Sicherheitsphrase ein und definieren anschließend ein neues Passwort.
|
|
||||||
|
|
||||||
## Suche & Filter
|
|
||||||
- Nutze das Suchfeld, um nach Benutzername oder E-Mail zu suchen.
|
|
||||||
- Filterchips für Status (aktiv/inaktiv) erleichtern das Auffinden gesperrter Accounts.
|
|
||||||
|
|
||||||
## Massenaktionen
|
|
||||||
- Mehrfachauswahl (Checkboxen) erlaubt das gleichzeitige Aktivieren/Deaktivieren oder Löschen mehrerer Benutzer. Vorsicht: Löschen entfernt auch Audit-Verweise.
|
|
||||||
+1083
-39
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "material-tailwind-dashboard-react",
|
|
||||||
"private": true,
|
|
||||||
"version": "2.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"axios": "1.7.5",
|
|
||||||
"socket.io-client": "4.8.1",
|
|
||||||
"@heroicons/react": "2.0.18",
|
|
||||||
"@material-tailwind/react": "2.1.4",
|
|
||||||
"apexcharts": "3.44.0",
|
|
||||||
"prop-types": "15.8.1",
|
|
||||||
"react": "18.2.0",
|
|
||||||
"react-apexcharts": "1.4.1",
|
|
||||||
"react-dom": "18.2.0",
|
|
||||||
"react-router-dom": "6.17.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "18.2.31",
|
|
||||||
"@types/react-dom": "18.2.14",
|
|
||||||
"@vitejs/plugin-react": "4.1.0",
|
|
||||||
"autoprefixer": "10.4.16",
|
|
||||||
"postcss": "8.4.31",
|
|
||||||
"prettier": "3.0.3",
|
|
||||||
"prettier-plugin-tailwindcss": "0.5.6",
|
|
||||||
"tailwindcss": "3.3.4",
|
|
||||||
"vite": "4.5.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
tailwindConfig: "./tailwind.config.cjs",
|
|
||||||
plugins: [require("prettier-plugin-tailwindcss")],
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { renderToString } from 'react-dom/server';
|
|
||||||
import pkg from '@material-tailwind/react';
|
|
||||||
const { Select, Option } = pkg;
|
|
||||||
|
|
||||||
const element = React.createElement(Select, {
|
|
||||||
label: 'Test',
|
|
||||||
value: 'b',
|
|
||||||
selected: (el) => el?.props.children,
|
|
||||||
onChange: () => {},
|
|
||||||
children: [
|
|
||||||
React.createElement(Option, { key: 'a', value: 'a' }, 'Alpha'),
|
|
||||||
React.createElement(Option, { key: 'b', value: 'b' }, 'Beta')
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(renderToString(element));
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
|
||||||
import { Dashboard, Auth } from "@/layouts";
|
|
||||||
import Setup from "@/pages/setup/setup";
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
|
||||||
<Route path="/auth/*" element={<Auth />} />
|
|
||||||
<Route path="/setup" element={<Setup />} />
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard/stacks" replace />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user