Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./
COPY VERSION ./VERSION
COPY --from=frontend-builder /app/web/dist ./web/dist

RUN mkdir -p /data/scans /data/db /data/auth /data/logs /var/log/scan2target /tmp/scan2target/scans \
Expand Down
23 changes: 17 additions & 6 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,23 @@ def no_cache_file(path: Path, media_type: str | None = None) -> FileResponse:


def get_version() -> str:
version_file = Path(__file__).parent.parent / "VERSION"
try:
return version_file.read_text().strip()
except OSError as exc:
logger.warning("Could not read VERSION file (%s): %s", version_file, exc)
return "0.0.0"
"""Read the version from VERSION, next to the app or at the repo root."""
env_version = os.environ.get("SCAN2TARGET_VERSION", "").strip()
if env_version:
return env_version

# In the container the app lives at /app, in a checkout at <repo>/app.
candidates = (
Path(__file__).parent / "VERSION",
Path(__file__).parent.parent / "VERSION",
)
for version_file in candidates:
try:
return version_file.read_text().strip()
except OSError:
continue
logger.warning("Could not read VERSION file (tried: %s)", ", ".join(str(c) for c in candidates))
return "0.0.0"


@asynccontextmanager
Expand Down
2 changes: 2 additions & 0 deletions app/web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import Sidebar from './components/layout/Sidebar.svelte';
import Topbar from './components/layout/Topbar.svelte';
import BottomNav from './components/layout/BottomNav.svelte';
import Footer from './components/layout/Footer.svelte';
import Toast from './components/ui/Toast.svelte';
import LoginOverlay from './components/LoginOverlay.svelte';
import NewScanView from './views/NewScanView.svelte';
Expand Down Expand Up @@ -48,6 +49,7 @@
{:else if state.page === 'manage'}<ManageView data={state} onDevices={appStore.replaceDevices} onTargets={appStore.replaceTargets} onNotify={appStore.notify} onProfilesChanged={appStore.loadCore} />
{:else}<SettingsView settings={state.settings} version={state.version} lastUpdated={state.lastUpdated} profiles={state.profiles} authConfig={state.authConfig} onChange={appStore.setSettings} onNotify={appStore.notify} onAuthChanged={appStore.loadAuthConfig} />{/if}
</div>
<Footer version={state.version} wsConnected={state.wsConnected} />
</main>
<BottomNav current={state.page} onNavigate={appStore.setPage} />
<Toast toast={state.toast} onClose={appStore.clearToast} />
Expand Down
49 changes: 46 additions & 3 deletions app/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ input,select,textarea{font:inherit;color:inherit}
/* ---------- 3. App shell ---------- */
.app-shell{min-height:100vh;background:var(--bg);color:var(--text)}
.main-area{min-height:100vh;margin-left:248px}
.view-container{max-width:1140px;margin:0 auto;padding:26px 34px 120px}
.view-container{max-width:1140px;margin:0 auto;padding:26px 34px 40px}

.sidebar{
position:fixed;inset:0 auto 0 0;width:248px;z-index:30;
Expand Down Expand Up @@ -205,6 +205,7 @@ input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0
.clean-list{list-style:none;margin:0;padding:0;background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);overflow:hidden}
.list-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 15px}
.list-row + .list-row{border-top:1px solid var(--line)}
.list-row>div{min-width:0}
.list-row strong{font-size:14px;font-weight:600;display:block}
.pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent);flex:0 0 auto;animation:pulse 1.6s infinite}
.fav-star{color:#f5a623;display:inline-flex}
Expand Down Expand Up @@ -321,6 +322,7 @@ input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0
.history-thumb{width:52px;height:52px;border-radius:9px;background:var(--surface-3);display:grid;place-items:center;position:relative;color:var(--faint);overflow:hidden;flex:0 0 auto}
.history-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:1}
.history-thumb img.hidden{display:none}
.history-copy{min-width:0}
.history-copy>div{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.history-copy strong{font-size:14.5px;font-weight:650}
.history-copy p{margin:3px 0 0;color:var(--muted);font-size:12.5px}
Expand All @@ -337,17 +339,25 @@ input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0
.profile-row span{display:block;color:var(--muted);font-size:12px;margin-top:4px}

/* resource cards (devices / targets) */
.resource-grid{display:grid;gap:12px}
.resource-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:12px}
.resource-card{border:1px solid var(--line);border-radius:var(--r-card);background:var(--surface-2);padding:16px}
.resource-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
.resource-title{display:flex;align-items:center;gap:12px;min-width:0}
.resource-title>div{min-width:0}
.resource-title h4{font-size:15px;font-weight:650}
.resource-title p{color:var(--muted);font-size:12.5px;margin-top:2px}
.resource-icon{width:40px;height:40px;border-radius:11px;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;flex:0 0 auto}
.resource-meta{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px}
.meta-box{min-width:0;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-sm);padding:10px 12px}
.meta-box span{display:block;font-size:11px;color:var(--muted);margin-bottom:4px}
.meta-box strong{display:block;font-size:13px;font-weight:600}
.check-result{display:flex;align-items:center;gap:8px;margin-top:12px;font-size:12.5px;font-weight:500;color:var(--muted)}
.check-result .dot{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--muted)}
.check-result.running .dot{background:var(--accent);animation:pulse 1.6s infinite}
.check-result.ok{color:var(--success)}
.check-result.ok .dot{background:var(--success)}
.check-result.error{color:var(--danger)}
.check-result.error .dot{background:var(--danger)}

/* ---------- 14. Settings ---------- */
.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
Expand Down Expand Up @@ -390,6 +400,37 @@ input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0
.login-logo{width:48px;height:48px;border-radius:14px;margin-bottom:16px;background:linear-gradient(150deg,#0a84ff,#38bdf8);display:grid;place-items:center}
.form-error{color:var(--danger);font-size:13px;margin-top:8px}

/* ---------- 16b. Toasts ---------- */
.toast{
position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:200;
display:flex;align-items:center;gap:12px;width:max-content;max-width:min(440px,calc(100vw - 32px));
padding:12px 14px;border-radius:var(--r-ctl);
background:var(--surface);border:1px solid var(--line-strong);box-shadow:var(--sh-lg);
font-size:13.5px;font-weight:500;animation:toast-in .22s ease
}
.toast:before{content:"";flex:0 0 auto;width:4px;align-self:stretch;border-radius:2px;background:var(--info)}
.toast.success:before{background:var(--success)}
.toast.error:before{background:var(--danger)}
.toast.warning:before{background:var(--warn)}
.toast span{min-width:0}
.toast button{
flex:0 0 auto;width:26px;height:26px;border-radius:8px;display:grid;place-items:center;
color:var(--muted);font-size:17px;line-height:1
}
.toast button:hover{background:var(--surface-2);color:var(--text)}
@keyframes toast-in{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}

/* ---------- 16c. App footer ---------- */
.app-footer{
display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;
max-width:1140px;margin:0 auto;padding:18px 34px 30px;
border-top:1px solid var(--line);color:var(--muted);font-size:12.5px
}
.app-footer a{color:var(--muted);text-decoration:none;display:inline-flex;align-items:center;gap:6px}
.app-footer a:hover{color:var(--accent)}
.app-footer nav{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.app-footer .app-version{font-weight:600;color:var(--text)}

/* ---------- 17. Bottom nav (mobile) ---------- */
.bottom-nav{display:none}

Expand All @@ -411,7 +452,7 @@ input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0
.topbar{padding:15px 16px}
.topbar h1{font-size:19px}
.topbar p{display:none}
.view-container{padding:18px 16px 110px}
.view-container{padding:18px 16px 20px}
.source-grid{grid-template-columns:1fr}
.page-grid{grid-template-columns:1fr 1fr}
.form-grid{grid-template-columns:1fr}
Expand All @@ -429,6 +470,8 @@ input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0
}
.bottom-nav button{display:flex;flex-direction:column;align-items:center;gap:3px;padding:9px 2px;border-radius:13px;color:var(--muted);font-size:10px;font-weight:600}
.bottom-nav button.active{background:var(--accent-soft);color:var(--accent)}
.toast{bottom:94px}
.app-footer{padding:16px 16px 100px;font-size:12px}
}
@media (max-width:480px){
.stat-grid-cards{grid-template-columns:1fr}
Expand Down
29 changes: 29 additions & 0 deletions app/web/src/components/layout/Footer.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script>
import Icon from '../ui/Icon.svelte';
import { lang } from '../../lib/i18n';

export let version = '';
export let wsConnected = false;

const repo = 'https://github.com/fgrfn/Scan2Target';

$: labels = $lang === 'de'
? { project: 'Scan-Server für Netzwerkscanner', source: 'Quellcode', docs: 'Dokumentation', live: 'Live verbunden', polling: 'Polling', version: 'Version' }
: { project: 'Scan server for network scanners', source: 'Source code', docs: 'Documentation', live: 'Live connected', polling: 'Polling', version: 'Version' };
</script>

<footer class="app-footer">
<div class="row gap wrap center">
<span class="app-version">Scan2Target {version ? `v${version}` : ''}</span>
<span>· {labels.project}</span>
</div>
<nav>
<a href={repo} target="_blank" rel="noopener noreferrer">
<Icon name="github" size={15} /> {labels.source}
</a>
<a href={`${repo}#readme`} target="_blank" rel="noopener noreferrer">
<Icon name="book" size={15} /> {labels.docs}
</a>
<span>{wsConnected ? labels.live : labels.polling}</span>
</nav>
</footer>
2 changes: 2 additions & 0 deletions app/web/src/components/ui/Icon.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
alert: 'M12 3l10 17H2zM12 10v4M12 17.5v.5',
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
bolt: 'M13 2L4 14h6l-1 8 9-12h-6z',
github: 'M9 19c-5 1.5-5-2.5-7-3m14 6v-3.9a3.4 3.4 0 0 0-.9-2.6c3-.3 6.2-1.5 6.2-6.7A5.2 5.2 0 0 0 20 5.8a4.9 4.9 0 0 0-.1-3.6s-1.1-.3-3.7 1.4a12.6 12.6 0 0 0-6.6 0C6.9 1.9 5.8 2.2 5.8 2.2A4.9 4.9 0 0 0 5.7 5.8 5.2 5.2 0 0 0 4.3 9.4c0 5.2 3.2 6.4 6.2 6.7a3.4 3.4 0 0 0-.9 2.6V22',
book: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z',
camera: 'M3 8a2 2 0 0 1 2-2h2l2-2h6l2 2h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
dot: 'M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z'
};
Expand Down
6 changes: 6 additions & 0 deletions app/web/src/lib/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const en = {
delete: 'Delete',
edit: 'Edit',
test: 'Test',
checkConnection: 'Check connection',
checking: 'Checking…',
add: 'Add',
added: 'Added',
back: 'Back',
Expand Down Expand Up @@ -68,6 +70,7 @@ const en = {
errorGeneric: 'Something went wrong',

// Statuses
statusLabel: 'Status',
status_completed: 'completed',
status_failed: 'failed',
status_running: 'running',
Expand Down Expand Up @@ -375,6 +378,8 @@ const de = {
delete: 'Löschen',
edit: 'Bearbeiten',
test: 'Testen',
checkConnection: 'Verbindung prüfen',
checking: 'Prüfe …',
add: 'Hinzufügen',
added: 'Hinzugefügt',
back: 'Zurück',
Expand Down Expand Up @@ -402,6 +407,7 @@ const de = {
errorGeneric: 'Es ist ein Fehler aufgetreten',

// Status
statusLabel: 'Status',
status_completed: 'abgeschlossen',
status_failed: 'fehlgeschlagen',
status_running: 'läuft',
Expand Down
17 changes: 14 additions & 3 deletions app/web/src/views/DevicesView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
let manual = { uri: '', name: '' };
let editDevice = null;
let discovering = false;
let checks = {};

$: devices = data.devices || [];
$: kpiCards = [
Expand Down Expand Up @@ -66,11 +67,16 @@
}

async function check(id) {
checks = { ...checks, [id]: { state: 'running', message: $t('checking') } };
try {
const res = await api.checkDevice(id);
await refreshDevices();
onNotify(res.message || `Status: ${res.status}`, 'info');
const online = res.status === 'online';
const message = res.message || `${$t('statusLabel')}: ${$t(statusKey(res.status || 'unknown'))}`;
checks = { ...checks, [id]: { state: online ? 'ok' : 'error', message } };
onNotify(message, online ? 'success' : 'error');
} catch (error) {
checks = { ...checks, [id]: { state: 'error', message: error.message } };
onNotify(error.message, 'error');
}
}
Expand Down Expand Up @@ -150,11 +156,16 @@
<div class="meta-box"><span>{$t('connectionLabel')}</span><strong>{d.connection_type || '—'}</strong></div>
<div class="meta-box"><span>{$t('modelLabel')}</span><strong>{d.model || d.make || '—'}</strong></div>
</div>
<div class="row gap">
<button class="btn ghost" on:click={() => check(d.id)}>{$t('test')}</button>
<div class="row gap wrap">
<button class="btn ghost" disabled={checks[d.id]?.state === 'running'} on:click={() => check(d.id)}>
{checks[d.id]?.state === 'running' ? $t('checking') : $t('checkConnection')}
</button>
<button class="btn ghost" on:click={() => (editDevice = { ...d })}>{$t('edit')}</button>
<button class="btn danger" on:click={() => remove(d.id)}>{$t('delete')}</button>
</div>
{#if checks[d.id]}
<p class="check-result {checks[d.id].state}"><span class="dot"></span>{checks[d.id].message}</p>
{/if}
</article>
{/each}
</div>
Expand Down
11 changes: 8 additions & 3 deletions app/web/src/views/SettingsView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
$: c = $lang === 'de' ? {
interface: 'Oberfläche', interfaceLead: 'Sprache und Darstellung auf diesem Gerät.', language: 'Sprache', theme: 'Darstellung', system: 'System', light: 'Hell', dark: 'Dunkel',
account: 'Anmeldung', accountLead: 'Das einzelne Administratorkonto verwalten oder den Anmeldeschutz deaktivieren.', enabled: 'Anmeldung erforderlich', user: 'Benutzername', email: 'E-Mail', current: 'Aktuelles Passwort', next: 'Neues Passwort (optional)', save: 'Konto aktualisieren', saved: 'Konto aktualisiert', authSaved: 'Anmeldeeinstellung gespeichert', logout: 'Abmelden',
ha: 'Home Assistant', haLead: 'Die bestehende Scan-API für Taster und Automationen.', show: 'Beispiel anzeigen', hide: 'Beispiel ausblenden', copy: 'Kopieren', copied: 'Kopiert', version: 'Version', live: 'Betrieb', online: 'Online'
ha: 'Home Assistant', haLead: 'Die bestehende Scan-API für Taster und Automationen.', show: 'Beispiel anzeigen', hide: 'Beispiel ausblenden', copy: 'Kopieren', copied: 'Kopiert', version: 'Version', live: 'Betrieb', online: 'Online',
endpointHint: 'Endpunkt: POST /api/v1/homeassistant/scan — ein Aufruf im Browser (GET) antwortet mit 405.', authOff: 'Die Anmeldung ist deaktiviert: Jeder im Netzwerk kann diese Oberfläche und die API nutzen.', authOn: 'Melde dich an, um Benutzername, E-Mail und Passwort zu ändern.'
} : {
interface: 'Interface', interfaceLead: 'Language and appearance on this device.', language: 'Language', theme: 'Appearance', system: 'System', light: 'Light', dark: 'Dark',
account: 'Sign-in', accountLead: 'Manage the single administrator account or disable sign-in protection.', enabled: 'Require sign-in', user: 'Username', email: 'Email', current: 'Current password', next: 'New password (optional)', save: 'Update account', saved: 'Account updated', authSaved: 'Sign-in setting saved', logout: 'Log out',
ha: 'Home Assistant', haLead: 'The existing scan API for buttons and automations.', show: 'Show example', hide: 'Hide example', copy: 'Copy', copied: 'Copied', version: 'Version', live: 'Operation', online: 'Online'
ha: 'Home Assistant', haLead: 'The existing scan API for buttons and automations.', show: 'Show example', hide: 'Hide example', copy: 'Copy', copied: 'Copied', version: 'Version', live: 'Operation', online: 'Online',
endpointHint: 'Endpoint: POST /api/v1/homeassistant/scan — opening it in a browser (GET) answers with 405.', authOff: 'Sign-in is disabled: anyone on the network can use this interface and the API.', authOn: 'Sign in to change username, email and password.'
};
$: snippet = `rest_command:\n scan_document:\n url: "${window.location.origin}/api/v1/homeassistant/scan"\n method: POST\n content_type: "application/json"\n payload: '{"scanner_id":"favorite","target_id":"favorite","profile":"${haProfile}"}'`;

Expand Down Expand Up @@ -54,15 +56,18 @@
<div class="setting-row"><span>{c.language}</span><div class="choice-pills compact"><button class:active={$lang === 'de'} on:click={() => lang.set('de')}>Deutsch</button><button class:active={$lang === 'en'} on:click={() => lang.set('en')}>English</button></div></div>
<div class="setting-row"><span>{c.theme}</span><select value={settings.theme} on:change={(event) => onChange({ theme: event.currentTarget.value })}><option value="system">{c.system}</option><option value="light">{c.light}</option><option value="dark">{c.dark}</option></select></div>
<label class="switch-row"><span>{$t('compactTablesLabel')}</span><input type="checkbox" checked={settings.compactTables} on:change={(event) => onChange({ compactTables: event.currentTarget.checked })} /></label>
<label class="switch-row"><span>{$t('autoRefreshLabel')}</span><input type="checkbox" checked={settings.autoRefresh} on:change={(event) => onChange({ autoRefresh: event.currentTarget.checked })} /></label>
</section>

<section class="settings-card"><div class="settings-heading"><span class="settings-icon"><Icon name="logout" /></span><div><h2>{c.account}</h2><p>{c.accountLead}</p></div></div>
<label class="switch-row"><span>{c.enabled}</span><input type="checkbox" checked={authConfig.enabled} on:change={(event) => toggleAuth(event.currentTarget.checked)} /></label>
{#if !appStore.hasToken()}<p class="muted small top-gap">{authConfig.enabled ? c.authOn : c.authOff}</p>{/if}
{#if appStore.hasToken()}<form class="account-form" on:submit|preventDefault={updateAccount}><div class="form-grid"><label>{c.user}<input bind:value={account.username} minlength="3" required /></label><label>{c.email}<input type="email" bind:value={account.email} /></label><label>{c.current}<input type="password" bind:value={account.current_password} required /></label><label>{c.next}<input type="password" bind:value={account.new_password} minlength={account.new_password ? 12 : null} /></label></div><div class="flow-actions"><button type="button" class="btn secondary" on:click={logout}>{c.logout}</button><button class="btn primary" disabled={savingAccount}>{c.save}</button></div></form>{/if}
</section>

<section class="settings-card full"><div class="settings-heading"><span class="settings-icon"><Icon name="bolt" /></span><div><h2>{c.ha}</h2><p>{c.haLead}</p></div></div>
<button class="btn secondary" on:click={() => haOpen = !haOpen}>{haOpen ? c.hide : c.show}</button>
<p class="muted small">{c.endpointHint}</p>
<button class="btn secondary top-gap" on:click={() => haOpen = !haOpen}>{haOpen ? c.hide : c.show}</button>
{#if haOpen}<div class="ha-config"><select bind:value={haProfile}>{#each profiles as profile}<option value={profile.id}>{profile.name}</option>{/each}</select><pre>{snippet}</pre><button class="btn secondary" on:click={copySnippet}><Icon name="copy" size={15} />{c.copy}</button></div>{/if}
</section>

Expand Down
Loading
Loading