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
|
||||||
|
|
||||||
`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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1.1.54
|
1.1.57
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 `
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue