ui updates

This commit is contained in:
beo3000 2026-02-24 20:18:09 +01:00
parent f046373184
commit a7f60960f6
7 changed files with 96 additions and 29 deletions

View File

@ -49,9 +49,23 @@ Use case: avoids MSAL silent refresh failures (Azure AD returning HTTP 400 on re
## Deploy ## Deploy
`deploy.ps1` uses `docker build --no-cache` to ensure TypeScript changes are always recompiled. `deploy.ps1` uses normal `docker build` with layer caching. Only changed layers are pushed to ACR.
Without `--no-cache`, Docker reuses the server-build layer → old compiled JS runs in production. `COPY server/ server/` triggers a cache miss whenever server source changes → tsc reruns automatically. `--no-cache` is not needed and was removed (it caused all layers to be rebuilt and pushed on every deploy, >40 MB).
## Delta Push (client-side filtering)
`pushAll(since)` only sends entities with `updatedAt > since.toISOString()`. A read-only client sends 0 entities and skips the HTTP request entirely.
`fullSync()` passes `since=null` → pushes everything (used after DB reset or first sync).
**Root cause for all-conflicts:** `upsertEntity` accepts only `clientVersion > serverVersion`. After a pull, client has `version == serverVersion` → equal-version entities are never pushed (filtered out by delta push). Without delta push, a read-only client would produce 700+ conflicts on every sync cycle.
## Client identification (`X-Client-Id` header)
Each browser instance generates a stable random ID in `localStorage['ka-clientId']`. The header value is `Browser·OS·id` (e.g. `Edge·Win·a3f2`, `Safari·iOS·x9k2`).
Server logs `client:` field in `[sync/push]` and `[sync/pull]` entries.
## ECONNRESET on POST /api/sync/push ## ECONNRESET on POST /api/sync/push

View File

@ -1 +1 @@
1.1.54 1.1.57

View File

@ -49,9 +49,9 @@
showSplit ? openTopics : [...openTopics, ...processedTopics] showSplit ? openTopics : [...openTopics, ...processedTopics]
); );
async function handleCreate(title: string) { async function handleCreate(title: string, body?: string) {
const topic = await createTopic(contextId, title); const topic = await createTopic(contextId, title);
await createHistoryEntry(topic.id, today(), 'Thema angelegt.'); await createHistoryEntry(topic.id, today(), body ?? 'Thema angelegt.');
} }
// --- Drag & Drop --- // --- Drag & Drop ---

View File

@ -1,34 +1,36 @@
<script lang="ts"> <script lang="ts">
import { mention } from '$lib/actions/mention'; import MarkdownEditor from './MarkdownEditor.svelte';
import { extractTitleAndBody } from '$lib/utils/titleUtils';
import { get } from 'svelte/store';
import { scopeSettings } from '$lib/stores/scopeContext';
interface Props { interface Props {
oncreate: (title: string) => void; oncreate: (title: string, body?: string) => void;
} }
let { oncreate }: Props = $props(); let { oncreate }: Props = $props();
let title = $state(''); let raw = $state('');
let editor: MarkdownEditor;
function handleCreate() { function handleCreate() {
const trimmed = title.trim(); const trimmed = raw.trim();
if (!trimmed) return; if (!trimmed) return;
oncreate(trimmed); const { maxTitleLength } = get(scopeSettings);
title = ''; const { title, body } = extractTitleAndBody(trimmed, maxTitleLength);
} oncreate(title, body || undefined);
raw = '';
function handleKeypress(e: KeyboardEvent) { editor?.clear();
if (e.key === 'Enter') handleCreate();
} }
</script> </script>
<div class="mb-8 flex flex-col gap-2.5 rounded-lg border border-border bg-sidebar p-4"> <div class="mb-8 flex flex-col gap-2.5 rounded-lg border border-border bg-sidebar p-4">
<label class="text-sm text-[#aaa]">Neues Thema / Log-Eintrag erstellen:</label> <label class="text-sm text-[#aaa]">Neues Thema / Log-Eintrag erstellen:</label>
<input <MarkdownEditor
type="text" bind:this={editor}
class="w-full rounded border border-[#444] bg-bg px-2.5 py-2 font-mono text-white" placeholder="Titel des Themas... (1. Zeile = Titel)"
placeholder="Titel des Themas..." minHeight="60px"
bind:value={title} onchange={(md) => raw = md}
onkeypress={handleKeypress} onsave={handleCreate}
use:mention
/> />
<button <button
class="self-start rounded bg-accent px-4 py-2 font-bold text-white hover:brightness-110" class="self-start rounded bg-accent px-4 py-2 font-bold text-white hover:brightness-110"

View File

@ -19,6 +19,34 @@ export const lastSyncAt = writable<Date | null>(
const API_BASE = import.meta.env.VITE_API_URL ?? ''; const API_BASE = import.meta.env.VITE_API_URL ?? '';
function getClientId(): string {
const KEY = 'ka-clientId';
let id = localStorage.getItem(KEY);
if (!id) {
id = Math.random().toString(36).slice(2, 8);
localStorage.setItem(KEY, id);
}
return id;
}
function getClientName(): string {
const ua = navigator.userAgent;
let browser = 'Browser';
if (/Edg\//.test(ua)) browser = 'Edge';
else if (/Chrome\//.test(ua)) browser = 'Chrome';
else if (/Firefox\//.test(ua)) browser = 'Firefox';
else if (/Safari\//.test(ua) && !/Chrome/.test(ua)) browser = 'Safari';
let os = 'Unknown';
if (/iPhone|iPad/.test(ua)) os = 'iOS';
else if (/Android/.test(ua)) os = 'Android';
else if (/Win/.test(ua)) os = 'Win';
else if (/Mac/.test(ua)) os = 'Mac';
else if (/Linux/.test(ua)) os = 'Linux';
return `${browser}·${os}·${getClientId()}`;
}
async function apiFetch(path: string, init: RequestInit): Promise<Response> { async function apiFetch(path: string, init: RequestInit): Promise<Response> {
const token = await getAccessToken(); const token = await getAccessToken();
try { try {
@ -30,6 +58,7 @@ async function apiFetch(path: string, init: RequestInit): Promise<Response> {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'X-Client-Id': getClientName(),
...(init.headers ?? {}), ...(init.headers ?? {}),
}, },
}); });
@ -118,8 +147,13 @@ async function pullAndMerge(since: Date | null): Promise<string> {
return data.serverTimestamp; return data.serverTimestamp;
} }
async function pushAll(): Promise<void> { async function pushAll(since: Date | null): Promise<void> {
const [contexts, topics, historyEntries, ratings, localBlobs, pages, notebooks, pageNotebooks] = await Promise.all([ // Only push entities modified after last sync (delta push)
const isNew = since
? (e: { updatedAt: string }) => e.updatedAt > since.toISOString()
: () => true;
const [allContexts, allTopics, allHistoryEntries, allRatings, allBlobs, allPages, allNotebooks, allPageNotebooks] = await Promise.all([
db.contexts.toArray(), db.contexts.toArray(),
db.topics.toArray(), db.topics.toArray(),
db.historyEntries.toArray(), db.historyEntries.toArray(),
@ -130,9 +164,22 @@ async function pushAll(): Promise<void> {
db.pageNotebooks.toArray(), db.pageNotebooks.toArray(),
]); ]);
const changed = {
contexts: allContexts.filter(isNew),
topics: allTopics.filter(isNew),
historyEntries: allHistoryEntries.filter(isNew),
ratings: allRatings.filter(isNew),
localBlobs: allBlobs.filter(isNew),
pages: allPages.filter(isNew),
notebooks: allNotebooks.filter(isNew),
pageNotebooks: allPageNotebooks.filter(isNew),
};
const totalChanged = Object.values(changed).reduce((s, a) => s + a.length, 0);
if (totalChanged === 0) return;
// Encode Blob → base64 for transport (chunked to avoid stack overflow on large blobs) // Encode Blob → base64 for transport (chunked to avoid stack overflow on large blobs)
const imageBlobs = await Promise.all( const imageBlobs = await Promise.all(
localBlobs.map(async (b) => { changed.localBlobs.map(async (b) => {
const buf = await b.data.arrayBuffer(); const buf = await b.data.arrayBuffer();
const bytes = new Uint8Array(buf); const bytes = new Uint8Array(buf);
let binary = ''; let binary = '';
@ -154,6 +201,7 @@ async function pushAll(): Promise<void> {
); );
if (_aiLocked) return; if (_aiLocked) return;
const { contexts, topics, historyEntries, ratings, pages, notebooks, pageNotebooks } = changed;
const body: SyncPushRequest = { changes: { contexts, topics, historyEntries, ratings, imageBlobs, pages, notebooks, pageNotebooks } }; const body: SyncPushRequest = { changes: { contexts, topics, historyEntries, ratings, imageBlobs, pages, notebooks, pageNotebooks } };
const res = await apiFetch('/api/sync/push', { method: 'POST', body: JSON.stringify(body) }); const res = await apiFetch('/api/sync/push', { method: 'POST', body: JSON.stringify(body) });
if (res.status === 423) { console.log('[sync] push blocked by AI lock'); return; } if (res.status === 423) { console.log('[sync] push blocked by AI lock'); return; }
@ -203,7 +251,7 @@ export async function sync(): Promise<void> {
async function doSync(since: Date | null): Promise<void> { async function doSync(since: Date | null): Promise<void> {
syncStatus.set('syncing'); syncStatus.set('syncing');
try { try {
await pushAll(); await pushAll(since);
const sinceWithBuffer = since ? new Date(since.getTime() - 60_000) : null; const sinceWithBuffer = since ? new Date(since.getTime() - 60_000) : null;
const serverTimestamp = await pullAndMerge(sinceWithBuffer); const serverTimestamp = await pullAndMerge(sinceWithBuffer);
const syncTime = new Date(serverTimestamp); const syncTime = new Date(serverTimestamp);

View File

@ -36,7 +36,7 @@ $token | docker login "$ACR.azurecr.io" --username 00000000-0000-0000-0000-00000
if ($LASTEXITCODE -ne 0) { throw "ACR login failed" } if ($LASTEXITCODE -ne 0) { throw "ACR login failed" }
Write-Host "=== Build Docker image ===" -ForegroundColor Cyan Write-Host "=== Build Docker image ===" -ForegroundColor Cyan
docker build -t $IMAGE --no-cache ` docker build -t $IMAGE `
--build-arg VITE_AZURE_CLIENT_ID=$env:AZURE_CLIENT_ID ` --build-arg VITE_AZURE_CLIENT_ID=$env:AZURE_CLIENT_ID `
--build-arg VITE_AZURE_TENANT_ID=$env:AZURE_TENANT_ID ` --build-arg VITE_AZURE_TENANT_ID=$env:AZURE_TENANT_ID `
--build-arg APP_VERSION=$VERSION ` --build-arg APP_VERSION=$VERSION `

View File

@ -19,9 +19,10 @@ sync.post('/push', handle('sync/push', async (c) => {
historyEntries: body.changes.historyEntries?.length ?? 0, historyEntries: body.changes.historyEntries?.length ?? 0,
ratings: body.changes.ratings?.length ?? 0, ratings: body.changes.ratings?.length ?? 0,
}; };
console.log('[sync/push] incoming', { userId, counts }); const clientId = c.req.header('x-client-id') ?? 'unknown';
console.log('[sync/push] incoming', { client: clientId, userId, counts });
const result = await pushChanges(body, userId); const result = await pushChanges(body, userId);
console.log('[sync/push] done', { accepted: result.accepted, conflicts: result.conflicts.length }); console.log('[sync/push] done', { client: clientId, accepted: result.accepted, conflicts: result.conflicts.length });
return c.json(result); return c.json(result);
})); }));
@ -29,7 +30,9 @@ sync.get('/pull', handle('sync/pull', async (c) => {
const { userId } = c.get('auth'); const { userId } = c.get('auth');
await ensureDailyLog(userId); await ensureDailyLog(userId);
const since = c.req.query('since') ?? ''; const since = c.req.query('since') ?? '';
const clientId = c.req.header('x-client-id') ?? 'unknown';
const result = await pullChanges({ since }, userId); const result = await pullChanges({ since }, userId);
console.log('[sync/pull] done', { client: clientId, changes: Object.values(result.changes).reduce((s: number, a) => s + (Array.isArray(a) ? a.length : 0), 0) });
return c.json(result); return c.json(result);
})); }));