Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
610ec13b19 |
59
app.go
59
app.go
@@ -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()
|
||||
|
||||
@@ -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 · SD → 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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user