update
This commit is contained in:
34
.env.example
34
.env.example
@@ -1,14 +1,40 @@
|
|||||||
# Nach .env kopieren und Werte setzen. Die .env ist die EINZIGE Stelle, an der
|
# Nach .env kopieren und Werte setzen. Die .env ist die EINZIGE Stelle, an der
|
||||||
# Modelle stehen — Code enthält keine Defaults und keine Fallbacks (Leitprinzip 2).
|
# Modelle stehen — Code enthält keine Defaults und keine Fallbacks (Leitprinzip 2).
|
||||||
|
# Die .env wird nur beim Serverstart gelesen — nach Änderungen Backend neu starten.
|
||||||
|
|
||||||
|
# --- Provider-Zugang ---------------------------------------------------------
|
||||||
|
# Pflicht, sobald eine Rolle den Provider minimax nutzt. Fehlt oder leer =>
|
||||||
|
# Vorflug stoppt den Lauf hart. Wird auch von dev-ops/opencode-slim.json gelesen.
|
||||||
MINIMAX_API_KEY=
|
MINIMAX_API_KEY=
|
||||||
|
|
||||||
# Rollen-Belegung (Format provider:modell). Start-Belegung: alles MiniMax.
|
# --- Rollen-Belegung (Pflicht, Format: provider:modell) -----------------------
|
||||||
|
# provider: minimax (opencode-CLI) oder claude. Fehlt/leer/ungültig => harter
|
||||||
|
# Stopp, nie ein stiller Default.
|
||||||
|
# modell bei minimax: <opencode-provider>/<modell> aus dev-ops/opencode-slim.json.
|
||||||
|
# Belegte MiniMax-Modelle:
|
||||||
|
# minimax/MiniMax-M3 nativ, mit Thinking — stärkstes (0 Fehler in Phase 0)
|
||||||
|
# minimax-kalt/MiniMax-M3 kalt, temperature 0.2, ohne Thinking
|
||||||
|
# minimax-kalt/MiniMax-M2.7-highspeed kalt, schwächer bei wörtlichen Zitaten
|
||||||
|
# Start-Belegung: alles MiniMax-M3 nativ (Evidenz: phase0/erkenntnisse.md, Erk. 2+3).
|
||||||
ROLLE_SCHNEIDEN=minimax:minimax/MiniMax-M3
|
ROLLE_SCHNEIDEN=minimax:minimax/MiniMax-M3
|
||||||
ROLLE_NACHFASS=minimax:minimax/MiniMax-M3
|
ROLLE_NACHFASS=minimax:minimax/MiniMax-M3
|
||||||
ROLLE_SICHTEN=minimax:minimax/MiniMax-M3
|
ROLLE_SICHTEN=minimax:minimax/MiniMax-M3
|
||||||
ROLLE_JUDGE=minimax:minimax/MiniMax-M3
|
ROLLE_JUDGE=minimax:minimax/MiniMax-M3
|
||||||
|
|
||||||
# Engine-Tuning (optional, Defaults siehe backend/config.py)
|
# --- Engine-Tuning (optional; Defaults im Code, backend/config.py) -------------
|
||||||
# MAX_CONCURRENT_AGENTS=6
|
# Zum Ändern Zeile einkommentieren UND Wert setzen — nie leer lassen
|
||||||
# RAM_MIN_FREE_PCT=20
|
# (leerer Wert crasht beim Import, int("")).
|
||||||
|
# KANBAN_BATCH=4 # Karten je Paket
|
||||||
|
# MAX_CARD_RETRIES=3 # danach Dead-Letter
|
||||||
|
# RETRY_BACKOFF=30 # Basis-Sekunden, exponentiell
|
||||||
|
# MAX_CONCURRENT_AGENTS=6 # Prozess-Tier (opencode/claude), ~310 MB je Agent
|
||||||
|
# MAX_CONCURRENT_AGENTS_PER_TOPIC=6 # Cap je Projekt
|
||||||
|
# MAX_CONCURRENT_API_AGENTS=12 # direkter API-Tier (~0 RAM)
|
||||||
|
# RAM_MIN_FREE_PCT=20 # 0 = RAM-Gate aus
|
||||||
|
# AGENT_TIMEOUT=360 # Sekunden je LLM-Call
|
||||||
|
# OPENCODE_START_DELAY=0.5 # Pause zwischen opencode-Kaltstarts (Sek.)
|
||||||
|
|
||||||
|
# --- Schalter (optional) -------------------------------------------------------
|
||||||
|
# PLANER_TEXT_API=1 # 1 = direkter MiniMax-API-Pfad für tool-lose Calls; 0 = aus
|
||||||
|
# PLANER_FAKE_AGENTS=1 # 1 = deterministische Fake-Agenten (Tests, kein Key nötig);
|
||||||
|
# # leer oder 0 = aus
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import httpx
|
|||||||
|
|
||||||
from config import (MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_AGENTS_PER_TOPIC,
|
from config import (MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_AGENTS_PER_TOPIC,
|
||||||
MAX_CONCURRENT_API_AGENTS, OPENCODE_CONFIG, PROVIDERS,
|
MAX_CONCURRENT_API_AGENTS, OPENCODE_CONFIG, PROVIDERS,
|
||||||
RAM_MIN_FREE_PCT, resolve_role)
|
RAM_MIN_FREE_PCT, fake_agents_aktiv, resolve_role)
|
||||||
|
|
||||||
log = logging.getLogger("planer.agents")
|
log = logging.getLogger("planer.agents")
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ async def run_agent(
|
|||||||
"""Ein LLM-Call der Rolle `role`. → (rc, stdout, stderr). Wirft AgentInfraError,
|
"""Ein LLM-Call der Rolle `role`. → (rc, stdout, stderr). Wirft AgentInfraError,
|
||||||
wenn die Infrastruktur nicht nutzbar ist (Rolle unkonfiguriert, Provider down,
|
wenn die Infrastruktur nicht nutzbar ist (Rolle unkonfiguriert, Provider down,
|
||||||
leere Antwort ohne Session) — der Lauf soll dann pausieren, nicht raten."""
|
leere Antwort ohne Session) — der Lauf soll dann pausieren, nicht raten."""
|
||||||
if os.getenv("PLANER_FAKE_AGENTS"): # Sekunden-Smoke: deterministisch statt LLM
|
if fake_agents_aktiv(): # Sekunden-Smoke: deterministisch statt LLM
|
||||||
import fake_agents
|
import fake_agents
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
res = await fake_agents.respond(agent_key, prompt, capabilities)
|
res = await fake_agents.respond(agent_key, prompt, capabilities)
|
||||||
@@ -514,7 +514,10 @@ async def _run_text_api(agent_key: str, prompt: str, timeout: int, model: str,
|
|||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
**_api_model_opts(model),
|
**_api_model_opts(model),
|
||||||
}
|
}
|
||||||
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": _API_VERSION}
|
api_key = os.environ.get("MINIMAX_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise AgentInfraError("MINIMAX_API_KEY fehlt — in .env setzen")
|
||||||
|
headers = {"x-api-key": api_key, "anthropic-version": _API_VERSION}
|
||||||
track_key = agent_key
|
track_key = agent_key
|
||||||
n = 2
|
n = 2
|
||||||
while track_key in _active_api:
|
while track_key in _active_api:
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ def resolve_role(role: str) -> tuple[str, str]:
|
|||||||
return provider, modell
|
return provider, modell
|
||||||
|
|
||||||
|
|
||||||
|
def fake_agents_aktiv() -> bool:
|
||||||
|
"""PLANER_FAKE_AGENTS: leer oder "0" = aus. Ein truthy "0" würde sonst still den
|
||||||
|
Fake-Modus (und damit den Rollen-Vorflug-Check) aktivieren."""
|
||||||
|
return os.getenv("PLANER_FAKE_AGENTS", "").strip() not in ("", "0")
|
||||||
|
|
||||||
|
|
||||||
# --- Engine-Konstanten (Kanban + Agenten) -----------------------------------------
|
# --- Engine-Konstanten (Kanban + Agenten) -----------------------------------------
|
||||||
KANBAN_BATCH = int(os.getenv("KANBAN_BATCH", "4")) # Karten je Paket
|
KANBAN_BATCH = int(os.getenv("KANBAN_BATCH", "4")) # Karten je Paket
|
||||||
MAX_CARD_RETRIES = int(os.getenv("MAX_CARD_RETRIES", "3")) # dann dead-letter
|
MAX_CARD_RETRIES = int(os.getenv("MAX_CARD_RETRIES", "3")) # dann dead-letter
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Projektliste: konfigurierte Pfade aus projekte.txt (eine Zeile = absoluter Pfad,
|
"""Projektliste: konfigurierte Pfade aus projekte.txt (eine Zeile = absoluter Pfad,
|
||||||
#-Kommentare). Von Hand editierbar — UI-Verwaltung kommt später (YAGNI)."""
|
#-Kommentare). Von Hand editierbar; hinzufuegen/entfernen bedienen die UI."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import config
|
import config
|
||||||
@@ -34,3 +34,41 @@ def finde(name: str) -> Path | None:
|
|||||||
if p["name"] == name and "fehler" not in p:
|
if p["name"] == name and "fehler" not in p:
|
||||||
return Path(p["pfad"])
|
return Path(p["pfad"])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def hinzufuegen(pfad: str) -> None:
|
||||||
|
"""Hängt einen Pfad an projekte.txt an. Wirft ValueError bei ungültigem
|
||||||
|
Verzeichnis, Duplikat oder Basename-Kollision."""
|
||||||
|
p = Path(pfad).expanduser()
|
||||||
|
if not p.is_absolute():
|
||||||
|
raise ValueError("Pfad muss absolut sein")
|
||||||
|
if not p.is_dir():
|
||||||
|
raise ValueError(f"{p} ist kein Verzeichnis")
|
||||||
|
for e in lade():
|
||||||
|
if e["pfad"] == str(p):
|
||||||
|
raise ValueError(f"{p} ist schon eingetragen")
|
||||||
|
if e["name"] == p.name:
|
||||||
|
raise ValueError(f"Namenskollision mit {e['pfad']}")
|
||||||
|
text = ""
|
||||||
|
if config.PROJEKTE_DATEI.is_file():
|
||||||
|
text = config.PROJEKTE_DATEI.read_text(encoding="utf-8")
|
||||||
|
if text and not text.endswith("\n"):
|
||||||
|
text += "\n"
|
||||||
|
config.PROJEKTE_DATEI.write_text(text + str(p) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def entfernen(name: str) -> None:
|
||||||
|
"""Streicht den Eintrag aus projekte.txt — nur die Zeile; .planer/ im Repo
|
||||||
|
bleibt unberührt. Wirft ValueError, wenn der Name nicht eingetragen ist."""
|
||||||
|
if not config.PROJEKTE_DATEI.is_file():
|
||||||
|
raise ValueError(f"'{name}' nicht in projekte.txt")
|
||||||
|
rest, getroffen = [], False
|
||||||
|
for zeile in config.PROJEKTE_DATEI.read_text(encoding="utf-8").splitlines():
|
||||||
|
s = zeile.strip()
|
||||||
|
if s and not s.startswith("#") and Path(s).expanduser().name == name:
|
||||||
|
getroffen = True
|
||||||
|
continue
|
||||||
|
rest.append(zeile)
|
||||||
|
if not getroffen:
|
||||||
|
raise ValueError(f"'{name}' nicht in projekte.txt")
|
||||||
|
config.PROJEKTE_DATEI.write_text("\n".join(rest) + ("\n" if rest else ""), encoding="utf-8")
|
||||||
|
|||||||
@@ -24,6 +24,47 @@ async def health():
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fs")
|
||||||
|
async def fs(pfad: str = ""):
|
||||||
|
"""Ordner-Browser für die Projekt-Auswahl: nur Verzeichnisse, keine versteckten."""
|
||||||
|
basis = Path(pfad).expanduser() if pfad else Path.home()
|
||||||
|
if not basis.is_dir():
|
||||||
|
raise HTTPException(404, f"{basis} ist kein Verzeichnis")
|
||||||
|
basis = basis.resolve()
|
||||||
|
try:
|
||||||
|
kinder = sorted(basis.iterdir(), key=lambda p: p.name.lower())
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(403, f"kein Zugriff auf {basis}")
|
||||||
|
ordner = [{"name": k.name, "pfad": str(k), "hat_planer": (k / ".planer").is_dir()}
|
||||||
|
for k in kinder if k.is_dir() and not k.name.startswith(".")]
|
||||||
|
eltern = str(basis.parent) if basis.parent != basis else None
|
||||||
|
return {"pfad": str(basis), "eltern": eltern, "ordner": ordner}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projekte")
|
||||||
|
async def projekt_hinzufuegen(body: dict):
|
||||||
|
pfad = (body.get("pfad") or "").strip()
|
||||||
|
if not pfad:
|
||||||
|
raise HTTPException(400, "pfad fehlt")
|
||||||
|
try:
|
||||||
|
projekte.hinzufuegen(pfad)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(409, str(e))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projekte/{name}")
|
||||||
|
async def projekt_entfernen(name: str):
|
||||||
|
lauf = scan_dienst.status(name)
|
||||||
|
if lauf and lauf["laeuft"]:
|
||||||
|
raise HTTPException(409, "Scan läuft — erst abwarten oder abbrechen")
|
||||||
|
try:
|
||||||
|
projekte.entfernen(name)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/projekte")
|
@router.get("/projekte")
|
||||||
async def liste():
|
async def liste():
|
||||||
out = []
|
out = []
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def vorflug(wurzel: Path) -> str:
|
|||||||
raise VorflugFehler(f"{wurzel} ist kein git-Repo — Anker brauchen Commit-Hashes (git init && commit)")
|
raise VorflugFehler(f"{wurzel} ist kein git-Repo — Anker brauchen Commit-Hashes (git init && commit)")
|
||||||
if not repo.working_tree_sauber(wurzel):
|
if not repo.working_tree_sauber(wurzel):
|
||||||
raise VorflugFehler(f"{wurzel} hat uncommittete Änderungen — erst committen, dann scannen")
|
raise VorflugFehler(f"{wurzel} hat uncommittete Änderungen — erst committen, dann scannen")
|
||||||
if not os.getenv("PLANER_FAKE_AGENTS"):
|
if not config.fake_agents_aktiv():
|
||||||
for rolle in config.ROLLEN:
|
for rolle in config.ROLLEN:
|
||||||
try:
|
try:
|
||||||
provider, _ = config.resolve_role(rolle)
|
provider, _ = config.resolve_role(rolle)
|
||||||
|
|||||||
@@ -85,3 +85,34 @@ async def test_scan_409_bei_dirty_tree(client):
|
|||||||
r = await c.post("/api/projekte/mini/scan", json={"art": "scan"})
|
r = await c.post("/api/projekte/mini/scan", json={"art": "scan"})
|
||||||
assert r.status_code == 409
|
assert r.status_code == 409
|
||||||
assert "uncommittete" in r.json()["detail"]
|
assert "uncommittete" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fs_browse(client, tmp_path):
|
||||||
|
c, _, _ = client
|
||||||
|
(tmp_path / "unter" / ".planer").mkdir(parents=True)
|
||||||
|
(tmp_path / ".versteckt").mkdir()
|
||||||
|
r = await c.get("/api/fs", params={"pfad": str(tmp_path)})
|
||||||
|
daten = r.json()
|
||||||
|
assert daten["pfad"] == str(tmp_path.resolve())
|
||||||
|
assert daten["eltern"] == str(tmp_path.resolve().parent)
|
||||||
|
ordner = {o["name"]: o for o in daten["ordner"]}
|
||||||
|
assert ordner["unter"]["hat_planer"] is True
|
||||||
|
assert ".versteckt" not in ordner
|
||||||
|
assert (await c.get("/api/fs", params={"pfad": str(tmp_path / "fehlt")})).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_projekt_hinzufuegen_und_entfernen(client, tmp_path):
|
||||||
|
c, _, _ = client
|
||||||
|
neu = tmp_path / "neues-repo"
|
||||||
|
(neu / ".planer").mkdir(parents=True)
|
||||||
|
|
||||||
|
assert (await c.post("/api/projekte", json={"pfad": str(neu)})).status_code == 200
|
||||||
|
namen = [p["name"] for p in (await c.get("/api/projekte")).json()]
|
||||||
|
assert "neues-repo" in namen and "mini" in namen
|
||||||
|
assert (await c.post("/api/projekte", json={"pfad": str(neu)})).status_code == 409
|
||||||
|
|
||||||
|
assert (await c.delete("/api/projekte/neues-repo")).status_code == 200
|
||||||
|
namen = [p["name"] for p in (await c.get("/api/projekte")).json()]
|
||||||
|
assert "neues-repo" not in namen and "mini" in namen
|
||||||
|
assert (neu / ".planer").is_dir() # Artefakte bleiben erhalten
|
||||||
|
assert (await c.delete("/api/projekte/neues-repo")).status_code == 404
|
||||||
|
|||||||
1
frontend/dist/assets/index-AxkPXHNh.css
vendored
1
frontend/dist/assets/index-AxkPXHNh.css
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-CzKrDvqu.css
vendored
Normal file
1
frontend/dist/assets/index-CzKrDvqu.css
vendored
Normal file
File diff suppressed because one or more lines are too long
64
frontend/dist/assets/index-DIiv29cS.js
vendored
64
frontend/dist/assets/index-DIiv29cS.js
vendored
File diff suppressed because one or more lines are too long
64
frontend/dist/assets/index-PAbaEjVj.js
vendored
Normal file
64
frontend/dist/assets/index-PAbaEjVj.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
@@ -13,8 +13,8 @@
|
|||||||
if (dark) document.documentElement.classList.add('dark')
|
if (dark) document.documentElement.classList.add('dark')
|
||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-DIiv29cS.js"></script>
|
<script type="module" crossorigin src="/assets/index-PAbaEjVj.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-AxkPXHNh.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CzKrDvqu.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -4,10 +4,19 @@ import { fetchProjekte } from './api.js'
|
|||||||
import { usePolling } from './composables/usePolling.js'
|
import { usePolling } from './composables/usePolling.js'
|
||||||
import StartSeite from './components/StartSeite.vue'
|
import StartSeite from './components/StartSeite.vue'
|
||||||
import ProjektAnsicht from './components/ProjektAnsicht.vue'
|
import ProjektAnsicht from './components/ProjektAnsicht.vue'
|
||||||
|
import OrdnerBrowser from './components/OrdnerBrowser.vue'
|
||||||
|
|
||||||
const projekte = ref([])
|
const projekte = ref([])
|
||||||
const aktivesProjekt = ref(null) // null = Übersicht
|
const aktivesProjekt = ref(null) // null = Übersicht
|
||||||
const ansicht = ref('features')
|
const ansicht = ref('features')
|
||||||
|
const zeigeBrowser = ref(false)
|
||||||
|
const listeVersion = ref(0) // remountet StartSeite nach Hinzufügen
|
||||||
|
|
||||||
|
function hinzugefuegt() {
|
||||||
|
zeigeBrowser.value = false
|
||||||
|
listeVersion.value++
|
||||||
|
laden()
|
||||||
|
}
|
||||||
|
|
||||||
const ANSICHTEN = [
|
const ANSICHTEN = [
|
||||||
['features', 'Features', '▦'],
|
['features', 'Features', '▦'],
|
||||||
@@ -55,6 +64,7 @@ function toggleDark() {
|
|||||||
<span v-else-if="p.drift?.length" class="punkt gelb" title="Drift"></span>
|
<span v-else-if="p.drift?.length" class="punkt gelb" title="Drift"></span>
|
||||||
<span v-else-if="p.hat_planer" class="punkt gruen" title="aktuell"></span>
|
<span v-else-if="p.hat_planer" class="punkt gruen" title="aktuell"></span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#" class="hinzu" @click.prevent="zeigeBrowser = true">+ Projekt hinzufügen</a>
|
||||||
</nav>
|
</nav>
|
||||||
<nav v-if="aktivesProjekt" class="ansichten">
|
<nav v-if="aktivesProjekt" class="ansichten">
|
||||||
<span class="gruppe">{{ aktivesProjekt }}</span>
|
<span class="gruppe">{{ aktivesProjekt }}</span>
|
||||||
@@ -66,9 +76,11 @@ function toggleDark() {
|
|||||||
<button class="dark-knopf" @click="toggleDark">{{ darkMode ? '☀ Hell' : '☾ Dunkel' }}</button>
|
<button class="dark-knopf" @click="toggleDark">{{ darkMode ? '☀ Hell' : '☾ Dunkel' }}</button>
|
||||||
</aside>
|
</aside>
|
||||||
<main>
|
<main>
|
||||||
<StartSeite v-if="!aktivesProjekt" @oeffne="oeffne" />
|
<StartSeite v-if="!aktivesProjekt" :key="listeVersion" @oeffne="oeffne" @geaendert="laden" />
|
||||||
<ProjektAnsicht v-else :name="aktivesProjekt" :ansicht="ansicht" />
|
<ProjektAnsicht v-else :name="aktivesProjekt" :ansicht="ansicht" />
|
||||||
</main>
|
</main>
|
||||||
|
<OrdnerBrowser v-if="zeigeBrowser" @schliessen="zeigeBrowser = false"
|
||||||
|
@hinzugefuegt="hinzugefuegt" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -93,6 +105,8 @@ nav a {
|
|||||||
nav a:hover { background: var(--bg); }
|
nav a:hover { background: var(--bg); }
|
||||||
nav a.aktiv { background: var(--akzent); color: var(--akzent-text); }
|
nav a.aktiv { background: var(--akzent); color: var(--akzent-text); }
|
||||||
nav a.gestoert { color: var(--dezent); cursor: default; }
|
nav a.gestoert { color: var(--dezent); cursor: default; }
|
||||||
|
nav a.hinzu { color: var(--dezent); font-size: 0.85rem; }
|
||||||
|
nav a.hinzu:hover { color: var(--text); }
|
||||||
.icon { width: 1.1rem; text-align: center; color: inherit; opacity: 0.75; }
|
.icon { width: 1.1rem; text-align: center; color: inherit; opacity: 0.75; }
|
||||||
.punkt { width: 8px; height: 8px; border-radius: 99px; margin-left: auto; }
|
.punkt { width: 8px; height: 8px; border-radius: 99px; margin-left: auto; }
|
||||||
.punkt.gruen { background: var(--gruen); }
|
.punkt.gruen { background: var(--gruen); }
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ async function req(path, { method = 'GET', body, query } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fetchProjekte = () => req('/projekte')
|
export const fetchProjekte = () => req('/projekte')
|
||||||
|
export const fetchOrdner = (pfad) => req('/fs', { query: { pfad } })
|
||||||
|
export const projektHinzufuegen = (pfad) =>
|
||||||
|
req('/projekte', { method: 'POST', body: { pfad } })
|
||||||
|
export const projektEntfernen = (name) =>
|
||||||
|
req(`/projekte/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||||
export const fetchProjekt = (name) => req(`/projekte/${encodeURIComponent(name)}`)
|
export const fetchProjekt = (name) => req(`/projekte/${encodeURIComponent(name)}`)
|
||||||
export const fetchEinheit = (name, id) =>
|
export const fetchEinheit = (name, id) =>
|
||||||
req(`/projekte/${encodeURIComponent(name)}/einheit`, { query: { id } })
|
req(`/projekte/${encodeURIComponent(name)}/einheit`, { query: { id } })
|
||||||
|
|||||||
94
frontend/src/components/OrdnerBrowser.vue
Normal file
94
frontend/src/components/OrdnerBrowser.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { fetchOrdner, projektHinzufuegen } from '../api.js'
|
||||||
|
|
||||||
|
const emit = defineEmits(['schliessen', 'hinzugefuegt'])
|
||||||
|
|
||||||
|
const pfad = ref('')
|
||||||
|
const eingabe = ref('')
|
||||||
|
const eltern = ref(null)
|
||||||
|
const ordner = ref([])
|
||||||
|
const fehler = ref('')
|
||||||
|
|
||||||
|
async function lade(ziel) {
|
||||||
|
try {
|
||||||
|
const d = await fetchOrdner(ziel)
|
||||||
|
pfad.value = d.pfad
|
||||||
|
eingabe.value = d.pfad
|
||||||
|
eltern.value = d.eltern
|
||||||
|
ordner.value = d.ordner
|
||||||
|
fehler.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
fehler.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uebernehmen() {
|
||||||
|
try {
|
||||||
|
await projektHinzufuegen(pfad.value)
|
||||||
|
emit('hinzugefuegt')
|
||||||
|
} catch (e) {
|
||||||
|
fehler.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => lade(''))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="schleier" @click.self="$emit('schliessen')" @keydown.esc="$emit('schliessen')">
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="kopf">
|
||||||
|
<h3>Projekt-Ordner wählen</h3>
|
||||||
|
<button class="zu" @click="$emit('schliessen')">✕</button>
|
||||||
|
</div>
|
||||||
|
<input v-model="eingabe" spellcheck="false"
|
||||||
|
@keydown.enter="lade(eingabe)" title="Pfad eintippen, Enter lädt" />
|
||||||
|
<div class="liste">
|
||||||
|
<a v-if="eltern" href="#" class="eintrag" @click.prevent="lade(eltern)">↰ ..</a>
|
||||||
|
<a v-for="o in ordner" :key="o.pfad" href="#" class="eintrag"
|
||||||
|
@click.prevent="lade(o.pfad)">
|
||||||
|
<span class="ordner-icon">▸</span>{{ o.name }}
|
||||||
|
<span v-if="o.hat_planer" class="badge gruen">.planer</span>
|
||||||
|
</a>
|
||||||
|
<p v-if="!ordner.length" class="leer">keine Unterordner</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
||||||
|
<div class="fuss">
|
||||||
|
<code class="gewaehlt">{{ pfad }}</code>
|
||||||
|
<button class="primaer" @click="uebernehmen">Als Projekt übernehmen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.schleier {
|
||||||
|
position: fixed; inset: 0; background: rgb(0 0 0 / 0.4); z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.dialog {
|
||||||
|
background: var(--panel); border: 1px solid var(--rand); border-radius: 12px;
|
||||||
|
width: min(560px, 92vw); max-height: 80vh; padding: 1rem 1.2rem;
|
||||||
|
display: flex; flex-direction: column; gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.kopf { display: flex; align-items: center; }
|
||||||
|
.kopf h3 { margin: 0; font-size: 1rem; flex: 1; }
|
||||||
|
.zu { border: none; background: none; color: var(--dezent); padding: 0.2rem; }
|
||||||
|
input {
|
||||||
|
font: inherit; color: var(--text); background: var(--bg);
|
||||||
|
border: 1px solid var(--rand); border-radius: 6px; padding: 0.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
.liste { overflow-y: auto; min-height: 8rem; display: flex; flex-direction: column; }
|
||||||
|
.eintrag {
|
||||||
|
display: flex; align-items: center; gap: 0.45rem;
|
||||||
|
color: var(--text); text-decoration: none; font-size: 0.92rem;
|
||||||
|
padding: 0.3rem 0.5rem; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.eintrag:hover { background: var(--bg); }
|
||||||
|
.ordner-icon { color: var(--dezent); }
|
||||||
|
.leer { color: var(--dezent); font-size: 0.85rem; margin: 0.3rem 0.5rem; }
|
||||||
|
.fehler { color: var(--rot); font-size: 0.85rem; margin: 0; }
|
||||||
|
.fuss { display: flex; align-items: center; gap: 0.8rem; border-top: 1px solid var(--rand); padding-top: 0.7rem; }
|
||||||
|
.gewaehlt { flex: 1; font-size: 0.8rem; overflow-wrap: anywhere; color: var(--dezent); }
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { starteScan } from '../api.js'
|
import { projektEntfernen, starteScan } from '../api.js'
|
||||||
|
|
||||||
const props = defineProps({ projekt: { type: Object, required: true } })
|
const props = defineProps({ projekt: { type: Object, required: true } })
|
||||||
const emit = defineEmits(['oeffne', 'aktualisiert'])
|
const emit = defineEmits(['oeffne', 'aktualisiert'])
|
||||||
@@ -16,6 +16,19 @@ async function scan(art) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function entfernen() {
|
||||||
|
const ok = confirm(
|
||||||
|
`„${props.projekt.name}" aus der Projektliste entfernen?\n.planer/ im Repo bleibt erhalten.`)
|
||||||
|
if (!ok) return
|
||||||
|
meldung.value = ''
|
||||||
|
try {
|
||||||
|
await projektEntfernen(props.projekt.name)
|
||||||
|
emit('aktualisiert')
|
||||||
|
} catch (e) {
|
||||||
|
meldung.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function zeit(ts) {
|
function zeit(ts) {
|
||||||
if (!ts) return 'nie gescannt'
|
if (!ts) return 'nie gescannt'
|
||||||
return new Date(ts).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
return new Date(ts).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
@@ -39,10 +52,14 @@ function zeit(ts) {
|
|||||||
{{ projekt.pfad }} · letzter Scan: {{ zeit(projekt.letzter_scan_ts) }}
|
{{ projekt.pfad }} · letzter Scan: {{ zeit(projekt.letzter_scan_ts) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="detail drift" v-if="projekt.drift?.length">veraltet: {{ projekt.drift.join(', ') }}</p>
|
<p class="detail drift" v-if="projekt.drift?.length">veraltet: {{ projekt.drift.join(', ') }}</p>
|
||||||
<div class="aktionen" v-if="!projekt.fehler">
|
<div class="aktionen">
|
||||||
<button v-if="!projekt.hat_planer" class="primaer" :disabled="projekt.scan_laeuft"
|
<template v-if="!projekt.fehler">
|
||||||
@click="scan('scan')">Scannen</button>
|
<button v-if="!projekt.hat_planer" class="primaer" :disabled="projekt.scan_laeuft"
|
||||||
<button v-else :disabled="projekt.scan_laeuft" @click="scan('rescan')">Re-Scan</button>
|
@click="scan('scan')">Scannen</button>
|
||||||
|
<button v-else :disabled="projekt.scan_laeuft" @click="scan('rescan')">Re-Scan</button>
|
||||||
|
</template>
|
||||||
|
<button class="entfernen" :disabled="projekt.scan_laeuft" @click="entfernen"
|
||||||
|
title=".planer/ im Repo bleibt erhalten">Entfernen</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,6 +78,8 @@ function zeit(ts) {
|
|||||||
a.name:hover { color: var(--akzent); }
|
a.name:hover { color: var(--akzent); }
|
||||||
.detail { color: var(--dezent); font-size: 0.85rem; margin: 0.3rem 0 0; overflow-wrap: anywhere; }
|
.detail { color: var(--dezent); font-size: 0.85rem; margin: 0.3rem 0 0; overflow-wrap: anywhere; }
|
||||||
.drift { color: var(--gelb); }
|
.drift { color: var(--gelb); }
|
||||||
.aktionen { margin-top: 0.7rem; }
|
.aktionen { margin-top: 0.7rem; display: flex; gap: 0.5rem; }
|
||||||
|
.entfernen { margin-left: auto; color: var(--dezent); }
|
||||||
|
.entfernen:hover:not(:disabled) { color: var(--rot); border-color: var(--rot); }
|
||||||
.meldung { color: var(--rot); font-size: 0.85rem; margin: 0.5rem 0 0; }
|
.meldung { color: var(--rot); font-size: 0.85rem; margin: 0.5rem 0 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { fetchProjekte } from '../api.js'
|
|||||||
import { usePolling } from '../composables/usePolling.js'
|
import { usePolling } from '../composables/usePolling.js'
|
||||||
import ProjektKarte from './ProjektKarte.vue'
|
import ProjektKarte from './ProjektKarte.vue'
|
||||||
|
|
||||||
defineEmits(['oeffne'])
|
defineEmits(['oeffne', 'geaendert'])
|
||||||
|
|
||||||
const projekte = ref([])
|
const projekte = ref([])
|
||||||
const fehler = ref('')
|
const fehler = ref('')
|
||||||
@@ -32,7 +32,8 @@ onMounted(async () => {
|
|||||||
</p>
|
</p>
|
||||||
<div class="liste">
|
<div class="liste">
|
||||||
<ProjektKarte v-for="p in projekte" :key="p.name" :projekt="p"
|
<ProjektKarte v-for="p in projekte" :key="p.name" :projekt="p"
|
||||||
@oeffne="$emit('oeffne', p.name)" @aktualisiert="laden(); poll.start()" />
|
@oeffne="$emit('oeffne', p.name)"
|
||||||
|
@aktualisiert="laden(); poll.start(); $emit('geaendert')" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
/home/arbeit/projects/creator
|
/home/arbeit/projects/creator
|
||||||
/home/arbeit/projects/planer
|
/home/arbeit/projects/planer
|
||||||
|
/home/arbeit/projects/tft
|
||||||
|
|||||||
Reference in New Issue
Block a user