feat(v0.2.0): config UI panel + folder picker + S3 bucket lister/tester

- Panel Reglages dans l'UI (modal)
- Folder picker natif Wails pour dossier local
- ListS3Buckets via aws-cli (profile-aware)
- TestS3Access pour verifier acces bucket
- Config persistee user config dir (cross-OS): %APPDATA%/field-sync ou ~/.config/field-sync
- Header affiche bucket + dest visibles
- Auto-open modal si bucket vide au premier run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Poulpe
2026-04-28 22:08:22 +00:00
parent dcaf5ac020
commit 610ec13b19
3 changed files with 302 additions and 15 deletions

59
app.go
View File

@@ -3,6 +3,8 @@ package main
import (
"context"
"fmt"
"os/exec"
"strings"
"os"
"path/filepath"
"sync"
@@ -66,12 +68,67 @@ func NewApp() *App {
// startup is called by Wails when the app starts
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.cfg = config.Load(".env")
a.cfg = config.LoadFromFile()
for i, letter := range []string{"A", "B", "C", "D"} {
a.slots[i] = SlotState{Slot: letter, Status: "idle"}
}
}
// SaveConfig persists config to user config dir and updates in-memory config
func (a *App) SaveConfig(cfg config.Config) error {
if err := config.Save(cfg); err != nil {
return err
}
a.mu.Lock()
a.cfg = cfg
a.mu.Unlock()
return nil
}
// GetConfigPath returns where the config is stored (for display)
func (a *App) GetConfigPath() string {
return config.Path()
}
// PickLocalDest opens native folder picker, returns selected path or "" if cancelled
func (a *App) PickLocalDest() (string, error) {
path, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Dossier de destination locale",
})
return path, err
}
// ListS3Buckets runs `aws s3api list-buckets --profile <p>` returns []string
func (a *App) ListS3Buckets(profile string) ([]string, error) {
args := []string{"s3api", "list-buckets", "--query", "Buckets[].Name", "--output", "text"}
if profile != "" {
args = append(args, "--profile", profile)
}
out, err := exec.Command("aws", args...).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("aws error: %s", strings.TrimSpace(string(out)))
}
raw := strings.TrimSpace(string(out))
if raw == "" || raw == "None" {
return []string{}, nil
}
parts := strings.Fields(raw)
return parts, nil
}
// TestS3Access checks bucket reachable: `aws s3 ls s3://<bucket>/ --profile <p>`
func (a *App) TestS3Access(bucket, profile string) error {
args := []string{"s3", "ls", "s3://" + bucket + "/"}
if profile != "" {
args = append(args, "--profile", profile)
}
out, err := exec.Command("aws", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("accès refusé: %s", strings.TrimSpace(string(out)))
}
return nil
}
// ScanCards returns up to 4 detected SD cards
func (a *App) ScanCards() []detect.SDCard {
cards := detect.Detect()

View File

@@ -1,12 +1,25 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ScanCards, StartIngest, CancelIngest, GetSnapshot, SetDryRun, LoadConfig } from '../wailsjs/go/main/App';
import { ScanCards, StartIngest, CancelIngest, GetSnapshot, SetDryRun, LoadConfig, SaveConfig, GetConfigPath, PickLocalDest, ListS3Buckets, TestS3Access } from '../wailsjs/go/main/App';
import { EventsOn } from '../wailsjs/runtime/runtime';
let cards: any[] = [];
let snapshot: any = { slots: [], running: false, dryRun: false, sessionID: '', config: {} };
let dryRun = false;
// Settings modal
let showSettings = false;
let settingsMsg = '';
let cfgPath = '';
let s3TestResult = '';
let s3TestOk: boolean | null = null;
let bucketList: string[] = [];
let loadingBuckets = false;
let savingConfig = false;
// Local editable config
let editCfg = { s3Bucket: '', localDest: '', awsProfile: 'cosma', concurrency: 4 };
async function refresh() {
cards = await ScanCards();
snapshot = await GetSnapshot();
@@ -27,11 +40,74 @@
SetDryRun(dryRun);
}
onMount(() => {
refresh();
async function openSettings() {
editCfg = {
s3Bucket: snapshot.config?.s3Bucket || '',
localDest: snapshot.config?.localDest || '',
awsProfile: snapshot.config?.awsProfile || 'cosma',
concurrency: snapshot.config?.concurrency || 4,
};
cfgPath = await GetConfigPath();
s3TestResult = '';
s3TestOk = null;
bucketList = [];
settingsMsg = '';
showSettings = true;
}
async function pickFolder() {
const p = await PickLocalDest();
if (p) editCfg.localDest = p;
}
async function listBuckets() {
loadingBuckets = true;
bucketList = [];
try {
bucketList = await ListS3Buckets(editCfg.awsProfile);
} catch (e: any) {
bucketList = [];
}
loadingBuckets = false;
}
async function testS3() {
s3TestResult = 'Test en cours...';
s3TestOk = null;
try {
await TestS3Access(editCfg.s3Bucket, editCfg.awsProfile);
s3TestResult = 'Acces OK';
s3TestOk = true;
} catch (e: any) {
s3TestResult = e?.message || 'Erreur acces S3';
s3TestOk = false;
}
}
async function saveSettings() {
savingConfig = true;
try {
await SaveConfig(editCfg);
await refresh();
showSettings = false;
} catch (e: any) {
settingsMsg = 'Erreur: ' + (e?.message || String(e));
}
savingConfig = false;
}
onMount(async () => {
await refresh();
const t = setInterval(refresh, 3000);
EventsOn('slot-update', () => refresh());
EventsOn('session-done', () => refresh());
// Auto-open settings if no bucket configured
if (!snapshot.config?.s3Bucket) {
settingsMsg = 'Configure d abord ton bucket S3 et le dossier local.';
await openSettings();
}
return () => clearInterval(t);
});
@@ -52,12 +128,29 @@
<header class="flex items-center justify-between border-b border-slate-700 pb-4">
<div>
<h1 class="text-3xl font-bold text-cyan-400">field-sync</h1>
<p class="text-sm text-slate-400">Cosma terrain · SD → local + S3</p>
<p class="text-sm text-slate-400">Cosma terrain &middot; SD &rarr; local + S3</p>
</div>
<div class="text-right text-xs text-slate-400">
<div>Session: <span class="text-slate-200">{snapshot.sessionID || '—'}</span></div>
<div>Bucket: <span class="text-slate-200">{snapshot.config?.s3Bucket || '—'}</span></div>
<div>Dest: <span class="text-slate-200">{snapshot.config?.localDest || '—'}</span></div>
<div class="flex flex-col items-end gap-1">
<div class="text-sm font-semibold text-cyan-300">
<span class="text-slate-400 font-normal">Local:</span>
{#if snapshot.config?.localDest}
{snapshot.config.localDest}
{:else}
<span class="italic text-slate-500">non configure</span>
{/if}
</div>
<div class="text-sm font-semibold text-cyan-300">
<span class="text-slate-400 font-normal">S3:</span>
{#if snapshot.config?.s3Bucket}
s3://{snapshot.config.s3Bucket}/
{:else}
<span class="italic text-slate-500">non configure</span>
{/if}
</div>
<div class="text-xs text-slate-500">Session: {snapshot.sessionID || '—'}</div>
<button on:click={openSettings} class="mt-1 px-3 py-1 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm font-semibold">
Reglages
</button>
</div>
</header>
@@ -99,26 +192,116 @@
</div>
</div>
{#if s.speedMBs}<div class="text-xs text-slate-400">{s.speedMBs.toFixed(1)} MB/s</div>{/if}
{#if s.error}<div class="text-xs text-red-400 mt-1"> {s.error}</div>{/if}
{#if s.error}<div class="text-xs text-red-400 mt-1">Erreur: {s.error}</div>{/if}
</div>
</div>
{/each}
</section>
<footer class="flex gap-3 items-center mt-auto border-t border-slate-700 pt-4">
<button on:click={refresh} class="px-5 py-3 bg-slate-700 hover:bg-slate-600 rounded-lg font-semibold">Rescan</button>
<button on:click={refresh} class="px-5 py-3 bg-slate-700 hover:bg-slate-600 rounded-lg font-semibold">Rescan</button>
<button on:click={start} disabled={snapshot.running || cards.length===0}
class="px-6 py-3 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 rounded-lg font-bold text-lg">
START INGEST
START INGEST
</button>
<button on:click={cancel} disabled={!snapshot.running}
class="px-5 py-3 bg-red-700 hover:bg-red-600 disabled:bg-slate-700 disabled:text-slate-500 rounded-lg font-semibold">
Cancel
Cancel
</button>
<label class="flex items-center gap-2 ml-4 text-sm cursor-pointer">
<input type="checkbox" bind:checked={dryRun} on:change={toggleDryRun} class="w-4 h-4">
Dry-run
</label>
<div class="ml-auto text-xs text-slate-500">{cards.length} carte(s) détectée(s)</div>
<div class="ml-auto text-xs text-slate-500">{cards.length} carte(s) detectee(s)</div>
</footer>
</div>
<!-- Settings Modal -->
{#if showSettings}
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div class="bg-slate-900 rounded-2xl border border-slate-600 shadow-2xl w-full max-w-lg p-6 flex flex-col gap-4">
<h2 class="text-xl font-bold text-cyan-300">Reglages</h2>
{#if settingsMsg}
<div class="text-sm text-amber-400 bg-amber-900/30 rounded-lg px-3 py-2">{settingsMsg}</div>
{/if}
<!-- Config path -->
<div>
<label class="text-xs text-slate-500 mb-1 block">Fichier config (info)</label>
<div class="text-xs text-slate-400 bg-slate-800 rounded px-3 py-2 font-mono truncate">{cfgPath || '...'}</div>
</div>
<!-- Local dest -->
<div>
<label class="text-xs text-slate-400 mb-1 block font-semibold">Dossier local</label>
<div class="flex gap-2">
<input type="text" bind:value={editCfg.localDest} placeholder="ex: C:\field-sync-data"
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500" />
<button on:click={pickFolder} class="px-3 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm font-semibold whitespace-nowrap">
Parcourir...
</button>
</div>
</div>
<!-- AWS Profile -->
<div>
<label class="text-xs text-slate-400 mb-1 block font-semibold">Profil AWS</label>
<input type="text" bind:value={editCfg.awsProfile} placeholder="cosma"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500" />
</div>
<!-- S3 Bucket -->
<div>
<label class="text-xs text-slate-400 mb-1 block font-semibold">Bucket S3</label>
<div class="flex gap-2 mb-2">
<input type="text" bind:value={editCfg.s3Bucket} placeholder="mon-bucket"
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500" />
<button on:click={listBuckets} disabled={loadingBuckets}
class="px-3 py-2 bg-slate-700 hover:bg-slate-600 disabled:opacity-50 rounded-lg text-sm font-semibold whitespace-nowrap">
{loadingBuckets ? '...' : 'Lister buckets'}
</button>
</div>
{#if bucketList.length > 0}
<select bind:value={editCfg.s3Bucket}
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500">
{#each bucketList as b}
<option value={b}>{b}</option>
{/each}
</select>
{/if}
<button on:click={testS3} disabled={!editCfg.s3Bucket}
class="mt-2 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 disabled:opacity-50 rounded-lg text-sm font-semibold">
Tester acces S3
</button>
{#if s3TestResult}
<span class="ml-3 text-sm" class:text-emerald-400={s3TestOk === true} class:text-red-400={s3TestOk === false}>
{s3TestResult}
</span>
{/if}
</div>
<!-- Concurrency -->
<div>
<label class="text-xs text-slate-400 mb-1 block font-semibold">Concurrence (workers)</label>
<input type="number" min="1" max="16" bind:value={editCfg.concurrency}
class="w-24 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500" />
</div>
<!-- Buttons -->
{#if settingsMsg && settingsMsg.includes('Erreur')}
<div class="text-sm text-red-400">{settingsMsg}</div>
{/if}
<div class="flex gap-3 pt-2 border-t border-slate-700">
<button on:click={saveSettings} disabled={savingConfig}
class="flex-1 px-4 py-2 bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 rounded-lg font-bold text-sm">
{savingConfig ? 'Enregistrement...' : 'Enregistrer'}
</button>
<button on:click={() => { showSettings = false; settingsMsg = ''; }}
class="px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg font-semibold text-sm">
Annuler
</button>
</div>
</div>
</div>
{/if}

View File

@@ -1,7 +1,9 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"github.com/joho/godotenv"
@@ -15,7 +17,52 @@ type Config struct {
Concurrency int `json:"concurrency"`
}
// Load reads config from ENV (with optional .env file)
// Path returns the cross-OS config file path
func Path() string {
dir, _ := os.UserConfigDir()
return filepath.Join(dir, "field-sync", "config.json")
}
// Save writes config to disk (atomic)
func Save(cfg Config) error {
p := Path()
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
return err
}
data, _ := json.MarshalIndent(cfg, "", " ")
tmp := p + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, p)
}
// LoadFromFile reads from JSON config file (no .env)
func LoadFromFile() Config {
data, err := os.ReadFile(Path())
if err != nil {
return Config{AWSProfile: "cosma", Concurrency: 4, LocalDest: defaultLocalDest()}
}
var c Config
json.Unmarshal(data, &c)
if c.Concurrency == 0 {
c.Concurrency = 4
}
if c.AWSProfile == "" {
c.AWSProfile = "cosma"
}
if c.LocalDest == "" {
c.LocalDest = defaultLocalDest()
}
return c
}
func defaultLocalDest() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "field-sync-data")
}
// Load reads config from ENV (with optional .env file) — kept for compat
func Load(envFile string) Config {
_ = godotenv.Load(envFile) // ignore if missing