ui updates
This commit is contained in:
parent
f046373184
commit
a7f60960f6
|
|
@ -49,9 +49,23 @@ Use case: avoids MSAL silent refresh failures (Azure AD returning HTTP 400 on re
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1.1.54
|
||||
1.1.57
|
||||
|
|
@ -49,9 +49,9 @@
|
|||
showSplit ? openTopics : [...openTopics, ...processedTopics]
|
||||
);
|
||||
|
||||
async function handleCreate(title: string) {
|
||||
async function handleCreate(title: string, body?: string) {
|
||||
const topic = await createTopic(contextId, title);
|
||||
await createHistoryEntry(topic.id, today(), 'Thema angelegt.');
|
||||
await createHistoryEntry(topic.id, today(), body ?? 'Thema angelegt.');
|
||||
}
|
||||
|
||||
// --- Drag & Drop ---
|
||||
|
|
|
|||
|
|
@ -1,34 +1,36 @@
|
|||
<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 {
|
||||
oncreate: (title: string) => void;
|
||||
oncreate: (title: string, body?: string) => void;
|
||||
}
|
||||
let { oncreate }: Props = $props();
|
||||
|
||||
let title = $state('');
|
||||
let raw = $state('');
|
||||
let editor: MarkdownEditor;
|
||||
|
||||
function handleCreate() {
|
||||
const trimmed = title.trim();
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
oncreate(trimmed);
|
||||
title = '';
|
||||
}
|
||||
|
||||
function handleKeypress(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
const { maxTitleLength } = get(scopeSettings);
|
||||
const { title, body } = extractTitleAndBody(trimmed, maxTitleLength);
|
||||
oncreate(title, body || undefined);
|
||||
raw = '';
|
||||
editor?.clear();
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full rounded border border-[#444] bg-bg px-2.5 py-2 font-mono text-white"
|
||||
placeholder="Titel des Themas..."
|
||||
bind:value={title}
|
||||
onkeypress={handleKeypress}
|
||||
use:mention
|
||||
<MarkdownEditor
|
||||
bind:this={editor}
|
||||
placeholder="Titel des Themas... (1. Zeile = Titel)"
|
||||
minHeight="60px"
|
||||
onchange={(md) => raw = md}
|
||||
onsave={handleCreate}
|
||||
/>
|
||||
<button
|
||||
class="self-start rounded bg-accent px-4 py-2 font-bold text-white hover:brightness-110"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,34 @@ export const lastSyncAt = writable<Date | null>(
|
|||
|
||||
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> {
|
||||
const token = await getAccessToken();
|
||||
try {
|
||||
|
|
@ -30,6 +58,7 @@ async function apiFetch(path: string, init: RequestInit): Promise<Response> {
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Client-Id': getClientName(),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
|
@ -118,8 +147,13 @@ async function pullAndMerge(since: Date | null): Promise<string> {
|
|||
return data.serverTimestamp;
|
||||
}
|
||||
|
||||
async function pushAll(): Promise<void> {
|
||||
const [contexts, topics, historyEntries, ratings, localBlobs, pages, notebooks, pageNotebooks] = await Promise.all([
|
||||
async function pushAll(since: Date | null): Promise<void> {
|
||||
// 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.topics.toArray(),
|
||||
db.historyEntries.toArray(),
|
||||
|
|
@ -130,9 +164,22 @@ async function pushAll(): Promise<void> {
|
|||
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)
|
||||
const imageBlobs = await Promise.all(
|
||||
localBlobs.map(async (b) => {
|
||||
changed.localBlobs.map(async (b) => {
|
||||
const buf = await b.data.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
|
|
@ -154,6 +201,7 @@ async function pushAll(): Promise<void> {
|
|||
);
|
||||
|
||||
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 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; }
|
||||
|
|
@ -203,7 +251,7 @@ export async function sync(): Promise<void> {
|
|||
async function doSync(since: Date | null): Promise<void> {
|
||||
syncStatus.set('syncing');
|
||||
try {
|
||||
await pushAll();
|
||||
await pushAll(since);
|
||||
const sinceWithBuffer = since ? new Date(since.getTime() - 60_000) : null;
|
||||
const serverTimestamp = await pullAndMerge(sinceWithBuffer);
|
||||
const syncTime = new Date(serverTimestamp);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ $token | docker login "$ACR.azurecr.io" --username 00000000-0000-0000-0000-00000
|
|||
if ($LASTEXITCODE -ne 0) { throw "ACR login failed" }
|
||||
|
||||
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_TENANT_ID=$env:AZURE_TENANT_ID `
|
||||
--build-arg APP_VERSION=$VERSION `
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ sync.post('/push', handle('sync/push', async (c) => {
|
|||
historyEntries: body.changes.historyEntries?.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);
|
||||
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);
|
||||
}));
|
||||
|
||||
|
|
@ -29,7 +30,9 @@ sync.get('/pull', handle('sync/pull', async (c) => {
|
|||
const { userId } = c.get('auth');
|
||||
await ensureDailyLog(userId);
|
||||
const since = c.req.query('since') ?? '';
|
||||
const clientId = c.req.header('x-client-id') ?? 'unknown';
|
||||
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);
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue