feat: orphan stack detection/deletion, filebrowser infra, setup scripts
- Add orphan detection: stacks not in catalog marked as "Elavult"
- Add DELETE /api/stacks/{name} endpoint with HDD data handling
- Add GET /api/stacks/{name}/hdd-data endpoint
- Add delete confirmation modal with HDD data checkbox (Hungarian UI)
- Add filebrowser to protected stacks list
- Add scripts/hdd-setup.sh and scripts/docker-setup.sh for node setup
- Hide "Frissítés" and "Részletek" buttons for orphaned stacks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ stacks:
|
||||
- "traefik"
|
||||
- "cloudflared"
|
||||
- "felhom-controller"
|
||||
- "filebrowser"
|
||||
update_window: "03:00-05:00"
|
||||
compose_command: ""
|
||||
|
||||
|
||||
@@ -83,6 +83,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case hasSuffix(path, "/logs") && req.Method == http.MethodGet:
|
||||
r.getStackLogs(w, req, extractName(path, "/logs"))
|
||||
|
||||
// GET /api/stacks/{name}/hdd-data
|
||||
case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet:
|
||||
r.getStackHDDData(w, req, extractName(path, "/hdd-data"))
|
||||
|
||||
// DELETE /api/stacks/{name}
|
||||
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
|
||||
r.deleteStack(w, req, trimSegment(path, "/stacks/"))
|
||||
|
||||
// POST /api/sync — trigger immediate catalog sync
|
||||
case path == "/sync" && req.Method == http.MethodPost:
|
||||
r.triggerSync(w, req)
|
||||
@@ -250,6 +258,45 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
|
||||
}
|
||||
|
||||
func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
resp, err := r.stackMgr.GetStackHDDData(name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
||||
}
|
||||
|
||||
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
r.logger.Printf("[API] Delete requested for stack: %s", name)
|
||||
|
||||
var body struct {
|
||||
RemoveHDDData bool `json:"remove_hdd_data"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
body.RemoveHDDData = false
|
||||
}
|
||||
|
||||
resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData)
|
||||
if err != nil {
|
||||
r.logger.Printf("[API] Delete failed for %s: %v", name, err)
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"})
|
||||
}
|
||||
|
||||
func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
r.logger.Println("[API] Manual catalog sync requested")
|
||||
result := r.syncer.TriggerSync()
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeleteResponse holds the result of a stack deletion.
|
||||
type DeleteResponse struct {
|
||||
Deleted string `json:"deleted"`
|
||||
VolumesRemoved []string `json:"volumes_removed"`
|
||||
HDDPathsRemoved []string `json:"hdd_paths_removed"`
|
||||
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
|
||||
}
|
||||
|
||||
// HDDDataResponse holds information about HDD data associated with a stack.
|
||||
type HDDDataResponse struct {
|
||||
Stack string `json:"stack"`
|
||||
HDDPaths []HDDPath `json:"hdd_paths"`
|
||||
HasHDDData bool `json:"has_hdd_data"`
|
||||
}
|
||||
|
||||
// HDDPath represents a single HDD bind mount path and its status.
|
||||
type HDDPath struct {
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SizeHuman string `json:"size_human"`
|
||||
Exists bool `json:"exists"`
|
||||
}
|
||||
|
||||
// protectedHDDPaths returns the set of top-level HDD directories that must never be deleted.
|
||||
func protectedHDDPaths(hddPath string) map[string]bool {
|
||||
if hddPath == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]bool{
|
||||
hddPath: true,
|
||||
filepath.Join(hddPath, "media"): true,
|
||||
filepath.Join(hddPath, "storage"): true,
|
||||
filepath.Join(hddPath, "Dokumentumok"): true,
|
||||
filepath.Join(hddPath, "appdata"): true,
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteStack removes an orphaned stack: stops containers, removes volumes,
|
||||
// optionally removes HDD data, and deletes the stack directory.
|
||||
func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, error) {
|
||||
// Safety: never delete protected stacks
|
||||
if m.cfg.IsProtectedStack(name) {
|
||||
return nil, fmt.Errorf("stack %q is protected and cannot be deleted", name)
|
||||
}
|
||||
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
// Must be orphaned
|
||||
if !stack.Orphaned {
|
||||
return nil, fmt.Errorf("stack %q is not orphaned — only orphaned stacks can be deleted", name)
|
||||
}
|
||||
|
||||
// Must be stopped (not running)
|
||||
if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting {
|
||||
return nil, fmt.Errorf("stack %q is still running — stop it first before deleting", name)
|
||||
}
|
||||
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
hddPath := m.cfg.Paths.HDDPath
|
||||
|
||||
m.logger.Printf("[INFO] Deleting orphaned stack: %s (removeHDDData=%v)", name, removeHDDData)
|
||||
start := time.Now()
|
||||
|
||||
resp := &DeleteResponse{
|
||||
Deleted: name,
|
||||
}
|
||||
|
||||
// Step 1: Parse compose file for HDD bind mounts
|
||||
hddMounts := parseComposeHDDMounts(stack.ComposePath, hddPath)
|
||||
|
||||
// Step 2: Run docker compose down --rmi local --volumes
|
||||
env := m.stackEnv(stackDir)
|
||||
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--rmi", "local", "--volumes")
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] docker compose down for %s had errors: %v (output: %s)", name, err, truncateStr(output, 200))
|
||||
// Continue anyway — the stack dir will be removed
|
||||
}
|
||||
|
||||
// Step 3: Identify removed volumes from compose output
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.Contains(line, "Removing volume") || strings.Contains(line, "Volume") {
|
||||
resp.VolumesRemoved = append(resp.VolumesRemoved, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Handle HDD data
|
||||
protected := protectedHDDPaths(hddPath)
|
||||
for _, mount := range hddMounts {
|
||||
// Safety: never delete protected top-level dirs
|
||||
cleanPath := filepath.Clean(mount)
|
||||
if protected != nil && protected[cleanPath] {
|
||||
m.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
|
||||
continue // path doesn't exist, nothing to do
|
||||
}
|
||||
|
||||
if removeHDDData {
|
||||
// Get size before removal
|
||||
sizeHuman := getDirSizeHuman(cleanPath)
|
||||
if err := os.RemoveAll(cleanPath); err != nil {
|
||||
m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err)
|
||||
} else {
|
||||
m.logger.Printf("[INFO] Removed HDD data: %s (%s)", cleanPath, sizeHuman)
|
||||
resp.HDDPathsRemoved = append(resp.HDDPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
|
||||
}
|
||||
} else {
|
||||
sizeHuman := getDirSizeHuman(cleanPath)
|
||||
resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Remove stack directory
|
||||
if err := os.RemoveAll(stackDir); err != nil {
|
||||
m.logger.Printf("[ERROR] Failed to remove stack directory %s: %v", stackDir, err)
|
||||
return resp, fmt.Errorf("failed to remove stack directory: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s deleted successfully (took %.1fs)", name, time.Since(start).Seconds())
|
||||
|
||||
// Step 6: Remove from in-memory map and rescan
|
||||
m.mu.Lock()
|
||||
delete(m.stacks, name)
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
m.logger.Printf("[WARN] Rescan after delete failed: %v", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetStackHDDData returns information about HDD bind mounts for a stack.
|
||||
func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
hddPath := m.cfg.Paths.HDDPath
|
||||
resp := &HDDDataResponse{
|
||||
Stack: name,
|
||||
}
|
||||
|
||||
if hddPath == "" {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
mounts := parseComposeHDDMounts(stack.ComposePath, hddPath)
|
||||
protected := protectedHDDPaths(hddPath)
|
||||
|
||||
for _, mount := range mounts {
|
||||
cleanPath := filepath.Clean(mount)
|
||||
|
||||
// Skip protected top-level dirs
|
||||
if protected != nil && protected[cleanPath] {
|
||||
continue
|
||||
}
|
||||
|
||||
hddItem := HDDPath{
|
||||
Path: cleanPath,
|
||||
}
|
||||
|
||||
info, err := os.Stat(cleanPath)
|
||||
if err != nil {
|
||||
hddItem.Exists = false
|
||||
} else {
|
||||
hddItem.Exists = true
|
||||
if info.IsDir() {
|
||||
hddItem.SizeBytes = getDirSizeBytes(cleanPath)
|
||||
hddItem.SizeHuman = getDirSizeHuman(cleanPath)
|
||||
}
|
||||
}
|
||||
|
||||
resp.HDDPaths = append(resp.HDDPaths, hddItem)
|
||||
}
|
||||
|
||||
resp.HasHDDData = len(resp.HDDPaths) > 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// parseComposeHDDMounts reads a docker-compose.yml and extracts host paths
|
||||
// that reference the HDD path from volume bind mounts.
|
||||
func parseComposeHDDMounts(composePath, hddPath string) []string {
|
||||
if hddPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var mounts []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
inVolumes := false
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Track when we're in a volumes section (service-level, not top-level)
|
||||
if strings.HasPrefix(line, "volumes:") {
|
||||
inVolumes = true
|
||||
continue
|
||||
}
|
||||
if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" {
|
||||
inVolumes = false
|
||||
}
|
||||
|
||||
if !inVolumes || !strings.HasPrefix(line, "- ") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse bind mount: "- /host/path:/container/path:options"
|
||||
mountStr := strings.TrimPrefix(line, "- ")
|
||||
mountStr = strings.Trim(mountStr, "\"'")
|
||||
|
||||
parts := strings.SplitN(mountStr, ":", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
hostPath := parts[0]
|
||||
|
||||
// Resolve ${HDD_PATH} variable reference
|
||||
hostPath = strings.ReplaceAll(hostPath, "${HDD_PATH}", hddPath)
|
||||
|
||||
// Check if this is an HDD mount
|
||||
if !strings.HasPrefix(hostPath, hddPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(hostPath)
|
||||
if !seen[cleanPath] {
|
||||
seen[cleanPath] = true
|
||||
mounts = append(mounts, cleanPath)
|
||||
}
|
||||
}
|
||||
|
||||
return mounts
|
||||
}
|
||||
|
||||
// getDirSizeHuman returns a human-readable size string for a directory using du.
|
||||
func getDirSizeHuman(path string) string {
|
||||
cmd := exec.Command("du", "-sh", path)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
fields := strings.Fields(string(output))
|
||||
if len(fields) > 0 {
|
||||
return fields[0]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// getDirSizeBytes returns the total size in bytes for a directory.
|
||||
func getDirSizeBytes(path string) int64 {
|
||||
cmd := exec.Command("du", "-sb", path)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(output))
|
||||
if len(fields) > 0 {
|
||||
var size int64
|
||||
fmt.Sscanf(fields[0], "%d", &size)
|
||||
return size
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
StatePaused ContainerState = "paused"
|
||||
StateUnknown ContainerState = "unknown"
|
||||
StateNotDeployed ContainerState = "not_deployed"
|
||||
StateOrphaned ContainerState = "orphaned"
|
||||
)
|
||||
|
||||
// ContainerInfo holds status info about a single container within a stack.
|
||||
@@ -47,6 +48,7 @@ type Stack struct {
|
||||
State ContainerState `json:"state"`
|
||||
Deployed bool `json:"deployed"` // Has app.yaml with deployed=true
|
||||
Protected bool `json:"protected"`
|
||||
Orphaned bool `json:"orphaned"` // Deployed but no catalog template
|
||||
Containers []ContainerInfo `json:"containers"`
|
||||
AppConfig *AppConfig `json:"app_config,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
@@ -166,6 +168,25 @@ func (m *Manager) ScanStacks() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect orphaned stacks (deployed but no longer in catalog)
|
||||
catalogTemplates := m.getCatalogTemplateSlugs()
|
||||
if catalogTemplates != nil {
|
||||
orphanCount := 0
|
||||
for _, stack := range m.stacks {
|
||||
if stack.Protected || !stack.Deployed {
|
||||
stack.Orphaned = false
|
||||
continue
|
||||
}
|
||||
stack.Orphaned = !catalogTemplates[stack.Name]
|
||||
if stack.Orphaned {
|
||||
orphanCount++
|
||||
}
|
||||
}
|
||||
if orphanCount > 0 {
|
||||
m.logger.Printf("[INFO] Detected %d orphaned stack(s)", orphanCount)
|
||||
}
|
||||
}
|
||||
|
||||
deployedCount := 0
|
||||
for _, s := range m.stacks {
|
||||
if s.Deployed {
|
||||
@@ -733,4 +754,25 @@ func (m *Manager) CommittedMemory() (requestMB int, limitMB int) {
|
||||
limitMB += ParseMemoryMB(s.Meta.Resources.MemLimit)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// getCatalogTemplateSlugs reads the synced catalog cache and returns a set of
|
||||
// template slugs (directory names) that have a docker-compose.yml.
|
||||
func (m *Manager) getCatalogTemplateSlugs() map[string]bool {
|
||||
cacheDir := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates")
|
||||
entries, err := os.ReadDir(cacheDir)
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] Cannot read catalog cache for orphan detection: %v", err)
|
||||
return nil
|
||||
}
|
||||
slugs := make(map[string]bool, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
composePath := filepath.Join(cacheDir, e.Name(), "docker-compose.yml")
|
||||
if _, err := os.Stat(composePath); err == nil {
|
||||
slugs[e.Name()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
@@ -117,6 +117,84 @@ const layoutTmpl = `
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
async function deleteOrphanStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'delete-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
|
||||
var data = await resp.json();
|
||||
var hddInfo = '';
|
||||
var checkboxHTML = '';
|
||||
if (data.ok && data.data && data.data.has_hdd_data) {
|
||||
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
|
||||
data.data.hdd_paths.forEach(function(p) {
|
||||
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
hddInfo += '</div>';
|
||||
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-secondary);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
hddInfo + checkboxHTML +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
|
||||
'</div>';
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-secondary)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeDeleteModal() {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmDelete(name) {
|
||||
var btn = document.getElementById('confirm-delete-btn');
|
||||
var checkbox = document.getElementById('delete-hdd-check');
|
||||
var removeHDD = checkbox ? checkbox.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Törlés folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name, {
|
||||
method: 'DELETE',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo = '<p style="color:var(--text-secondary);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-secondary);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen törölve!</h3>' +
|
||||
'<p style="color:var(--text-secondary)">Az alkalmazás (' + name + ') törölve lett.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -198,6 +276,7 @@ const dashboardTmpl = `
|
||||
</div>
|
||||
<div class="stack-actions">
|
||||
<span class="stack-state-label">{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
|
||||
{{if .Protected}}
|
||||
<span class="badge badge-protected">Védett</span>
|
||||
@@ -211,6 +290,7 @@ const dashboardTmpl = `
|
||||
<button class="btn btn-sm btn-success" onclick="stackAction('{{.Name}}', 'start')">▶</button>
|
||||
{{end}}
|
||||
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
{{if .Orphaned}}<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Name}}')">Törlés</button>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,6 +333,7 @@ const stacksTmpl = `
|
||||
</div>
|
||||
</div>
|
||||
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
|
||||
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Meta.Description}}
|
||||
@@ -284,14 +365,15 @@ const stacksTmpl = `
|
||||
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
|
||||
{{else}}
|
||||
{{if isOperational .State}}
|
||||
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'update')">Frissítés</button>
|
||||
{{if not .Orphaned}}<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'update')">Frissítés</button>{{end}}
|
||||
<button class="btn btn-warning" onclick="stackAction('{{.Name}}', 'restart')">Újraindítás</button>
|
||||
<button class="btn btn-danger" onclick="stackAction('{{.Name}}', 'stop')">Leállítás</button>
|
||||
{{else}}
|
||||
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'start')">Indítás</button>
|
||||
{{end}}
|
||||
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">Naplók</a>
|
||||
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
|
||||
{{if not .Orphaned}}<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>{{end}}
|
||||
{{if .Orphaned}}<button class="btn btn-danger" onclick="deleteOrphanStack('{{.Name}}')">Törlés</button>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -753,9 +835,14 @@ const appInfoTmpl = `
|
||||
<div style="display:flex;align-items:center;gap:.5rem">
|
||||
{{if .Stack.Deployed}}
|
||||
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
|
||||
{{if .Stack.Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||
<a href="https://{{.Meta.Subdomain}}.{{.Domain}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>
|
||||
<a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">Beállítások</a>
|
||||
{{if .Stack.Orphaned}}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">Törlés</button>
|
||||
{{else}}
|
||||
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">Beállítások</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Stack.Name}}')">Telepítés</a>
|
||||
{{end}}
|
||||
@@ -1980,6 +2067,74 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
|
||||
color: var(--green) !important;
|
||||
}
|
||||
|
||||
/* Orphan badge */
|
||||
.badge-orphaned {
|
||||
background: var(--orange-bg);
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
/* Delete modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.modal-card h3 {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
.modal-hdd-info {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: .75rem 1rem;
|
||||
margin: .75rem 0;
|
||||
font-size: .85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.modal-hdd-path {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: .8rem;
|
||||
color: var(--text-muted);
|
||||
padding: .2rem 0;
|
||||
}
|
||||
.modal-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
margin: .75rem 0;
|
||||
padding: .75rem 1rem;
|
||||
background: var(--red-bg);
|
||||
border: 1px solid rgba(218, 54, 51, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: .85rem;
|
||||
color: var(--red);
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-checkbox input[type="checkbox"] {
|
||||
accent-color: var(--red);
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
margin-top: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media(max-width: 768px) {
|
||||
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user