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
|
||||
# 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=
|
||||
|
||||
# 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_NACHFASS=minimax:minimax/MiniMax-M3
|
||||
ROLLE_SICHTEN=minimax:minimax/MiniMax-M3
|
||||
ROLLE_JUDGE=minimax:minimax/MiniMax-M3
|
||||
|
||||
# Engine-Tuning (optional, Defaults siehe backend/config.py)
|
||||
# MAX_CONCURRENT_AGENTS=6
|
||||
# RAM_MIN_FREE_PCT=20
|
||||
# --- Engine-Tuning (optional; Defaults im Code, backend/config.py) -------------
|
||||
# Zum Ändern Zeile einkommentieren UND Wert setzen — nie leer lassen
|
||||
# (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,
|
||||
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")
|
||||
|
||||
@@ -270,7 +270,7 @@ async def run_agent(
|
||||
"""Ein LLM-Call der Rolle `role`. → (rc, stdout, stderr). Wirft AgentInfraError,
|
||||
wenn die Infrastruktur nicht nutzbar ist (Rolle unkonfiguriert, Provider down,
|
||||
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
|
||||
start = time.monotonic()
|
||||
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}],
|
||||
**_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
|
||||
n = 2
|
||||
while track_key in _active_api:
|
||||
|
||||
@@ -61,6 +61,12 @@ def resolve_role(role: str) -> tuple[str, str]:
|
||||
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) -----------------------------------------
|
||||
KANBAN_BATCH = int(os.getenv("KANBAN_BATCH", "4")) # Karten je Paket
|
||||
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,
|
||||
#-Kommentare). Von Hand editierbar — UI-Verwaltung kommt später (YAGNI)."""
|
||||
#-Kommentare). Von Hand editierbar; hinzufuegen/entfernen bedienen die UI."""
|
||||
from pathlib import Path
|
||||
|
||||
import config
|
||||
@@ -34,3 +34,41 @@ def finde(name: str) -> Path | None:
|
||||
if p["name"] == name and "fehler" not in p:
|
||||
return Path(p["pfad"])
|
||||
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}
|
||||
|
||||
|
||||
@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")
|
||||
async def liste():
|
||||
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)")
|
||||
if not repo.working_tree_sauber(wurzel):
|
||||
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:
|
||||
try:
|
||||
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"})
|
||||
assert r.status_code == 409
|
||||
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')
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DIiv29cS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-AxkPXHNh.css">
|
||||
<script type="module" crossorigin src="/assets/index-PAbaEjVj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CzKrDvqu.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -4,10 +4,19 @@ import { fetchProjekte } from './api.js'
|
||||
import { usePolling } from './composables/usePolling.js'
|
||||
import StartSeite from './components/StartSeite.vue'
|
||||
import ProjektAnsicht from './components/ProjektAnsicht.vue'
|
||||
import OrdnerBrowser from './components/OrdnerBrowser.vue'
|
||||
|
||||
const projekte = ref([])
|
||||
const aktivesProjekt = ref(null) // null = Übersicht
|
||||
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 = [
|
||||
['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.hat_planer" class="punkt gruen" title="aktuell"></span>
|
||||
</a>
|
||||
<a href="#" class="hinzu" @click.prevent="zeigeBrowser = true">+ Projekt hinzufügen</a>
|
||||
</nav>
|
||||
<nav v-if="aktivesProjekt" class="ansichten">
|
||||
<span class="gruppe">{{ aktivesProjekt }}</span>
|
||||
@@ -66,9 +76,11 @@ function toggleDark() {
|
||||
<button class="dark-knopf" @click="toggleDark">{{ darkMode ? '☀ Hell' : '☾ Dunkel' }}</button>
|
||||
</aside>
|
||||
<main>
|
||||
<StartSeite v-if="!aktivesProjekt" @oeffne="oeffne" />
|
||||
<StartSeite v-if="!aktivesProjekt" :key="listeVersion" @oeffne="oeffne" @geaendert="laden" />
|
||||
<ProjektAnsicht v-else :name="aktivesProjekt" :ansicht="ansicht" />
|
||||
</main>
|
||||
<OrdnerBrowser v-if="zeigeBrowser" @schliessen="zeigeBrowser = false"
|
||||
@hinzugefuegt="hinzugefuegt" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -93,6 +105,8 @@ nav a {
|
||||
nav a:hover { background: var(--bg); }
|
||||
nav a.aktiv { background: var(--akzent); color: var(--akzent-text); }
|
||||
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; }
|
||||
.punkt { width: 8px; height: 8px; border-radius: 99px; margin-left: auto; }
|
||||
.punkt.gruen { background: var(--gruen); }
|
||||
|
||||
@@ -34,6 +34,11 @@ async function req(path, { method = 'GET', body, query } = {}) {
|
||||
}
|
||||
|
||||
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 fetchEinheit = (name, 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>
|
||||
import { ref } from 'vue'
|
||||
import { starteScan } from '../api.js'
|
||||
import { projektEntfernen, starteScan } from '../api.js'
|
||||
|
||||
const props = defineProps({ projekt: { type: Object, required: true } })
|
||||
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) {
|
||||
if (!ts) return 'nie gescannt'
|
||||
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) }}
|
||||
</p>
|
||||
<p class="detail drift" v-if="projekt.drift?.length">veraltet: {{ projekt.drift.join(', ') }}</p>
|
||||
<div class="aktionen" v-if="!projekt.fehler">
|
||||
<button v-if="!projekt.hat_planer" class="primaer" :disabled="projekt.scan_laeuft"
|
||||
@click="scan('scan')">Scannen</button>
|
||||
<button v-else :disabled="projekt.scan_laeuft" @click="scan('rescan')">Re-Scan</button>
|
||||
<div class="aktionen">
|
||||
<template v-if="!projekt.fehler">
|
||||
<button v-if="!projekt.hat_planer" class="primaer" :disabled="projekt.scan_laeuft"
|
||||
@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>
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
</div>
|
||||
@@ -61,6 +78,8 @@ function zeit(ts) {
|
||||
a.name:hover { color: var(--akzent); }
|
||||
.detail { color: var(--dezent); font-size: 0.85rem; margin: 0.3rem 0 0; overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fetchProjekte } from '../api.js'
|
||||
import { usePolling } from '../composables/usePolling.js'
|
||||
import ProjektKarte from './ProjektKarte.vue'
|
||||
|
||||
defineEmits(['oeffne'])
|
||||
defineEmits(['oeffne', 'geaendert'])
|
||||
|
||||
const projekte = ref([])
|
||||
const fehler = ref('')
|
||||
@@ -32,7 +32,8 @@ onMounted(async () => {
|
||||
</p>
|
||||
<div class="liste">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
/home/arbeit/projects/creator
|
||||
/home/arbeit/projects/planer
|
||||
/home/arbeit/projects/tft
|
||||
|
||||
Reference in New Issue
Block a user