mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
rename(taktik): rebrand to Times
Rename taktik → times across the entire app: package names (@taktik → @times), appId, localStorage keys, export filenames, type names (TaktikSettings → TimesSettings), monorepo scripts, shared-branding, mana-auth trustedOrigins, docker-compose, and documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1eb370eaaa
commit
c33339b0cf
92 changed files with 970 additions and 1263 deletions
|
|
@ -54,7 +54,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
|
||||||
| **citycorners** | City guide for Konstanz | Web, Landing |
|
| **citycorners** | City guide for Konstanz | Web, Landing |
|
||||||
| **inventar** | Inventory management | Web |
|
| **inventar** | Inventory management | Web |
|
||||||
| **traces** | City exploration | Backend, Mobile |
|
| **traces** | City exploration | Backend, Mobile |
|
||||||
| **taktik** | Time tracking | Web |
|
| **times** | Time tracking | Web |
|
||||||
| **uload** | URL shortener & link management | Server, Web, Landing |
|
| **uload** | URL shortener & link management | Server, Web, Landing |
|
||||||
| **news** | AI news reader & personal library | Server, Web, Landing |
|
| **news** | AI news reader & personal library | Server, Web, Landing |
|
||||||
| **wisekeep** | AI transcription & wisdom library | Server, Web, Landing |
|
| **wisekeep** | AI transcription & wisdom library | Server, Web, Landing |
|
||||||
|
|
@ -585,7 +585,7 @@ Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → Postg
|
||||||
| Photos | albums, albumItems, favorites, tags, photoTags | Done |
|
| Photos | albums, albumItems, favorites, tags, photoTags | Done |
|
||||||
| SkilltTree | skills, activities, achievements | Done |
|
| SkilltTree | skills, activities, achievements | Done |
|
||||||
| CityCorners | locations, favorites | Done |
|
| CityCorners | locations, favorites | Done |
|
||||||
| Taktik | clients, projects, timeEntries, tags, templates, settings | Done |
|
| Times | clients, projects, timeEntries, tags, templates, settings | Done |
|
||||||
| uLoad | links, tags, folders, linkTags | Done |
|
| uLoad | links, tags, folders, linkTags | Done |
|
||||||
| Calc | calculations, savedFormulas | Done |
|
| Calc | calculations, savedFormulas | Done |
|
||||||
| ManaCore | userSettings, dashboardConfigs | Done |
|
| ManaCore | userSettings, dashboardConfigs | Done |
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
---
|
---
|
||||||
title: 'Taktik: Production Readiness Audit'
|
title: 'Times: Production Readiness Audit'
|
||||||
description: 'Zeiterfassung mit Live-Timer, Projekten, Kunden, Reports, CSV-Export, Templates und Abrechnungsraten - local-first mit umfassender Dokumentation und solider Testabdeckung'
|
description: 'Zeiterfassung mit Live-Timer, Projekten, Kunden, Reports, CSV-Export, Templates und Abrechnungsraten - local-first mit umfassender Dokumentation und solider Testabdeckung'
|
||||||
date: 2026-03-30
|
date: 2026-03-30
|
||||||
app: 'taktik'
|
app: 'times'
|
||||||
author: 'Claude Code'
|
author: 'Claude Code'
|
||||||
tags: ['audit', 'taktik', 'production-readiness', 'beta']
|
tags: ['audit', 'times', 'production-readiness', 'beta']
|
||||||
score: 55
|
score: 55
|
||||||
scores:
|
scores:
|
||||||
backend: 5
|
backend: 5
|
||||||
|
|
@ -39,7 +39,7 @@ stats:
|
||||||
|
|
||||||
## Zusammenfassung
|
## Zusammenfassung
|
||||||
|
|
||||||
Taktik ist eine **vollwertige Zeiterfassung** mit Live-Timer, Projekt-/Kunden-Management, Reports mit Charts, CSV-Export und konfigurierbaren Abrechnungsraten. Local-first mit 6 Dexie-Collections, 4 Testdateien und umfassender CLAUDE.md. Feature-komplett für den Produktiveinsatz.
|
Times ist eine **vollwertige Zeiterfassung** mit Live-Timer, Projekt-/Kunden-Management, Reports mit Charts, CSV-Export und konfigurierbaren Abrechnungsraten. Local-first mit 6 Dexie-Collections, 4 Testdateien und umfassender CLAUDE.md. Feature-komplett für den Produktiveinsatz.
|
||||||
|
|
||||||
## Backend (5/100)
|
## Backend (5/100)
|
||||||
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"name": "taktik",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "Taktik - Zeiterfassung & Timetracking",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "pnpm --filter @taktik/web dev",
|
|
||||||
"dev:web": "pnpm --filter @taktik/web dev"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.9.3"
|
|
||||||
},
|
|
||||||
"packageManager": "pnpm@9.15.0"
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Taktik
|
# Times
|
||||||
|
|
||||||
Zeiterfassung & Timetracking - Dein Arbeitsrhythmus, messbar gemacht.
|
Zeiterfassung & Timetracking - Dein Arbeitsrhythmus, messbar gemacht.
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ Zeiterfassung & Timetracking - Dein Arbeitsrhythmus, messbar gemacht.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Taktik is a professional time tracking app with timer, manual entry, projects, clients, reports, templates, and guild (team) integration. Built local-first for offline capability and instant UI.
|
Times is a professional time tracking app with timer, manual entry, projects, clients, reports, templates, and guild (team) integration. Built local-first for offline capability and instant UI.
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
|
|
@ -24,16 +24,16 @@ Taktik is a professional time tracking app with timer, manual entry, projects, c
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From monorepo root
|
# From monorepo root
|
||||||
pnpm dev:taktik:web # Start web app on port 5197
|
pnpm dev:times:web # Start web app on port 5197
|
||||||
pnpm dev:taktik:full # Start with auth + sync server
|
pnpm dev:times:full # Start with auth + sync server
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
pnpm --filter @taktik/web test # Run all tests
|
pnpm --filter @times/web test # Run all tests
|
||||||
pnpm --filter @taktik/web test:unit # Run in watch mode
|
pnpm --filter @times/web test:unit # Run in watch mode
|
||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
pnpm --filter @taktik/web type-check
|
pnpm --filter @times/web type-check
|
||||||
pnpm --filter @taktik/shared type-check
|
pnpm --filter @times/shared type-check
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
@ -104,7 +104,7 @@ pnpm --filter @taktik/shared type-check
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/taktik/
|
apps/times/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ └── web/ # SvelteKit web client (port 5197)
|
│ └── web/ # SvelteKit web client (port 5197)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
|
|
@ -160,7 +160,7 @@ apps/taktik/
|
||||||
│ │ └── version.ts
|
│ │ └── version.ts
|
||||||
│ └── static/
|
│ └── static/
|
||||||
├── packages/
|
├── packages/
|
||||||
│ └── shared/ # @taktik/shared
|
│ └── shared/ # @times/shared
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── types/index.ts # All TypeScript types
|
│ ├── types/index.ts # All TypeScript types
|
||||||
│ ├── constants/index.ts # Currencies, colors, defaults
|
│ ├── constants/index.ts # Currencies, colors, defaults
|
||||||
|
|
@ -9,15 +9,15 @@ ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
|
||||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||||
|
|
||||||
# Copy app-specific packages
|
# Copy app-specific packages
|
||||||
COPY apps/taktik/packages/shared ./apps/taktik/packages/shared
|
COPY apps/times/packages/shared ./apps/times/packages/shared
|
||||||
COPY apps/taktik/apps/web ./apps/taktik/apps/web
|
COPY apps/times/apps/web ./apps/times/apps/web
|
||||||
|
|
||||||
# Install app-specific dependencies
|
# Install app-specific dependencies
|
||||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||||
pnpm install --no-frozen-lockfile --ignore-scripts
|
pnpm install --no-frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
# Build the web app
|
# Build the web app
|
||||||
WORKDIR /app/apps/taktik/apps/web
|
WORKDIR /app/apps/times/apps/web
|
||||||
RUN pnpm exec svelte-kit sync
|
RUN pnpm exec svelte-kit sync
|
||||||
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
|
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
|
||||||
|
|
||||||
|
|
@ -25,17 +25,17 @@ RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
# Keep same directory structure as builder so pnpm symlinks resolve correctly
|
# Keep same directory structure as builder so pnpm symlinks resolve correctly
|
||||||
WORKDIR /app/apps/taktik/apps/web
|
WORKDIR /app/apps/times/apps/web
|
||||||
|
|
||||||
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
|
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
|
||||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||||
|
|
||||||
# Copy the app's node_modules (contains symlinks to the pnpm store)
|
# Copy the app's node_modules (contains symlinks to the pnpm store)
|
||||||
COPY --from=builder /app/apps/taktik/apps/web/node_modules ./node_modules
|
COPY --from=builder /app/apps/times/apps/web/node_modules ./node_modules
|
||||||
|
|
||||||
# Copy built application
|
# Copy built application
|
||||||
COPY --from=builder /app/apps/taktik/apps/web/build ./build
|
COPY --from=builder /app/apps/times/apps/web/build ./build
|
||||||
COPY --from=builder /app/apps/taktik/apps/web/package.json ./
|
COPY --from=builder /app/apps/times/apps/web/package.json ./
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 5027
|
EXPOSE 5027
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@taktik/web",
|
"name": "@times/web",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
"@manacore/shared-types": "workspace:*",
|
"@manacore/shared-types": "workspace:*",
|
||||||
"@manacore/shared-ui": "workspace:*",
|
"@manacore/shared-ui": "workspace:*",
|
||||||
"@manacore/shared-utils": "workspace:*",
|
"@manacore/shared-utils": "workspace:*",
|
||||||
"@taktik/shared": "workspace:*",
|
"@times/shared": "workspace:*",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"svelte-i18n": "^4.0.1"
|
"svelte-i18n": "^4.0.1"
|
||||||
},
|
},
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||||
<title>Taktik</title>
|
<title>Times</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { timeEntryCollection } from '$lib/data/local-store';
|
import { timeEntryCollection } from '$lib/data/local-store';
|
||||||
import type { Project, Client } from '@taktik/shared';
|
import type { Project, Client } from '@times/shared';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
visible = false,
|
visible = false,
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { timeEntryCollection } from '$lib/data/local-store';
|
import { timeEntryCollection } from '$lib/data/local-store';
|
||||||
import { formatDurationCompact } from '$lib/data/queries';
|
import { formatDurationCompact } from '$lib/data/queries';
|
||||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
import type { TimeEntry, Project, Client } from '@times/shared';
|
||||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import EntryItem from './EntryItem.svelte';
|
import EntryItem from './EntryItem.svelte';
|
||||||
import { groupEntriesByDate, getTotalDuration, formatDurationCompact } from '$lib/data/queries';
|
import { groupEntriesByDate, getTotalDuration, formatDurationCompact } from '$lib/data/queries';
|
||||||
import type { TimeEntry } from '@taktik/shared';
|
import type { TimeEntry } from '@times/shared';
|
||||||
|
|
||||||
let { entries }: { entries: TimeEntry[] } = $props();
|
let { entries }: { entries: TimeEntry[] } = $props();
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { timerStore } from '$lib/stores/timer.svelte';
|
import { timerStore } from '$lib/stores/timer.svelte';
|
||||||
import type { TimeEntry, Project } from '@taktik/shared';
|
import type { TimeEntry, Project } from '@times/shared';
|
||||||
|
|
||||||
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
|
||||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { timerStore } from '$lib/stores/timer.svelte';
|
import { timerStore } from '$lib/stores/timer.svelte';
|
||||||
import { formatDuration } from '$lib/data/queries';
|
import { formatDuration } from '$lib/data/queries';
|
||||||
import type { Project, Client } from '@taktik/shared';
|
import type { Project, Client } from '@times/shared';
|
||||||
|
|
||||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||||
const allClients = getContext<{ value: Client[] }>('clients');
|
const allClients = getContext<{ value: Client[] }>('clients');
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { timerStore } from '$lib/stores/timer.svelte';
|
import { timerStore } from '$lib/stores/timer.svelte';
|
||||||
import { formatDuration } from '$lib/data/queries';
|
import { formatDuration } from '$lib/data/queries';
|
||||||
import type { Project } from '@taktik/shared';
|
import type { Project } from '@times/shared';
|
||||||
|
|
||||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Guest seed data for the Taktik app.
|
* Guest seed data for the Times app.
|
||||||
*
|
*
|
||||||
* Provides demo clients, projects, and time entries for the guest experience.
|
* Provides demo clients, projects, and time entries for the guest experience.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Taktik — Local-First Data Layer
|
* Times — Local-First Data Layer
|
||||||
*
|
*
|
||||||
* IndexedDB (Dexie.js) with sync support for time tracking.
|
* IndexedDB (Dexie.js) with sync support for time tracking.
|
||||||
* Clients, projects, time entries, tags, templates, and settings.
|
* Clients, projects, time entries, tags, templates, and settings.
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
guestTags,
|
guestTags,
|
||||||
guestSettings,
|
guestSettings,
|
||||||
} from './guest-seed';
|
} from './guest-seed';
|
||||||
import type { BillingRate, ProjectVisibility, EntrySourceRef } from '@taktik/shared';
|
import type { BillingRate, ProjectVisibility, EntrySourceRef } from '@times/shared';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -97,8 +97,8 @@ export interface LocalSettings extends BaseRecord {
|
||||||
|
|
||||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||||
|
|
||||||
export const taktikStore = createLocalStore({
|
export const timesStore = createLocalStore({
|
||||||
appId: 'taktik',
|
appId: 'times',
|
||||||
collections: [
|
collections: [
|
||||||
{
|
{
|
||||||
name: 'clients',
|
name: 'clients',
|
||||||
|
|
@ -145,9 +145,9 @@ export const taktikStore = createLocalStore({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Typed collection accessors
|
// Typed collection accessors
|
||||||
export const clientCollection = taktikStore.collection<LocalClient>('clients');
|
export const clientCollection = timesStore.collection<LocalClient>('clients');
|
||||||
export const projectCollection = taktikStore.collection<LocalProject>('projects');
|
export const projectCollection = timesStore.collection<LocalProject>('projects');
|
||||||
export const timeEntryCollection = taktikStore.collection<LocalTimeEntry>('timeEntries');
|
export const timeEntryCollection = timesStore.collection<LocalTimeEntry>('timeEntries');
|
||||||
export const tagCollection = taktikStore.collection<LocalTag>('tags');
|
export const tagCollection = timesStore.collection<LocalTag>('tags');
|
||||||
export const templateCollection = taktikStore.collection<LocalTemplate>('templates');
|
export const templateCollection = timesStore.collection<LocalTemplate>('templates');
|
||||||
export const settingsCollection = taktikStore.collection<LocalSettings>('settings');
|
export const settingsCollection = timesStore.collection<LocalSettings>('settings');
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
getClientById,
|
getClientById,
|
||||||
getProjectsByClient,
|
getProjectsByClient,
|
||||||
} from './queries';
|
} from './queries';
|
||||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
import type { TimeEntry, Project, Client } from '@times/shared';
|
||||||
|
|
||||||
// ─── Test Factories ──────────────────────────────────────
|
// ─── Test Factories ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Reactive Queries & Pure Helpers for Taktik
|
* Reactive Queries & Pure Helpers for Times
|
||||||
*
|
*
|
||||||
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
|
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
|
||||||
* (local writes, sync updates, other tabs).
|
* (local writes, sync updates, other tabs).
|
||||||
|
|
@ -26,10 +26,10 @@ import type {
|
||||||
TimeEntry,
|
TimeEntry,
|
||||||
Tag,
|
Tag,
|
||||||
EntryTemplate,
|
EntryTemplate,
|
||||||
TaktikSettings,
|
TimesSettings,
|
||||||
FilterCriteria,
|
FilterCriteria,
|
||||||
SortOption,
|
SortOption,
|
||||||
} from '@taktik/shared';
|
} from '@times/shared';
|
||||||
|
|
||||||
// ─── Type Converters ───────────────────────────────────────
|
// ─── Type Converters ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -118,7 +118,7 @@ export function toTemplate(local: LocalTemplate): EntryTemplate {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toSettings(local: LocalSettings): TaktikSettings {
|
export function toSettings(local: LocalSettings): TimesSettings {
|
||||||
return {
|
return {
|
||||||
id: local.id,
|
id: local.id,
|
||||||
defaultBillingRate: local.defaultBillingRate ?? undefined,
|
defaultBillingRate: local.defaultBillingRate ?? undefined,
|
||||||
|
|
@ -178,7 +178,7 @@ export function useSettings() {
|
||||||
const locals = await settingsCollection.getAll();
|
const locals = await settingsCollection.getAll();
|
||||||
return locals.length > 0 ? toSettings(locals[0]) : null;
|
return locals.length > 0 ? toSettings(locals[0]) : null;
|
||||||
},
|
},
|
||||||
null as TaktikSettings | null
|
null as TimesSettings | null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5,11 +5,11 @@ import type {
|
||||||
TimeEntry,
|
TimeEntry,
|
||||||
Tag,
|
Tag,
|
||||||
EntryTemplate,
|
EntryTemplate,
|
||||||
TaktikSettings,
|
TimesSettings,
|
||||||
BillingRate,
|
BillingRate,
|
||||||
FilterCriteria,
|
FilterCriteria,
|
||||||
SortOption,
|
SortOption,
|
||||||
} from '@taktik/shared';
|
} from '@times/shared';
|
||||||
|
|
||||||
describe('Shared Types', () => {
|
describe('Shared Types', () => {
|
||||||
it('BillingRate has correct shape', () => {
|
it('BillingRate has correct shape', () => {
|
||||||
|
|
@ -11,7 +11,7 @@ register('en', () => import('./locales/en.json'));
|
||||||
|
|
||||||
function getInitialLocale(): SupportedLocale {
|
function getInitialLocale(): SupportedLocale {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
const stored = localStorage.getItem('taktik_locale');
|
const stored = localStorage.getItem('times_locale');
|
||||||
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
|
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
|
||||||
return stored as SupportedLocale;
|
return stored as SupportedLocale;
|
||||||
}
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ init({
|
||||||
export function setLocale(newLocale: SupportedLocale) {
|
export function setLocale(newLocale: SupportedLocale) {
|
||||||
locale.set(newLocale);
|
locale.set(newLocale);
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem('taktik_locale', newLocale);
|
localStorage.setItem('times_locale', newLocale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Taktik",
|
"name": "Times",
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
"tagline": "Dein Arbeitsrhythmus, messbar gemacht."
|
"tagline": "Dein Arbeitsrhythmus, messbar gemacht."
|
||||||
},
|
},
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"name": "Taktik",
|
"name": "Times",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"tagline": "Your work rhythm, made measurable."
|
"tagline": "Your work rhythm, made measurable."
|
||||||
},
|
},
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createThemeStore } from '@manacore/shared-theme';
|
import { createThemeStore } from '@manacore/shared-theme';
|
||||||
|
|
||||||
export const theme = createThemeStore({
|
export const theme = createThemeStore({
|
||||||
appId: 'taktik',
|
appId: 'times',
|
||||||
defaultVariant: 'ocean',
|
defaultVariant: 'ocean',
|
||||||
});
|
});
|
||||||
|
|
@ -12,7 +12,7 @@ function getAuthUrl(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userSettings = createUserSettingsStore({
|
export const userSettings = createUserSettingsStore({
|
||||||
appId: 'taktik',
|
appId: 'times',
|
||||||
authUrl: getAuthUrl,
|
authUrl: getAuthUrl,
|
||||||
getAccessToken: () => authStore.getAccessToken(),
|
getAccessToken: () => authStore.getAccessToken(),
|
||||||
});
|
});
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import type { ViewMode, SortOption, FilterCriteria, SavedFilter } from '@taktik/shared';
|
import type { ViewMode, SortOption, FilterCriteria, SavedFilter } from '@times/shared';
|
||||||
|
|
||||||
const VIEW_KEY = 'taktik_view_mode';
|
const VIEW_KEY = 'times_view_mode';
|
||||||
const SORT_KEY = 'taktik_sort';
|
const SORT_KEY = 'times_sort';
|
||||||
const FILTERS_KEY = 'taktik_saved_filters';
|
const FILTERS_KEY = 'times_saved_filters';
|
||||||
|
|
||||||
function load<T>(key: string, fallback: T): T {
|
function load<T>(key: string, fallback: T): T {
|
||||||
if (!browser) return fallback;
|
if (!browser) return fallback;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
import type { TimeEntry, Project, Client } from '@times/shared';
|
||||||
|
|
||||||
// We test the CSV generation logic without triggering DOM download.
|
// We test the CSV generation logic without triggering DOM download.
|
||||||
// This mirrors the core logic from export.ts.
|
// This mirrors the core logic from export.ts.
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* CSV Export utility for time entries
|
* CSV Export utility for time entries
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
import type { TimeEntry, Project, Client } from '@times/shared';
|
||||||
|
|
||||||
export function exportEntriesToCSV(
|
export function exportEntriesToCSV(
|
||||||
entries: TimeEntry[],
|
entries: TimeEntry[],
|
||||||
|
|
@ -55,7 +55,7 @@ export function exportEntriesToCSV(
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `taktik-export-${new Date().toISOString().split('T')[0]}.csv`;
|
a.download = `times-export-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Applies rounding based on user settings (increment + method).
|
* Applies rounding based on user settings (increment + method).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RoundingMethod } from '@taktik/shared';
|
import type { RoundingMethod } from '@times/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Round a duration in seconds based on settings.
|
* Round a duration in seconds based on settings.
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
import { getPillAppItems } from '@manacore/shared-branding';
|
import { getPillAppItems } from '@manacore/shared-branding';
|
||||||
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||||
import { taktikStore } from '$lib/data/local-store';
|
import { timesStore } from '$lib/data/local-store';
|
||||||
import {
|
import {
|
||||||
useAllClients,
|
useAllClients,
|
||||||
useAllProjects,
|
useAllProjects,
|
||||||
|
|
@ -46,17 +46,17 @@
|
||||||
setContext('settings', settings);
|
setContext('settings', settings);
|
||||||
|
|
||||||
async function handleAuthReady() {
|
async function handleAuthReady() {
|
||||||
await taktikStore.initialize();
|
await timesStore.initialize();
|
||||||
|
|
||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
taktikStore.startSync(() => authStore.getValidToken());
|
timesStore.startSync(() => authStore.getValidToken());
|
||||||
}
|
}
|
||||||
|
|
||||||
viewStore.initialize();
|
viewStore.initialize();
|
||||||
await timerStore.initialize();
|
await timerStore.initialize();
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('taktik')) {
|
if (!authStore.isAuthenticated && shouldShowGuestWelcome('times')) {
|
||||||
showGuestWelcome = true;
|
showGuestWelcome = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +105,7 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-lg font-bold text-[hsl(var(--foreground))]">Taktik</span>
|
<span class="text-lg font-bold text-[hsl(var(--foreground))]">Times</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Nav Items -->
|
<!-- Nav Items -->
|
||||||
|
|
@ -203,7 +203,7 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<GuestWelcomeModal
|
<GuestWelcomeModal
|
||||||
appId="taktik"
|
appId="times"
|
||||||
visible={showGuestWelcome}
|
visible={showGuestWelcome}
|
||||||
onClose={() => (showGuestWelcome = false)}
|
onClose={() => (showGuestWelcome = false)}
|
||||||
onLogin={() => goto('/login')}
|
onLogin={() => goto('/login')}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
import type { TimeEntry, Project, Client } from '@times/shared';
|
||||||
import {
|
import {
|
||||||
getEntriesByDate,
|
getEntriesByDate,
|
||||||
getTotalDuration,
|
getTotalDuration,
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Timer | Taktik</title>
|
<title>Timer | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { clientCollection } from '$lib/data/local-store';
|
import { clientCollection } from '$lib/data/local-store';
|
||||||
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
|
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
|
||||||
import type { Client, Project, TimeEntry } from '@taktik/shared';
|
import type { Client, Project, TimeEntry } from '@times/shared';
|
||||||
import { PROJECT_COLORS } from '@taktik/shared/constants';
|
import { PROJECT_COLORS } from '@times/shared/constants';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
const allClients = getContext<{ value: Client[] }>('clients');
|
const allClients = getContext<{ value: Client[] }>('clients');
|
||||||
|
|
@ -101,7 +101,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_('nav.clients')} | Taktik</title>
|
<title>{$_('nav.clients')} | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
formatDurationDecimal,
|
formatDurationDecimal,
|
||||||
} from '$lib/data/queries';
|
} from '$lib/data/queries';
|
||||||
import EntryList from '$lib/components/EntryList.svelte';
|
import EntryList from '$lib/components/EntryList.svelte';
|
||||||
import type { Project, Client, TimeEntry } from '@taktik/shared';
|
import type { Project, Client, TimeEntry } from '@times/shared';
|
||||||
|
|
||||||
const allClients = getContext<{ value: Client[] }>('clients');
|
const allClients = getContext<{ value: Client[] }>('clients');
|
||||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{client?.name || 'Kunde'} | Taktik</title>
|
<title>{client?.name || 'Kunde'} | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !client}
|
{#if !client}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import type { TimeEntry } from '@taktik/shared';
|
import type { TimeEntry } from '@times/shared';
|
||||||
import {
|
import {
|
||||||
getFilteredEntries,
|
getFilteredEntries,
|
||||||
getSortedEntries,
|
getSortedEntries,
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_('nav.entries')} | Taktik</title>
|
<title>{$_('nav.entries')} | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Feedback | Taktik</title>
|
<title>Feedback | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Hilfe | Taktik</title>
|
<title>Hilfe | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Mana | Taktik</title>
|
<title>Mana | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Profil | Taktik</title>
|
<title>Profil | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { projectCollection } from '$lib/data/local-store';
|
import { projectCollection } from '$lib/data/local-store';
|
||||||
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
|
import { getTotalDuration, formatDurationCompact } from '$lib/data/queries';
|
||||||
import type { Project, Client, TimeEntry } from '@taktik/shared';
|
import type { Project, Client, TimeEntry } from '@times/shared';
|
||||||
import { PROJECT_COLORS } from '@taktik/shared/constants';
|
import { PROJECT_COLORS } from '@times/shared/constants';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_('nav.projects')} | Taktik</title>
|
<title>{$_('nav.projects')} | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
@ -11,8 +11,8 @@
|
||||||
formatDurationDecimal,
|
formatDurationDecimal,
|
||||||
} from '$lib/data/queries';
|
} from '$lib/data/queries';
|
||||||
import EntryList from '$lib/components/EntryList.svelte';
|
import EntryList from '$lib/components/EntryList.svelte';
|
||||||
import type { Project, Client, TimeEntry } from '@taktik/shared';
|
import type { Project, Client, TimeEntry } from '@times/shared';
|
||||||
import { PROJECT_COLORS } from '@taktik/shared/constants';
|
import { PROJECT_COLORS } from '@times/shared/constants';
|
||||||
|
|
||||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||||
const allClients = getContext<{ value: Client[] }>('clients');
|
const allClients = getContext<{ value: Client[] }>('clients');
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{project?.name || 'Projekt'} | Taktik</title>
|
<title>{project?.name || 'Projekt'} | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !project}
|
{#if !project}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import type { TimeEntry, Project, Client } from '@taktik/shared';
|
import type { TimeEntry, Project, Client } from '@times/shared';
|
||||||
import { exportEntriesToCSV } from '$lib/utils/export';
|
import { exportEntriesToCSV } from '$lib/utils/export';
|
||||||
import {
|
import {
|
||||||
getTotalDuration,
|
getTotalDuration,
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_('nav.reports')} | Taktik</title>
|
<title>{$_('nav.reports')} | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { settingsCollection } from '$lib/data/local-store';
|
import { settingsCollection } from '$lib/data/local-store';
|
||||||
import type { TaktikSettings } from '@taktik/shared';
|
import type { TimesSettings } from '@times/shared';
|
||||||
import { CURRENCIES, ROUNDING_INCREMENTS } from '@taktik/shared/constants';
|
import { CURRENCIES, ROUNDING_INCREMENTS } from '@times/shared/constants';
|
||||||
|
|
||||||
const settings = getContext<{ value: TaktikSettings | null }>('settings');
|
const settings = getContext<{ value: TimesSettings | null }>('settings');
|
||||||
|
|
||||||
// Local edit state, synced from settings
|
// Local edit state, synced from settings
|
||||||
let workingHoursPerDay = $state(8);
|
let workingHoursPerDay = $state(8);
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_('settings.title')} | Taktik</title>
|
<title>{$_('settings.title')} | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { templateCollection, timeEntryCollection } from '$lib/data/local-store';
|
import { templateCollection, timeEntryCollection } from '$lib/data/local-store';
|
||||||
import { timerStore } from '$lib/stores/timer.svelte';
|
import { timerStore } from '$lib/stores/timer.svelte';
|
||||||
import type { EntryTemplate, Project, Client } from '@taktik/shared';
|
import type { EntryTemplate, Project, Client } from '@times/shared';
|
||||||
|
|
||||||
const allTemplates = getContext<{ value: EntryTemplate[] }>('templates');
|
const allTemplates = getContext<{ value: EntryTemplate[] }>('templates');
|
||||||
const allProjects = getContext<{ value: Project[] }>('projects');
|
const allProjects = getContext<{ value: Project[] }>('projects');
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_('nav.templates')} | Taktik</title>
|
<title>{$_('nav.templates')} | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Themes | Taktik</title>
|
<title>Themes | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -96,7 +96,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{showRegister ? $_('auth.register') : $_('auth.login')} | Taktik</title>
|
<title>{showRegister ? $_('auth.register') : $_('auth.login')} | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
|
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Taktik</h1>
|
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Times</h1>
|
||||||
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">Zeiterfassung</p>
|
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">Zeiterfassung</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -256,7 +256,7 @@
|
||||||
<!-- App switcher -->
|
<!-- App switcher -->
|
||||||
<div class="mt-6 flex flex-wrap justify-center gap-2">
|
<div class="mt-6 flex flex-wrap justify-center gap-2">
|
||||||
{#each getPillAppItems() as app}
|
{#each getPillAppItems() as app}
|
||||||
{#if app.id !== 'taktik'}
|
{#if app.id !== 'times'}
|
||||||
<a
|
<a
|
||||||
href={app.url}
|
href={app.url}
|
||||||
class="rounded-full border border-[hsl(var(--border))] px-3 py-1 text-xs text-[hsl(var(--muted-foreground))] transition-colors hover:bg-[hsl(var(--accent))] hover:text-[hsl(var(--accent-foreground))]"
|
class="rounded-full border border-[hsl(var(--border))] px-3 py-1 text-xs text-[hsl(var(--muted-foreground))] transition-colors hover:bg-[hsl(var(--accent))] hover:text-[hsl(var(--accent-foreground))]"
|
||||||
|
|
@ -5,6 +5,6 @@ export const GET: RequestHandler = async () => {
|
||||||
return json({
|
return json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
service: 'taktik-web',
|
service: 'times-web',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Offline | Taktik</title>
|
<title>Offline | Times</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
|
<div class="flex min-h-screen items-center justify-center bg-[hsl(var(--background))] p-4">
|
||||||
|
|
@ -11,8 +11,8 @@ export default defineConfig({
|
||||||
SvelteKitPWA({
|
SvelteKitPWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Taktik',
|
name: 'Times',
|
||||||
short_name: 'Taktik',
|
short_name: 'Times',
|
||||||
description: 'Zeiterfassung & Timetracking',
|
description: 'Zeiterfassung & Timetracking',
|
||||||
theme_color: '#f59e0b',
|
theme_color: '#f59e0b',
|
||||||
background_color: '#0f172a',
|
background_color: '#0f172a',
|
||||||
14
apps/times/package.json
Normal file
14
apps/times/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "times",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Times - Zeiterfassung & Timetracking",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --filter @times/web dev",
|
||||||
|
"dev:web": "pnpm --filter @times/web dev"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.0"
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@taktik/shared",
|
"name": "@times/shared",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -110,7 +110,7 @@ export interface EntryTemplate {
|
||||||
|
|
||||||
export type RoundingMethod = 'none' | 'up' | 'down' | 'nearest';
|
export type RoundingMethod = 'none' | 'up' | 'down' | 'nearest';
|
||||||
|
|
||||||
export interface TaktikSettings {
|
export interface TimesSettings {
|
||||||
id: string;
|
id: string;
|
||||||
defaultBillingRate?: BillingRate;
|
defaultBillingRate?: BillingRate;
|
||||||
workingHoursPerDay: number;
|
workingHoursPerDay: number;
|
||||||
|
|
@ -287,7 +287,7 @@ services:
|
||||||
SMTP_PASS: ${SMTP_PASSWORD}
|
SMTP_PASS: ${SMTP_PASSWORD}
|
||||||
SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET:-}
|
SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET:-}
|
||||||
MAX_DAILY_SIGNUPS: ${MAX_DAILY_SIGNUPS:-0}
|
MAX_DAILY_SIGNUPS: ${MAX_DAILY_SIGNUPS:-0}
|
||||||
CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://taktik.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how
|
CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://times.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -1355,12 +1355,12 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
taktik-web:
|
times-web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/taktik/apps/web/Dockerfile
|
dockerfile: apps/times/apps/web/Dockerfile
|
||||||
image: taktik-web:local
|
image: times-web:local
|
||||||
container_name: mana-app-taktik-web
|
container_name: mana-app-times-web
|
||||||
restart: always
|
restart: always
|
||||||
mem_limit: 128m
|
mem_limit: 128m
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
59
docker/matrix/element/config.json
Normal file
59
docker/matrix/element/config.json
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"default_server_config": {
|
||||||
|
"m.homeserver": {
|
||||||
|
"base_url": "https://matrix.mana.how",
|
||||||
|
"server_name": "mana.how"
|
||||||
|
},
|
||||||
|
"m.identity_server": {
|
||||||
|
"base_url": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"brand": "ManaCore Chat",
|
||||||
|
"integrations_ui_url": "",
|
||||||
|
"integrations_rest_url": "",
|
||||||
|
"integrations_widgets_urls": [],
|
||||||
|
"disable_guests": true,
|
||||||
|
"disable_3pid_login": true,
|
||||||
|
"default_country_code": "DE",
|
||||||
|
"show_labs_settings": false,
|
||||||
|
"features": {
|
||||||
|
"feature_video_rooms": true,
|
||||||
|
"feature_group_calls": true,
|
||||||
|
"feature_thread": true
|
||||||
|
},
|
||||||
|
"room_directory": {
|
||||||
|
"servers": ["mana.how"]
|
||||||
|
},
|
||||||
|
"setting_defaults": {
|
||||||
|
"breadcrumbs": true,
|
||||||
|
"custom_themes": [],
|
||||||
|
"UIFeature.e2eeDefault": false,
|
||||||
|
"FTUE.userOnboardingButton": false,
|
||||||
|
"analyticsOptIn": false,
|
||||||
|
"pseudonymousAnalyticsOptIn": false
|
||||||
|
},
|
||||||
|
"ui_features": {
|
||||||
|
"UIFeature.RoomEncryptionSettings": false
|
||||||
|
},
|
||||||
|
"force_verification": false,
|
||||||
|
"enable_presence_by_hs_url": {},
|
||||||
|
"default_theme": "dark",
|
||||||
|
"permalink_prefix": "https://element.mana.how",
|
||||||
|
"terms_and_conditions_links": [],
|
||||||
|
"sso_redirect_options": {
|
||||||
|
"immediate": false,
|
||||||
|
"on_welcome_page": true
|
||||||
|
},
|
||||||
|
"posthog": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"sentry": {
|
||||||
|
"disabled": true
|
||||||
|
},
|
||||||
|
"analytics_owner": "",
|
||||||
|
"privacy_policy_url": "",
|
||||||
|
"show_analytics_setting": false,
|
||||||
|
"bug_report_endpoint_url": "",
|
||||||
|
"help_url": "https://mana.how/help",
|
||||||
|
"help_encryption_url": "https://element.io/help#encryption"
|
||||||
|
}
|
||||||
433
docs/CLUSTER_HARDWARE_ANALYSE.md
Normal file
433
docs/CLUSTER_HARDWARE_ANALYSE.md
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
# Hardware-Analyse: 3-Node Home Server Cluster
|
||||||
|
|
||||||
|
**Stand: Maerz 2026** | Anforderung: K3s, YugabyteDB, ~60 Container (Go, Node/Bun, Python, SvelteKit)
|
||||||
|
|
||||||
|
## Referenz: Apple Mac Mini M4 (24 GB)
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | Apple M4, 10-Core (4P + 6E), ARM64 |
|
||||||
|
| RAM | 24 GB unified (nicht aufruestbar) |
|
||||||
|
| SSD | 256 GB / 512 GB NVMe (nicht aufruestbar) |
|
||||||
|
| Netzwerk | 1x Gigabit Ethernet (10 GbE als Option: +30 EUR Thunderbolt-Adapter) |
|
||||||
|
| Leistungsaufnahme | **3-4 W idle / 25-35 W Last** |
|
||||||
|
| Preis (DE) | **929 EUR** (Apple Store), ab ~870 EUR (Geizhals, 24 GB / 512 GB) |
|
||||||
|
| Linux | Asahi Linux (Fedora-basiert), experimentell. macOS + Colima/Docker Desktop funktioniert |
|
||||||
|
| K3s | Nativ nur via Asahi Linux oder VM. Praxis: Colima + k3s in Lima-VM |
|
||||||
|
|
||||||
|
### Staerken
|
||||||
|
- Unschlagbare Energieeffizienz (3-4 W idle)
|
||||||
|
- Hervorragende Single- und Multi-Core-Performance
|
||||||
|
- Kompaktestes Gehaeuse am Markt (12.7 x 12.7 cm)
|
||||||
|
- Leise (passiv bei niedrigem Last)
|
||||||
|
|
||||||
|
### Schwaechen
|
||||||
|
- **Kein natives Linux** (Asahi experimentell, kein offizieller Support)
|
||||||
|
- **RAM und SSD nicht aufruestbar** (bei Kauf festgelegt)
|
||||||
|
- K3s laeuft nicht nativ auf macOS -- Virtualisierung noetig
|
||||||
|
- ARM64: ~20% der Helm Charts liefern nur x86-Images
|
||||||
|
- Kein 10 GbE onboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ASUS NUC 14 Pro / Pro+
|
||||||
|
|
||||||
|
### ASUS NUC 14 Pro (Barebone)
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | Intel Core Ultra 5 125H / Ultra 7 155H / Ultra 9 185H (Meteor Lake) |
|
||||||
|
| Kerne | 14C/18T (Ultra 7) bzw. 16C/22T (Ultra 9) |
|
||||||
|
| RAM | Bis 96 GB DDR5-5600 SO-DIMM (2 Slots, aufruestbar) |
|
||||||
|
| SSD | 1x M.2 2280 PCIe 4.0, 1x M.2 2242 |
|
||||||
|
| Netzwerk | 1x 2.5 GbE Intel i226-V |
|
||||||
|
| Leistungsaufnahme | **8-10 W idle / 45-88 W Last** |
|
||||||
|
| Preis (DE) | Barebone ab **235 EUR** (Geizhals), Komplettsystem ab **879 EUR** |
|
||||||
|
| Linux | Hervorragend (Ubuntu, Fedora nativ) |
|
||||||
|
| Verfuegbarkeit | Sofort lieferbar |
|
||||||
|
|
||||||
|
### ASUS NUC 14 Pro+ (Komplettsystem)
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | Intel Core Ultra 7 155H / Ultra 9 185H |
|
||||||
|
| RAM | 16-32 GB DDR5 (aufruestbar bis 96 GB) |
|
||||||
|
| Preis (DE) | ab **879 EUR** (Komplettsystem mit 16 GB / 512 GB) |
|
||||||
|
| Besonderheit | Thunderbolt 4, WiFi 6E, vPro (manche Modelle) |
|
||||||
|
|
||||||
|
**Kalkulation fuer 32 GB Variante:**
|
||||||
|
- Barebone Ultra 7 155H: ~450 EUR
|
||||||
|
- 32 GB DDR5 SO-DIMM Kit: ~80 EUR
|
||||||
|
- 1 TB NVMe SSD: ~70 EUR
|
||||||
|
- **Gesamt: ~600 EUR pro Node**
|
||||||
|
|
||||||
|
### Bewertung vs. Mac Mini M4
|
||||||
|
|
||||||
|
| Kriterium | NUC 14 Pro | Mac Mini M4 |
|
||||||
|
|---|---|---|
|
||||||
|
| Preis (32 GB) | ~600 EUR | ~929 EUR |
|
||||||
|
| Idle-Verbrauch | 8-10 W | 3-4 W |
|
||||||
|
| Multi-Core | Vergleichbar (14C) | Leicht besser (effizienter) |
|
||||||
|
| RAM aufruestbar | Ja (bis 96 GB) | Nein |
|
||||||
|
| Linux nativ | Ja | Nein (Asahi experimentell) |
|
||||||
|
| 10 GbE | Nein (2.5 GbE) | Nein (1 GbE) |
|
||||||
|
|
||||||
|
**Fazit:** Bester Kompromiss aus Preis, Performance und Linux-Kompatibilitaet. Klare Empfehlung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. AMD Mini PCs
|
||||||
|
|
||||||
|
### Minisforum UM890 Pro
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | AMD Ryzen 9 8945HS (8C/16T, Zen 4, bis 5.2 GHz) |
|
||||||
|
| RAM | Bis 64 GB DDR5-5600 SO-DIMM (2 Slots, aufruestbar) |
|
||||||
|
| SSD | 2x M.2 2280 PCIe 4.0 |
|
||||||
|
| Netzwerk | 1x 2.5 GbE |
|
||||||
|
| Leistungsaufnahme | **7-10 W idle / 80-95 W Last** |
|
||||||
|
| Preis (DE) | ab **463 EUR** (Geizhals), mit 32 GB/1 TB ca. **550-600 EUR** |
|
||||||
|
| Linux | Hervorragend (voller AMD-Support) |
|
||||||
|
| Verfuegbarkeit | Teilweise ausverkauft, Nachlieferung Mai 2026 |
|
||||||
|
|
||||||
|
### Beelink SER8
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | AMD Ryzen 7 8845HS (8C/16T, Zen 4, bis 5.1 GHz) |
|
||||||
|
| RAM | 32 GB DDR5-5600 (aufruestbar) |
|
||||||
|
| SSD | 1 TB PCIe 4.0 |
|
||||||
|
| Netzwerk | 1x 2.5 GbE |
|
||||||
|
| Leistungsaufnahme | **8-10 W idle (Linux) / 85 W Last** |
|
||||||
|
| Preis (DE) | **399-450 EUR** (Amazon.de mit 32 GB/1 TB) |
|
||||||
|
| Linux | Hervorragend |
|
||||||
|
| Verfuegbarkeit | Sofort lieferbar |
|
||||||
|
|
||||||
|
### Beelink SER9 (Zen 5)
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | AMD Ryzen AI 9 HX 370 (12C/24T, Zen 5) |
|
||||||
|
| RAM | Bis 64 GB DDR5 |
|
||||||
|
| Netzwerk | 1x 2.5 GbE |
|
||||||
|
| Leistungsaufnahme | ~10 W idle / ~95 W Last |
|
||||||
|
| Preis (DE) | ab ca. **650-750 EUR** (mit 32 GB) |
|
||||||
|
|
||||||
|
### Geekom A8 Max
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | AMD Ryzen 9 8945HS (8C/16T) oder Ryzen 7 8745HS |
|
||||||
|
| RAM | Bis 64 GB DDR5 (aufruestbar) |
|
||||||
|
| SSD | Bis 2 TB |
|
||||||
|
| Netzwerk | **2x 2.5 GbE** (Dual-LAN!) |
|
||||||
|
| Leistungsaufnahme | ~8-10 W idle / ~85 W Last |
|
||||||
|
| Preis (DE) | ab **720 EUR** (mit Code), regulaer **849 EUR** |
|
||||||
|
| Besonderheit | USB4, Dual 2.5 GbE fuer LAG/Failover |
|
||||||
|
|
||||||
|
### Minisforum MS-A2 (Homelab-Spezialist)
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | AMD Ryzen 9 9955HX (16C/32T, Zen 5) |
|
||||||
|
| RAM | Bis 96 GB DDR5 (2 Slots) |
|
||||||
|
| SSD | 3x M.2 (2280 + 22110 + U.2) |
|
||||||
|
| Netzwerk | **2x 10 GbE SFP+** + 2x 2.5 GbE |
|
||||||
|
| PCIe | 1x PCIe x16 Slot |
|
||||||
|
| Leistungsaufnahme | **25-30 W idle / 120+ W Last** |
|
||||||
|
| Preis (DE) | Barebone ab **839 EUR**, Komplett ab **975 EUR** |
|
||||||
|
| Linux | Hervorragend |
|
||||||
|
| Verfuegbarkeit | Teilweise ausverkauft |
|
||||||
|
|
||||||
|
**Fazit AMD:** Der **Beelink SER8** bietet das beste Preis-Leistungs-Verhaeltnis. Die **MS-A2** ist das Schweizer Taschenmesser fuer Homelabs mit 10 GbE, aber teuer und stromhungrig im Idle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ARM-Alternativen
|
||||||
|
|
||||||
|
### Raspberry Pi 5 (8 GB)
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | Broadcom BCM2712, 4x Cortex-A76 @ 2.4 GHz |
|
||||||
|
| RAM | 8 GB LPDDR4X (nicht aufruestbar) |
|
||||||
|
| SSD | Via M.2 HAT (NVMe) oder USB 3.0 SSD |
|
||||||
|
| Netzwerk | 1x Gigabit Ethernet |
|
||||||
|
| Leistungsaufnahme | **3-4 W idle / 10-12 W Last** |
|
||||||
|
| Preis (DE) | ca. **95 EUR** (Board + Gehaeuse + Kuehler + Netzteil) |
|
||||||
|
| Linux | Hervorragend (Raspberry Pi OS, Ubuntu) |
|
||||||
|
|
||||||
|
**Realistisch fuer diesen Workload? NEIN.**
|
||||||
|
|
||||||
|
- 8 GB RAM reicht nicht fuer YugabyteDB + K3s + Container
|
||||||
|
- Nur 4 Kerne -- ~60 Container werden sehr eng
|
||||||
|
- ~20% der Container-Images sind nur x86
|
||||||
|
- SD-Karten-IO ist ungeeignet (NVMe-HAT hilft, aber PCIe 2.0 x1)
|
||||||
|
- Kein ECC, instabil unter Dauerlast
|
||||||
|
- Muesste 5-6 Nodes statt 3 nutzen = mehr Komplexitaet
|
||||||
|
|
||||||
|
**Einsatz:** Gut als dedizierter K3s-Agent fuer leichte Workloads, nicht als Hauptknoten.
|
||||||
|
|
||||||
|
### Orange Pi 5 Plus / Rock 5B
|
||||||
|
|
||||||
|
| Eigenschaft | Orange Pi 5 Plus | Rock 5B |
|
||||||
|
|---|---|---|
|
||||||
|
| CPU | RK3588 8C (4x A76 + 4x A55) | RK3588 8C |
|
||||||
|
| RAM | 16 GB / 32 GB | 16 GB |
|
||||||
|
| Netzwerk | 2x 2.5 GbE | 1x 2.5 GbE |
|
||||||
|
| Preis | ~170 EUR (32 GB) | ~130 EUR (16 GB) |
|
||||||
|
| Leistungsaufnahme | 5-8 W idle | 5-8 W idle |
|
||||||
|
|
||||||
|
**Bewertung:** Mehr RAM als Pi 5, aber gleiche Grundprobleme: ARM-Image-Kompatibilitaet, begrenzte CPU-Leistung, kein ECC. Fuer 60 Container nicht empfohlen.
|
||||||
|
|
||||||
|
### Ampere Altra
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | Ampere Altra (bis 128 ARM-Kerne) |
|
||||||
|
| RAM | Server-DDR5 ECC, bis 512 GB |
|
||||||
|
| Preis | ab **3.000+ EUR** fuer komplettes System |
|
||||||
|
|
||||||
|
**Bewertung:** Overkill und weit ueber Budget. Interessant fuer Datacenter, nicht fuer Home-Cluster.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Refurbished Enterprise Micro Server
|
||||||
|
|
||||||
|
### Lenovo ThinkCentre M75q Gen 5 Tiny
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | AMD Ryzen 7 PRO 8700GE (8C/16T, 35W TDP) |
|
||||||
|
| RAM | Bis 64 GB DDR5 (2 Slots) |
|
||||||
|
| SSD | 1x M.2 2280 |
|
||||||
|
| Netzwerk | 1x 1 GbE (kein 2.5 GbE!) |
|
||||||
|
| Leistungsaufnahme | ~8-12 W idle / 35-50 W Last |
|
||||||
|
| Preis (DE, neu) | ab ca. **600-800 EUR** |
|
||||||
|
| Preis (refurbished) | ab ca. **350-500 EUR** |
|
||||||
|
| Linux | Hervorragend (Business-Hardware, gut getestet) |
|
||||||
|
|
||||||
|
### HP Elite Mini 800 G9
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | Intel Core i5-12500T / i7-12700T (12./13. Gen) |
|
||||||
|
| RAM | Bis 64 GB DDR5 |
|
||||||
|
| SSD | 1x M.2 NVMe |
|
||||||
|
| Netzwerk | 1x 1 GbE |
|
||||||
|
| Leistungsaufnahme | ~10-15 W idle / 35-65 W Last |
|
||||||
|
| Preis (DE, refurbished) | ab ca. **300-450 EUR** (32 GB, refurbed.de) |
|
||||||
|
| Linux | Hervorragend |
|
||||||
|
|
||||||
|
### Dell OptiPlex 7010/7020 Micro
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | Intel Core i5-13500T / i7-13700T |
|
||||||
|
| RAM | Bis 64 GB DDR5 (ab Gen 13) |
|
||||||
|
| Netzwerk | 1x 1 GbE |
|
||||||
|
| Preis (DE, refurbished) | ab ca. **280-400 EUR** |
|
||||||
|
| Linux | Hervorragend |
|
||||||
|
|
||||||
|
**Fazit Refurbished:** Extrem guenstig, aber nur 1 GbE Netzwerk und aeltere CPUs. Gut fuer Budget-Cluster, weniger fuer Zukunftssicherheit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ultra-Low-Power Mini PCs
|
||||||
|
|
||||||
|
### Intel N100 Mini PCs (z.B. Beelink S12 Pro, CWWK)
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | Intel N100 (4C/4T, Alder Lake-N, bis 3.4 GHz) |
|
||||||
|
| RAM | 8-16 GB DDR5 (meistens geloetet, nicht aufruestbar) |
|
||||||
|
| SSD | 1x M.2 2280 |
|
||||||
|
| Netzwerk | 1x 2.5 GbE (CWWK: bis 6x 2.5 GbE) |
|
||||||
|
| Leistungsaufnahme | **6-8 W idle / 25 W Last** |
|
||||||
|
| Preis (DE) | ab **130-200 EUR** |
|
||||||
|
| Linux | Hervorragend |
|
||||||
|
|
||||||
|
### Intel N305 Mini PCs (z.B. CWWK F7)
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|---|---|
|
||||||
|
| CPU | Intel i3-N305 (8C/8T, Alder Lake-N, bis 3.8 GHz) |
|
||||||
|
| RAM | Bis 32 GB DDR5 |
|
||||||
|
| SSD | 1-2x M.2 NVMe |
|
||||||
|
| Netzwerk | Bis 6x 2.5 GbE (CWWK/Topton Modelle) |
|
||||||
|
| Leistungsaufnahme | **10-14 W idle / 34-50 W Last** |
|
||||||
|
| Preis (DE) | ab **200-350 EUR** (mit 32 GB/1 TB) |
|
||||||
|
| Linux | Hervorragend |
|
||||||
|
| Besonderheit | Fanless-Modelle verfuegbar (24/7 Betrieb, lautlos) |
|
||||||
|
|
||||||
|
**Bewertung N100:** Nur 4 Kerne und max 16 GB RAM -- fuer YugabyteDB + 20 Container pro Node zu knapp.
|
||||||
|
|
||||||
|
**Bewertung N305:** 8 Kerne und 32 GB moeglich -- minimal ausreichend. Fanless und extrem stromsparend. Aber Single-Thread-Leistung weit unter Ryzen/Apple Silicon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gesamtvergleich
|
||||||
|
|
||||||
|
### Performance und Effizienz
|
||||||
|
|
||||||
|
| System | Kerne | Idle (W) | Last (W) | Multi-Core (rel.) | Perf/Watt |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **Mac Mini M4** | 10 (4P+6E) | 3-4 | 25-35 | 100% (Referenz) | Exzellent |
|
||||||
|
| ASUS NUC 14 Pro (Ultra 7) | 14 (6P+8E) | 8-10 | 45-88 | ~90-100% | Gut |
|
||||||
|
| Beelink SER8 (8845HS) | 8 (8P) | 8-10 | 85 | ~85% | Gut |
|
||||||
|
| Minisforum UM890 Pro | 8 (8P) | 7-10 | 80-95 | ~90% | Gut |
|
||||||
|
| Minisforum MS-A2 (9955HX) | 16 (16P) | 25-30 | 120+ | ~170% | Maessig |
|
||||||
|
| Lenovo M75q (8700GE) | 8 (8P) | 8-12 | 35-50 | ~75% | Gut |
|
||||||
|
| Intel N305 (CWWK) | 8 (8E) | 10-14 | 34-50 | ~35% | Gut (fuer idle) |
|
||||||
|
| Raspberry Pi 5 | 4 | 3-4 | 10-12 | ~15% | Gut (fuer idle) |
|
||||||
|
|
||||||
|
### Stromkosten ueber 3 Jahre (0,35 EUR/kWh, 3 Nodes)
|
||||||
|
|
||||||
|
| System | Idle (W, 3 Nodes) | Jahreskosten (idle) | 3-Jahres-Strom |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Mac Mini M4** | 9-12 W | 28-37 EUR | **84-110 EUR** |
|
||||||
|
| ASUS NUC 14 Pro | 24-30 W | 74-92 EUR | **221-276 EUR** |
|
||||||
|
| Beelink SER8 | 24-30 W | 74-92 EUR | **221-276 EUR** |
|
||||||
|
| Minisforum MS-A2 | 75-90 W | 230-276 EUR | **690-828 EUR** |
|
||||||
|
| Intel N305 | 30-42 W | 92-129 EUR | **276-387 EUR** |
|
||||||
|
| Raspberry Pi 5 | 9-12 W | 28-37 EUR | **84-110 EUR** |
|
||||||
|
|
||||||
|
*Annahme: Server laeuft 24/7, Grossteil der Zeit im Idle/Low-Load.*
|
||||||
|
|
||||||
|
### Total Cost of Ownership (3 Jahre: Hardware + Strom)
|
||||||
|
|
||||||
|
| System | Hardware (3x) | Strom (3 J.) | **TCO Gesamt** |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Mac Mini M4 (24 GB)** | 2.787 EUR | ~100 EUR | **~2.887 EUR** |
|
||||||
|
| ASUS NUC 14 Pro (32 GB) | 1.800 EUR | ~250 EUR | **~2.050 EUR** |
|
||||||
|
| Beelink SER8 (32 GB) | 1.200-1.350 EUR | ~250 EUR | **~1.500 EUR** |
|
||||||
|
| Minisforum UM890 Pro (32 GB) | 1.650 EUR | ~250 EUR | **~1.900 EUR** |
|
||||||
|
| Minisforum MS-A2 (32 GB) | 2.925 EUR | ~760 EUR | **~3.685 EUR** |
|
||||||
|
| Lenovo M75q refurb (32 GB) | 1.050-1.500 EUR | ~250 EUR | **~1.500 EUR** |
|
||||||
|
| Intel N305 CWWK (32 GB) | 750-1.050 EUR | ~330 EUR | **~1.200 EUR** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ARM vs. x86 Ueberlegungen fuer Docker/K8s
|
||||||
|
|
||||||
|
| Aspekt | ARM64 (Mac Mini, Pi) | x86_64 (Intel, AMD) |
|
||||||
|
|---|---|---|
|
||||||
|
| Container-Image-Verfuegbarkeit | ~80% (multi-arch nimmt zu) | 99%+ |
|
||||||
|
| Offizielle K3s-Unterstuetzung | Ja (ARM64 Builds) | Ja (primaer) |
|
||||||
|
| YugabyteDB | Offiziell nur x86! ARM experimentell | Voll unterstuetzt |
|
||||||
|
| Go-Services | Kein Problem (Cross-Compile) | Kein Problem |
|
||||||
|
| Node.js/Bun | Kein Problem | Kein Problem |
|
||||||
|
| Python | Kein Problem (manche C-Extensions langsamer) | Kein Problem |
|
||||||
|
| Helm Charts (Community) | ~80% kompatibel | ~99% kompatibel |
|
||||||
|
|
||||||
|
**Kritisch: YugabyteDB hat keinen offiziellen ARM64-Support.** Das allein disqualifiziert reine ARM-Setups fuer das geplante Setup, es sei denn man baut selbst oder nutzt einen Fork.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfehlungen
|
||||||
|
|
||||||
|
### Empfehlung 1: Beelink SER8 (Bestes Preis-Leistungs-Verhaeltnis)
|
||||||
|
|
||||||
|
**3x Beelink SER8 (32 GB DDR5 / 1 TB SSD)**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Preis pro Node | ~400-450 EUR |
|
||||||
|
| Gesamtpreis (3 Nodes) | ~1.200-1.350 EUR |
|
||||||
|
| TCO (3 Jahre) | ~1.500 EUR |
|
||||||
|
| Idle-Verbrauch (3 Nodes) | ~24-30 W |
|
||||||
|
|
||||||
|
- Sofort auf Amazon.de verfuegbar
|
||||||
|
- Linux-Support perfekt
|
||||||
|
- YugabyteDB laeuft nativ
|
||||||
|
- 8C/16T pro Node = 24C/48T gesamt -- mehr als genug fuer 60 Container
|
||||||
|
- 2.5 GbE Netzwerk
|
||||||
|
- Aufruestbar auf 64 GB RAM pro Node bei Bedarf
|
||||||
|
- **Spart ~1.400 EUR gegenueber 3x Mac Mini M4**
|
||||||
|
|
||||||
|
### Empfehlung 2: ASUS NUC 14 Pro (Bester Kompromiss)
|
||||||
|
|
||||||
|
**3x ASUS NUC 14 Pro Barebone (Ultra 7 155H) + 32 GB + 1 TB**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Preis pro Node | ~600 EUR |
|
||||||
|
| Gesamtpreis (3 Nodes) | ~1.800 EUR |
|
||||||
|
| TCO (3 Jahre) | ~2.050 EUR |
|
||||||
|
|
||||||
|
- Bewaeehrte NUC-Qualitaet (Intel/ASUS)
|
||||||
|
- 14 Kerne pro Node = 42 Kerne gesamt
|
||||||
|
- Aufruestbar bis 96 GB RAM
|
||||||
|
- Thunderbolt 4 fuer 10 GbE Adapter
|
||||||
|
- Business-Hardware mit laengerer Verfuegbarkeit von Ersatzteilen
|
||||||
|
|
||||||
|
### Empfehlung 3: Minisforum MS-A2 (Maximum Homelab)
|
||||||
|
|
||||||
|
**Nur wenn 10 GbE zwingend noetig.**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Preis pro Node | ~975 EUR |
|
||||||
|
| Gesamtpreis (3 Nodes) | ~2.925 EUR |
|
||||||
|
| TCO (3 Jahre) | ~3.685 EUR |
|
||||||
|
|
||||||
|
- 16 Kerne + 10 GbE SFP+ onboard
|
||||||
|
- PCIe x16 Slot fuer GPU/NIC-Erweiterung
|
||||||
|
- Aber: Hoher Idle-Verbrauch (25-30 W), teuer, oft ausverkauft
|
||||||
|
|
||||||
|
### Empfehlung 4: Hybrid (Budget + Zukunftssicherheit)
|
||||||
|
|
||||||
|
**2x Beelink SER8 + 1x ASUS NUC 14 Pro (als Control Plane)**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Gesamtpreis | ~1.500 EUR |
|
||||||
|
| TCO (3 Jahre) | ~1.750 EUR |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht empfohlen
|
||||||
|
|
||||||
|
| Option | Grund |
|
||||||
|
|---|---|
|
||||||
|
| **Mac Mini M4** | Kein natives Linux, YugabyteDB nicht ARM-offiziell, RAM nicht aufruestbar, teuer |
|
||||||
|
| **Raspberry Pi 5** | Zu wenig RAM (8 GB), zu wenig CPU, ARM-Image-Probleme |
|
||||||
|
| **Intel N100** | Nur 4 Kerne, max 16 GB RAM -- zu schwach |
|
||||||
|
| **Ampere Altra** | Weit ueber Budget |
|
||||||
|
| **Minisforum MS-A2** | Nur wenn 10 GbE Pflicht (sonst zu teuer und zu stromhungrig) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fazit
|
||||||
|
|
||||||
|
Fuer den geplanten Cluster mit K3s, YugabyteDB und ~60 Containern ist der **Beelink SER8** die wirtschaftlich beste Wahl: 3 Nodes fuer ~1.300 EUR, perfekter Linux-Support, ausreichend Leistung, und moderate Stromkosten. Die ~150 EUR mehr Stromkosten gegenueber Mac Minis werden durch ~1.400 EUR Hardware-Ersparnis mehr als kompensiert.
|
||||||
|
|
||||||
|
Falls die Anforderungen wachsen (mehr RAM, 10 GbE), ist ein spaeterer Umstieg auf ASUS NUC 14 Pro oder Minisforum MS-A2 Nodes moeglich -- K3s macht das Hinzufuegen und Entfernen von Nodes trivial.
|
||||||
|
|
||||||
|
**Wichtigster Grund gegen den Mac Mini M4:** YugabyteDB hat keinen offiziellen ARM64-Support. Das ist fuer eine produktive Datenbank ein No-Go.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
- [Mac Mini M4 Effizienz - Jeff Geerling](https://www.jeffgeerling.com/blog/2024/m4-mac-minis-efficiency-incredible/)
|
||||||
|
- [Mac Mini M4 Review - NotebookCheck](https://www.notebookcheck.net/Apple-Mac-Mini-M4-review-Smaller-faster-and-louder.918832.0.html)
|
||||||
|
- [Mac Mini M4 Review - ServeTheHome](https://www.servethehome.com/the-apple-mac-mini-m4-sets-the-mini-computer-standard/3/)
|
||||||
|
- [ASUS NUC 14 Pro Review - ServeTheHome](https://www.servethehome.com/asus-nuc-14-pro-review-intel-core/4/)
|
||||||
|
- [ASUS NUC 14 Pro Geizhals](https://geizhals.de/asus-nuc-14-pro-kit-barebone-v158051.html)
|
||||||
|
- [Minisforum UM890 Pro Review - NotebookCheck](https://www.notebookcheck.net/Minisforum-EliteMini-UM890-Pro-review-A-powerful-mini-PC-with-AMD-Ryzen-9-and-whisper-quiet-cooling.982755.0.html)
|
||||||
|
- [Minisforum UM890 Pro Review - ServeTheHome](https://www.servethehome.com/minisforum-um890-pro-review-re-architected-amd-ryzen-8945hs-mini-pc/4/)
|
||||||
|
- [Minisforum MS-A2 Review - ServeTheHome](https://www.servethehome.com/minisforum-ms-a2-review-an-almost-perfect-amd-ryzen-intel-10gbe-homelab-system/4/)
|
||||||
|
- [Minisforum MS-A2 Geizhals](https://geizhals.de/minisforum-ms-a2-a3504299.html)
|
||||||
|
- [Beelink SER8 Review - ServeTheHome](https://www.servethehome.com/beelink-ser8-review-amd-ryzen-7-8845hs-powered-mini-pc/3/)
|
||||||
|
- [Beelink SER8 Review - Hardwareluxx](https://www.hardwareluxx.de/index.php/artikel/hardware/komplettsysteme/63918-mit-ryzen-7-8845hs-und-im-kompakten-geh%C3%A4use-beelink-ser8-im-test.html)
|
||||||
|
- [Beelink SER7 Amazon.de](https://www.amazon.de/Beelink-Ryzen-Threads-PCIe4-0-Display/dp/B0CH7VJZ94)
|
||||||
|
- [Geekom A8 Max - NotebookCheck](https://www.notebookcheck.net/Best-value-with-AMD-Ryzen-7-8845HS-The-Geekom-A8-Max-mini-PC-review.1005224.0.html)
|
||||||
|
- [Geekom DE Shop](https://www.geekom.de/geekom-a8-mini-pc/)
|
||||||
|
- [HP Elite Mini 800 G9 - refurbed.de](https://www.refurbed.de/p/hp-elite-mini-800-g9/)
|
||||||
|
- [Lenovo M75q Gen 2 Geizhals](https://geizhals.de/lenovo-thinkcentre-m75q-gen-2-v46509.html)
|
||||||
|
- [CWWK N305 Benchmarks](https://rovingclimber.com/2025/01/05/cwwk-i3-n305-benchmarks-power-consumption/)
|
||||||
|
- [N100 vs Pi - Jeff Geerling](https://www.jeffgeerling.com/blog/2025/intel-n100-better-value-raspberry-pi/)
|
||||||
|
- [N100 vs N305 Home Server](https://www.lowerhomeserver.vip/blog/hardware/intel-n100-vs-n305-home-server-2026)
|
||||||
|
- [K3s auf Raspberry Pi 5](https://www.picocluster.com/blogs/picocluster-software-engineering/installing-k3s-on-the-raspberry-pi-5-a-step-by-step-guide)
|
||||||
84
docs/FIX_COLIMA_MOUNTS.md
Normal file
84
docs/FIX_COLIMA_MOUNTS.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Fix: Colima VirtioFS Mounts nach Stromausfall
|
||||||
|
|
||||||
|
Nach dem `colima delete` + recreate fehlt der Home-Directory Mount.
|
||||||
|
Dadurch werden alle Bind-Mounts zu Projekt-Dateien als leere Verzeichnisse gemountet
|
||||||
|
und Container wie Synapse, SearXNG, Alertmanager, VictoriaMetrics, Loki crashen.
|
||||||
|
|
||||||
|
## Schritte (direkt am Mac Mini ausfuehren)
|
||||||
|
|
||||||
|
### 1. Mac Mini neu starten (Power-Button)
|
||||||
|
|
||||||
|
SSH funktioniert nicht mehr — der Server muss physisch neugestartet werden.
|
||||||
|
|
||||||
|
### 2. Terminal oeffnen und Colima stoppen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PATH=/opt/homebrew/bin:$PATH colima stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Home-Directory Mount in Colima Config einfuegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i '' '/^mounts:/a\
|
||||||
|
- location: /Users/mana\
|
||||||
|
writable: true
|
||||||
|
' ~/.colima/default/colima.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verifizieren dass beide Mounts drin sind
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -A8 'mounts:' ~/.colima/default/colima.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartete Ausgabe:
|
||||||
|
|
||||||
|
```
|
||||||
|
mounts:
|
||||||
|
- location: /Users/mana
|
||||||
|
writable: true
|
||||||
|
- location: /Volumes/ManaData
|
||||||
|
writable: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Colima starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PATH=/opt/homebrew/bin:$PATH colima start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Testen ob File-Mounts funktionieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v ~/projects/manacore-monorepo/docker/alertmanager/alertmanager.yml:/test.yml:ro alpine head -3 /test.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Sollte YAML-Inhalt zeigen, NICHT "Is a directory".
|
||||||
|
|
||||||
|
### 7. Alle Container starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/projects/manacore-monorepo
|
||||||
|
docker compose -f docker-compose.macmini.yml up -d --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Pruefen ob die vorher crashenden Container laufen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E 'synapse|searxng|alertmanager|vmalert|victoria|loki'
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle sollten "Up" und "healthy" zeigen.
|
||||||
|
|
||||||
|
### 9. Memory Baseline messen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/mac-mini/memory-baseline.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
|
||||||
|
`colima delete` hat die VM komplett geloescht. Beim Neuerstellen mit
|
||||||
|
`colima start --mount /Volumes/ManaData:w` wurde nur das externe SSD
|
||||||
|
gemountet, nicht das Home-Directory `/Users/mana`. Ohne diesen Mount
|
||||||
|
sieht VirtioFS alle Host-Dateien als leere Verzeichnisse.
|
||||||
257
docs/MANA_BOX_HARDWARE.md
Normal file
257
docs/MANA_BOX_HARDWARE.md
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
# Mana Box — On-Premise Appliance Hardware-Planung
|
||||||
|
|
||||||
|
**Stand:** 2026-03-30
|
||||||
|
**Status:** Recherche / Evaluierung
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
Jeder Kunde (KMU, Verein, Schule, Agentur etc.) kauft eine **Mana Box** — einen kleinen, vorkonfigurierten Server mit dem gesamten Mana-Stack vorinstalliert. Alle Daten liegen on-premise beim Kunden. Optional kann der Kunde auch Websites, Intranets o.a. darauf hosten.
|
||||||
|
|
||||||
|
### Warum On-Premise?
|
||||||
|
|
||||||
|
- **Datensouveranitat**: Kundendaten verlassen nie das Gebaude
|
||||||
|
- **DSGVO-Compliance**: Keine Cloud-Abhaengigkeiten, keine Auftragsverarbeitung noetig
|
||||||
|
- **Unabhaengigkeit**: Funktioniert auch ohne Internet (Offline-First-Architektur)
|
||||||
|
- **Planbare Kosten**: Einmaliger Hardwarekauf + optionales Support-Abo
|
||||||
|
- **B2B-Differenzierung**: Kein Vendor-Lock-in, Kunde besitzt die Infrastruktur
|
||||||
|
|
||||||
|
## Architektur: Single-Node Appliance
|
||||||
|
|
||||||
|
Da jede Mana Box ein einzelner Server ist (kein Cluster), vereinfacht sich der Stack erheblich:
|
||||||
|
|
||||||
|
| Komponente | Cluster-Ansatz (verworfen) | Mana Box (Single Node) |
|
||||||
|
|---|---|---|
|
||||||
|
| Orchestrierung | K3s (Kubernetes) | **Docker Compose** |
|
||||||
|
| Datenbank | YugabyteDB (verteilt) | **PostgreSQL** (lokal) |
|
||||||
|
| Netzwerk | 10 GbE + Headscale Mesh | **1 GbE LAN** |
|
||||||
|
| High Availability | 3-Node Failover | **Backup-Strategie** |
|
||||||
|
| Management | kubectl | **Portainer oder CLI** |
|
||||||
|
|
||||||
|
### Stack pro Mana Box
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Mana Box │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Docker Compose │
|
||||||
|
│ ├── mana-auth (Hono/Bun) │
|
||||||
|
│ ├── mana-credits (Hono/Bun) │
|
||||||
|
│ ├── mana-user (Hono/Bun) │
|
||||||
|
│ ├── mana-sync (Go) │
|
||||||
|
│ ├── mana-search (Go) │
|
||||||
|
│ ├── mana-notify (Go) │
|
||||||
|
│ ├── mana-api-gateway (Go) │
|
||||||
|
│ ├── mana-media (Hono/Bun) │
|
||||||
|
│ ├── App-Backends (NestJS / Hono) │
|
||||||
|
│ ├── App-Webs (SvelteKit, static) │
|
||||||
|
│ ├── PostgreSQL │
|
||||||
|
│ ├── Redis │
|
||||||
|
│ ├── MinIO │
|
||||||
|
│ ├── Traefik (Reverse Proxy) │
|
||||||
|
│ └── Tailscale (Remote-Management) │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Debian/Ubuntu Minimal + unattended-upgrades│
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hardware-Anforderungen
|
||||||
|
|
||||||
|
### Prioritaeten (anders als bei eigenem Cluster)
|
||||||
|
|
||||||
|
| Anforderung | Prioritaet | Begruendung |
|
||||||
|
|---|---|---|
|
||||||
|
| **Stueckpreis** | Kritisch | Bestimmt Verkaufspreis und Marge |
|
||||||
|
| **Zuverlaessigkeit** | Kritisch | Kein IT-Personal beim Kunden |
|
||||||
|
| **Lautstaerke** | Sehr hoch | Steht im Buero, nicht im Rack |
|
||||||
|
| **Stromverbrauch** | Hoch | Kunde zahlt Strom, 24/7 Betrieb |
|
||||||
|
| **Linux nativ** | Pflicht | Docker Compose direkt auf Host |
|
||||||
|
| **Kompakte Groesse** | Mittel | Schreibtisch oder Serverschrank |
|
||||||
|
| **RAM aufruetbar** | Niedrig | Wird vor Versand konfiguriert |
|
||||||
|
|
||||||
|
### RAM-Sizing nach Kundengroesse
|
||||||
|
|
||||||
|
| Tier | User | Typische Apps | RAM-Bedarf |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **S** (Klein) | 5-15 | Auth + 3-4 Apps + DB + Redis | 6-8 GB |
|
||||||
|
| **M** (Mittel) | 15-30 | Auth + 8-10 Apps + DB + Redis + MinIO | 10-14 GB |
|
||||||
|
| **L** (Gross) | 30-50+ | Alle Apps + alle Services + MinIO | 16-20 GB |
|
||||||
|
|
||||||
|
**Sweet Spot: 16 GB RAM** deckt die grosse Mehrheit der KMU-Kunden ab.
|
||||||
|
32 GB nur fuer Power-User oder lokale LLM-Inferenz.
|
||||||
|
|
||||||
|
## Hardware-Kandidaten
|
||||||
|
|
||||||
|
### Vergleichstabelle
|
||||||
|
|
||||||
|
| Geraet | CPU | Cores | RAM | Preis (EK) | Luefter | Idle | Linux | Eignung |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| **Intel N100 Mini PC** (diverse) | Intel N100 | 4C/4T | 16 GB (aufr.) | 120-180 EUR | Luefterlos | ~6W | Nativ | Budget, nur 4 Cores |
|
||||||
|
| **Intel N305 Mini PC** (z.B. Beelink EQ14) | Intel N305 | 8C/8T | 16 GB (aufr.) | 200-280 EUR | Luefterlos | ~8W | Nativ | **Bester Kandidat fuer Box S** |
|
||||||
|
| **Beelink SER8** | Ryzen 7 8845HS | 8C/16T | 32 GB DDR5 (aufr.) | ~680 EUR | Aktiv (leise) | ~8W | Nativ | Overpowered fuer die meisten |
|
||||||
|
| **Beelink SER9 Pro (H255)** | Ryzen 7 H255 | 8C/16T | 32 GB LPDDR5X (fix) | ab 779 EUR | Aktiv (leise) | ~8W | Nativ | Gut, aber RAM verloetet |
|
||||||
|
| **Beelink SER9 Pro (HX 370)** | Ryzen AI 9 HX 370 | 12C/24T | 32 GB LPDDR5X (fix) | ab 1.029 EUR | Aktiv (leise) | ~10W | Nativ | Overkill, RAM verloetet |
|
||||||
|
| **Minisforum UM790 Pro** | Ryzen 9 7940HS | 8C/16T | bis 64 GB (aufr.) | ab 399 EUR | Aktiv (leise) | ~8W | Nativ | Gutes Preis/Leistung |
|
||||||
|
| **Minisforum UM890 Pro** | Ryzen 9 8945HS | 8C/16T | bis 64 GB (aufr.) | ab 463 EUR | Aktiv (leise) | ~8W | Nativ | Homelab-Favorit |
|
||||||
|
| **GEEKOM A8** | Ryzen 7 8745HS | 8C/16T | 16 GB DDR5 (aufr.) | ab 659 EUR | Aktiv (leise) | ~8W | Nativ | Dual 2.5 GbE, solide |
|
||||||
|
| **Lenovo M75q Gen 2** (refurb) | Ryzen 5 PRO 5650GE | 6C/12T | bis 64 GB (aufr.) | 150-250 EUR | Aktiv (leise) | ~10W | Nativ | **Guenstigster Kandidat** |
|
||||||
|
| **Lenovo M75q Gen 5** (neu) | Ryzen 7 PRO 8700GE | 8C/16T | bis 64 GB DDR5 (aufr.) | 500-600 EUR | Aktiv (leise) | ~10W | Nativ | Premium, Enterprise-Qualitaet |
|
||||||
|
| **ASUS NUC 14 Pro** (Barebone) | Core Ultra 7 155H | 14C/18T | bis 96 GB (aufr.) | ab 547 EUR | Aktiv (leise) | ~10W | Nativ | Viele Cores, flexibel |
|
||||||
|
| **HP Elite Mini 800 G9** (refurb) | Core i7-12700 | 12C/20T | 32 GB (aufr.) | ~350-500 EUR | Aktiv (leise) | ~12W | Nativ | Enterprise-refurb |
|
||||||
|
| **Raspberry Pi 5** | Cortex-A76 | 4C/4T | 8 GB (fix) | ~100 EUR | Passiv moegl. | ~3W | Nativ | Zu wenig RAM, ARM-Limits |
|
||||||
|
|
||||||
|
### Detailbewertung der Top-Kandidaten
|
||||||
|
|
||||||
|
#### Intel N305 Mini PCs (z.B. Beelink EQ14) — Box S
|
||||||
|
|
||||||
|
- **Preis:** 200-280 EUR
|
||||||
|
- **Vorteile:** Luefterlos, 8W idle, 8 Cores, extrem zuverlaessig, guenstig
|
||||||
|
- **Nachteile:** Nur E-Cores (kein Performance-Core), max ~16 GB typisch
|
||||||
|
- **Ideal fuer:** Kleine Kunden (5-15 User), Basic-Setup
|
||||||
|
- **Luefterlos = keine beweglichen Teile = hoechste Zuverlaessigkeit**
|
||||||
|
|
||||||
|
#### Minisforum UM890 Pro — Box M
|
||||||
|
|
||||||
|
- **Preis:** ab 463 EUR (Barebone), ~600-700 EUR konfiguriert
|
||||||
|
- **Vorteile:** Ryzen 9, aufruetbar bis 64 GB, sehr leise, bewahrt im Homelab
|
||||||
|
- **Nachteile:** Aktiver Luefter (wenn auch leise)
|
||||||
|
- **Ideal fuer:** Mittelgrosse Kunden (15-50 User)
|
||||||
|
|
||||||
|
#### Beelink SER8 — Box L
|
||||||
|
|
||||||
|
- **Preis:** ~680 EUR mit 32 GB/1 TB
|
||||||
|
- **Vorteile:** Ryzen 7 8845HS, DDR5 SO-DIMM aufruetbar bis 256 GB, 2.5 GbE
|
||||||
|
- **Nachteile:** Auslaufmodell (Nachfolger SER9 hat verloeteten RAM)
|
||||||
|
- **Ideal fuer:** Grosse Kunden, LLM-Workloads, Website-Hosting
|
||||||
|
- **Achtung:** Verfuegbarkeit pruefen — wird vom SER9 abgeloest
|
||||||
|
|
||||||
|
#### Lenovo M75q Gen 2 (refurbished) — Budget-Option
|
||||||
|
|
||||||
|
- **Preis:** 150-250 EUR (refurbished mit 12-24 Monate Garantie)
|
||||||
|
- **Vorteile:** Enterprise-Qualitaet, extrem guenstig, aufruetbar bis 64 GB
|
||||||
|
- **Nachteile:** Aeltere CPU (Ryzen 5 5650GE), DDR4, keine USB4
|
||||||
|
- **Bezugsquellen DE:** it-goods.de, notebookswieneu.de, thinkstore24.de
|
||||||
|
- **Ideal fuer:** Budget-bewusste Kunden, Prototyp-Phase
|
||||||
|
|
||||||
|
## Produkt-Tiers
|
||||||
|
|
||||||
|
### Mana Box S — 599-799 EUR (VK)
|
||||||
|
|
||||||
|
| Komponente | Spezifikation |
|
||||||
|
|---|---|
|
||||||
|
| Hardware | Intel N305 Mini PC, luefterlos |
|
||||||
|
| RAM | 16 GB DDR5 |
|
||||||
|
| Speicher | 512 GB NVMe SSD |
|
||||||
|
| Netzwerk | 1 GbE |
|
||||||
|
| Strom | ~8W idle |
|
||||||
|
| EK Hardware | ~250-300 EUR |
|
||||||
|
| Zielgruppe | 5-15 User |
|
||||||
|
| Apps | Auth + 3-5 Kern-Apps |
|
||||||
|
|
||||||
|
### Mana Box M — 999-1.299 EUR (VK)
|
||||||
|
|
||||||
|
| Komponente | Spezifikation |
|
||||||
|
|---|---|
|
||||||
|
| Hardware | Minisforum UM890 Pro oder Lenovo M75q Gen 5 |
|
||||||
|
| RAM | 32 GB DDR5 |
|
||||||
|
| Speicher | 1 TB NVMe SSD |
|
||||||
|
| Netzwerk | 2.5 GbE |
|
||||||
|
| Strom | ~10W idle |
|
||||||
|
| EK Hardware | ~500-650 EUR |
|
||||||
|
| Zielgruppe | 15-50 User |
|
||||||
|
| Apps | Auth + 8-15 Apps + MinIO |
|
||||||
|
|
||||||
|
### Mana Box L — 1.499-1.999 EUR (VK)
|
||||||
|
|
||||||
|
| Komponente | Spezifikation |
|
||||||
|
|---|---|
|
||||||
|
| Hardware | Beelink SER8 oder Minisforum MS-A1 |
|
||||||
|
| RAM | 64 GB DDR5 |
|
||||||
|
| Speicher | 2 TB NVMe SSD |
|
||||||
|
| Netzwerk | 2.5 GbE (oder 10 GbE bei MS-A1) |
|
||||||
|
| Strom | ~12W idle |
|
||||||
|
| EK Hardware | ~800-1.000 EUR |
|
||||||
|
| Zielgruppe | 50+ User, LLM lokal |
|
||||||
|
| Apps | Alle Apps + LLM-Inferenz |
|
||||||
|
|
||||||
|
## Betrieb & Management
|
||||||
|
|
||||||
|
### Provisioning
|
||||||
|
|
||||||
|
1. Debian 12 Minimal-Image mit vorinstalliertem Docker + Mana Stack
|
||||||
|
2. Image wird auf SSD geflasht (vor Versand oder per USB-Stick)
|
||||||
|
3. Kunde schliesst Box an Strom + LAN → Box startet automatisch
|
||||||
|
4. Ersteinrichtung via Web-UI (Admin-Account, Domain, App-Auswahl)
|
||||||
|
|
||||||
|
### Remote-Management
|
||||||
|
|
||||||
|
| Funktion | Technologie |
|
||||||
|
|---|---|
|
||||||
|
| VPN-Tunnel | Tailscale (oder WireGuard + eigener Koordinator) |
|
||||||
|
| Health-Monitoring | Phone-Home an Mana-Zentrale (Heartbeat, Metriken) |
|
||||||
|
| Remote-Shell | SSH via Tailscale (nur mit Kunden-Zustimmung) |
|
||||||
|
| Logs | Promtail → zentrale Loki-Instanz (optional, opt-in) |
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
|
||||||
|
| Aspekt | Loesung |
|
||||||
|
|---|---|
|
||||||
|
| OS-Updates | unattended-upgrades (Debian) |
|
||||||
|
| App-Updates | Docker Image Pull + Compose Restart |
|
||||||
|
| Update-Kanal | Staging → Stable (Kunden koennen waehlen) |
|
||||||
|
| Rollback | Vorheriges Image bleibt lokal, 1-Click Rollback |
|
||||||
|
| Zeitfenster | Konfigurierbares Wartungsfenster (z.B. 03:00-05:00) |
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
| Aspekt | Loesung |
|
||||||
|
|---|---|
|
||||||
|
| Datenbank | pg_dump, taeglich, verschluesselt |
|
||||||
|
| Dateien | MinIO/Medien-Backup |
|
||||||
|
| Ziel | Lokal (USB/NAS) und/oder verschluesselt in Mana Cloud |
|
||||||
|
| Retention | 7 Tage lokal, 30 Tage Cloud |
|
||||||
|
|
||||||
|
## Preiskalkulation (Beispiel Box M)
|
||||||
|
|
||||||
|
| Posten | Kosten |
|
||||||
|
|---|---|
|
||||||
|
| Hardware (EK) | ~600 EUR |
|
||||||
|
| Zusammenbau + Imaging + QA | ~50 EUR |
|
||||||
|
| Verpackung + Versand | ~30 EUR |
|
||||||
|
| **Gesamtkosten** | **~680 EUR** |
|
||||||
|
| **Verkaufspreis** | **999-1.299 EUR** |
|
||||||
|
| **Marge** | **~320-620 EUR (32-48%)** |
|
||||||
|
|
||||||
|
Zusaetzliche Einnahmen:
|
||||||
|
- Support-Abo: 29-99 EUR/Monat (Remote-Management, Updates, Monitoring)
|
||||||
|
- Erweiterungen: Zusaetzliche Apps, mehr Speicher, LLM-Modul
|
||||||
|
|
||||||
|
## Eigene Infrastruktur (weiterhin noetig)
|
||||||
|
|
||||||
|
Die Mana Boxen beim Kunden ersetzen nicht die eigene Infrastruktur. Weiterhin benoetigt:
|
||||||
|
|
||||||
|
| Zweck | Loesung |
|
||||||
|
|---|---|
|
||||||
|
| SaaS-Variante fuer Einzelnutzer | Mac Mini Cluster (2-3 Nodes) |
|
||||||
|
| Update-Server (Docker Registry) | Eigener Registry-Mirror |
|
||||||
|
| Health-Dashboard | Grafana + VictoriaMetrics |
|
||||||
|
| Tailscale Coordination | Headscale (self-hosted) |
|
||||||
|
| Landing Pages + Marketing | Cloudflare Pages |
|
||||||
|
| CI/CD (Image-Builds) | Forgejo Actions |
|
||||||
|
|
||||||
|
## Offene Fragen
|
||||||
|
|
||||||
|
- [ ] Welche Linux-Distribution? Debian 12 (stabil) vs. Ubuntu LTS (breiter Support)?
|
||||||
|
- [ ] Branding: Eigenes Gehaeuse/Aufkleber oder neutraler Mini PC?
|
||||||
|
- [ ] Garantie/Support: 1 Jahr vs. 2 Jahre? Austauschgeraet bei Defekt?
|
||||||
|
- [ ] App-Lizenzierung: Alle Apps inklusive oder modulares Lizenzmodell?
|
||||||
|
- [ ] LLM lokal: Ollama als optionales Modul? Welche Modelle passen in 16/32 GB?
|
||||||
|
- [ ] Backup-Cloud: Eigene S3-Instanz oder Hetzner Storage Box als Backup-Ziel?
|
||||||
|
- [ ] Pilotprojekt: Welcher Kunde/Anwendungsfall fuer den ersten Prototyp?
|
||||||
|
|
||||||
|
## Naechste Schritte
|
||||||
|
|
||||||
|
1. **Prototyp bauen**: N305 Mini PC oder M75q Gen 2 bestellen, Mana Stack als Docker Compose aufsetzen
|
||||||
|
2. **Benchmarken**: RAM/CPU-Verbrauch mit 5, 15, 30 simulierten Usern messen
|
||||||
|
3. **Provisioning-Image**: Reproduzierbares Debian-Image mit Packer oder cloud-init erstellen
|
||||||
|
4. **Remote-Management**: Tailscale/Headscale Setup testen
|
||||||
|
5. **Pricing validieren**: Gespraeche mit potenziellen Pilotkunden fuehren
|
||||||
|
|
@ -136,9 +136,9 @@
|
||||||
"dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
|
"dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
|
||||||
"inventar:dev": "turbo run dev --filter=inventar...",
|
"inventar:dev": "turbo run dev --filter=inventar...",
|
||||||
"dev:inventar:web": "pnpm --filter @inventar/web dev",
|
"dev:inventar:web": "pnpm --filter @inventar/web dev",
|
||||||
"taktik:dev": "turbo run dev --filter=taktik...",
|
"times:dev": "turbo run dev --filter=times...",
|
||||||
"dev:taktik:web": "pnpm --filter @taktik/web dev",
|
"dev:times:web": "pnpm --filter @times/web dev",
|
||||||
"dev:taktik:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:taktik:web\"",
|
"dev:times:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:times:web\"",
|
||||||
"manavoxel:dev": "turbo run dev --filter=manavoxel...",
|
"manavoxel:dev": "turbo run dev --filter=manavoxel...",
|
||||||
"dev:manavoxel:web": "pnpm --filter @manavoxel/web dev",
|
"dev:manavoxel:web": "pnpm --filter @manavoxel/web dev",
|
||||||
"dev:manavoxel:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:manavoxel:web\"",
|
"dev:manavoxel:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:manavoxel:web\"",
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@manacore/nestjs-integration",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "NestJS integration package for Mana Core authentication and credits",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"require": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./guards": {
|
|
||||||
"types": "./dist/guards/index.d.ts",
|
|
||||||
"import": "./dist/guards/index.js",
|
|
||||||
"require": "./dist/guards/index.js"
|
|
||||||
},
|
|
||||||
"./decorators": {
|
|
||||||
"types": "./dist/decorators/index.d.ts",
|
|
||||||
"import": "./dist/decorators/index.js",
|
|
||||||
"require": "./dist/decorators/index.js"
|
|
||||||
},
|
|
||||||
"./interceptors": {
|
|
||||||
"types": "./dist/interceptors/index.d.ts",
|
|
||||||
"import": "./dist/interceptors/index.js",
|
|
||||||
"require": "./dist/interceptors/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"dev": "tsc --watch",
|
|
||||||
"clean": "rm -rf dist",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"type-check": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@manacore/credit-operations": "workspace:*",
|
|
||||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
|
||||||
"@nestjs/config": "^3.0.0 || ^4.0.0",
|
|
||||||
"@nestjs/core": "^10.0.0 || ^11.0.0",
|
|
||||||
"reflect-metadata": "^0.1.13 || ^0.2.0",
|
|
||||||
"rxjs": "^7.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.0.0",
|
|
||||||
"typescript": "^5.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
|
||||||
"@nestjs/config": "^3.0.0 || ^4.0.0",
|
|
||||||
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { createParamDecorator } from '@nestjs/common';
|
|
||||||
import type { ExecutionContext } from '@nestjs/common';
|
|
||||||
|
|
||||||
export interface JwtPayload {
|
|
||||||
sub: string;
|
|
||||||
email: string;
|
|
||||||
role?: string;
|
|
||||||
app_id?: string;
|
|
||||||
iat?: number;
|
|
||||||
exp?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CurrentUser = createParamDecorator(
|
|
||||||
(data: keyof JwtPayload | undefined, ctx: ExecutionContext): JwtPayload | string => {
|
|
||||||
const request = ctx.switchToHttp().getRequest();
|
|
||||||
const user = request.user as JwtPayload;
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
return user[data] as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export { CurrentUser, JwtPayload } from './current-user.decorator';
|
|
||||||
export { Public, IS_PUBLIC_KEY } from './public.decorator';
|
|
||||||
export { UseCredits, CreditOperationConfig, CREDIT_OPERATION_KEY } from './use-credits.decorator';
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const IS_PUBLIC_KEY = 'isPublic';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decorator to mark a route as public (no authentication required)
|
|
||||||
*/
|
|
||||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { SetMetadata, applyDecorators, UseInterceptors } from '@nestjs/common';
|
|
||||||
import { CreditInterceptor } from '../interceptors/credit.interceptor';
|
|
||||||
import { type CreditOperationType } from '@manacore/credit-operations';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metadata key for credit operation configuration.
|
|
||||||
*/
|
|
||||||
export const CREDIT_OPERATION_KEY = 'credit_operation';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for credit consumption.
|
|
||||||
*/
|
|
||||||
export interface CreditOperationConfig {
|
|
||||||
/**
|
|
||||||
* The operation type from the credit-operations package.
|
|
||||||
*/
|
|
||||||
operation: CreditOperationType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom cost override. If not specified, uses the default from CREDIT_COSTS.
|
|
||||||
*/
|
|
||||||
customCost?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to consume credits before or after the handler execution.
|
|
||||||
* - 'before': Validate and reserve credits before execution (default)
|
|
||||||
* - 'after': Consume credits only after successful execution
|
|
||||||
*/
|
|
||||||
consumeMode?: 'before' | 'after';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional function to calculate cost dynamically based on request.
|
|
||||||
* Receives the request object and should return the credit cost.
|
|
||||||
*/
|
|
||||||
dynamicCost?: (request: any) => number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional function to generate description for the transaction.
|
|
||||||
* Receives the request object and should return a description string.
|
|
||||||
*/
|
|
||||||
descriptionFn?: (request: any) => string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to skip the credit check in development mode.
|
|
||||||
* Default: false
|
|
||||||
*/
|
|
||||||
skipInDev?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decorator to require credits for an endpoint.
|
|
||||||
*
|
|
||||||
* @example Simple usage with operation type:
|
|
||||||
* ```typescript
|
|
||||||
* @Post('tasks')
|
|
||||||
* @UseCredits(CreditOperationType.TASK_CREATE)
|
|
||||||
* async createTask(@Body() dto: CreateTaskDto) {
|
|
||||||
* return this.taskService.create(dto);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example With configuration object:
|
|
||||||
* ```typescript
|
|
||||||
* @Post('generate')
|
|
||||||
* @UseCredits({
|
|
||||||
* operation: CreditOperationType.AI_IMAGE_GENERATION,
|
|
||||||
* consumeMode: 'after',
|
|
||||||
* descriptionFn: (req) => `Generated image: ${req.body.prompt}`,
|
|
||||||
* })
|
|
||||||
* async generateImage(@Body() dto: GenerateDto) {
|
|
||||||
* return this.imageService.generate(dto);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example With dynamic cost:
|
|
||||||
* ```typescript
|
|
||||||
* @Post('bulk-import')
|
|
||||||
* @UseCredits({
|
|
||||||
* operation: CreditOperationType.BULK_IMPORT,
|
|
||||||
* dynamicCost: (req) => Math.ceil(req.body.items.length / 10) * 0.2,
|
|
||||||
* })
|
|
||||||
* async bulkImport(@Body() dto: BulkImportDto) {
|
|
||||||
* return this.importService.import(dto);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function UseCredits(
|
|
||||||
operationOrConfig: CreditOperationType | CreditOperationConfig
|
|
||||||
): MethodDecorator {
|
|
||||||
const config: CreditOperationConfig =
|
|
||||||
typeof operationOrConfig === 'string' ? { operation: operationOrConfig } : operationOrConfig;
|
|
||||||
|
|
||||||
return applyDecorators(
|
|
||||||
SetMetadata(CREDIT_OPERATION_KEY, config),
|
|
||||||
UseInterceptors(CreditInterceptor)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
|
||||||
|
|
||||||
export interface InsufficientCreditsDetails {
|
|
||||||
requiredCredits: number;
|
|
||||||
availableCredits: number;
|
|
||||||
creditType: 'user' | 'app';
|
|
||||||
operation: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InsufficientCreditsException extends HttpException {
|
|
||||||
constructor(details: InsufficientCreditsDetails) {
|
|
||||||
super(
|
|
||||||
{
|
|
||||||
statusCode: HttpStatus.PAYMENT_REQUIRED,
|
|
||||||
error: 'Insufficient Credits',
|
|
||||||
message: `Not enough credits for ${details.operation}. Required: ${details.requiredCredits}, Available: ${details.availableCredits}`,
|
|
||||||
details,
|
|
||||||
},
|
|
||||||
HttpStatus.PAYMENT_REQUIRED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
import {
|
|
||||||
Injectable,
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
UnauthorizedException,
|
|
||||||
Inject,
|
|
||||||
Optional,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { MANA_CORE_OPTIONS } from '../mana-core.module';
|
|
||||||
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
|
|
||||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
|
||||||
|
|
||||||
interface TokenValidationResponse {
|
|
||||||
valid: boolean;
|
|
||||||
payload?: {
|
|
||||||
sub: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
sessionId?: string;
|
|
||||||
sid?: string;
|
|
||||||
app_id?: string;
|
|
||||||
iat?: number;
|
|
||||||
exp?: number;
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default development test user ID
|
|
||||||
const DEFAULT_DEV_USER_ID = '00000000-0000-0000-0000-000000000000';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT Authentication Guard for NestJS backends.
|
|
||||||
*
|
|
||||||
* Validates JWT tokens by calling the Mana Core Auth service.
|
|
||||||
* Supports development mode bypass via DEV_BYPASS_AUTH=true.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class AuthGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
@Optional()
|
|
||||||
@Inject(MANA_CORE_OPTIONS)
|
|
||||||
private readonly options?: ManaCoreModuleOptions,
|
|
||||||
@Optional()
|
|
||||||
private readonly reflector?: Reflector,
|
|
||||||
@Optional()
|
|
||||||
private readonly configService?: ConfigService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
// Check if route is marked as public
|
|
||||||
if (this.reflector) {
|
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
if (isPublic) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
// Development mode: bypass auth if DEV_BYPASS_AUTH is set
|
|
||||||
if (this.shouldBypassAuth()) {
|
|
||||||
request.user = this.getDevUser();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = this.extractTokenFromHeader(request);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new UnauthorizedException('No authorization token provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userData = await this.validateToken(token);
|
|
||||||
request.user = userData;
|
|
||||||
request.accessToken = token;
|
|
||||||
|
|
||||||
if (this.options?.debug) {
|
|
||||||
console.log('[AuthGuard] User authenticated:', userData.sub);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UnauthorizedException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (this.options?.debug) {
|
|
||||||
console.error('[AuthGuard] Token validation failed:', error);
|
|
||||||
}
|
|
||||||
throw new UnauthorizedException('Invalid or expired token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if auth should be bypassed (development mode)
|
|
||||||
*/
|
|
||||||
private shouldBypassAuth(): boolean {
|
|
||||||
const isDev =
|
|
||||||
this.configService?.get<string>('NODE_ENV') === 'development' ||
|
|
||||||
process.env.NODE_ENV === 'development';
|
|
||||||
const bypassAuth =
|
|
||||||
this.configService?.get<string>('DEV_BYPASS_AUTH') === 'true' ||
|
|
||||||
process.env.DEV_BYPASS_AUTH === 'true';
|
|
||||||
return isDev && bypassAuth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get development user data
|
|
||||||
*/
|
|
||||||
private getDevUser() {
|
|
||||||
const devUserId =
|
|
||||||
this.configService?.get<string>('DEV_USER_ID') ||
|
|
||||||
process.env.DEV_USER_ID ||
|
|
||||||
DEFAULT_DEV_USER_ID;
|
|
||||||
return {
|
|
||||||
sub: devUserId,
|
|
||||||
email: 'dev@example.com',
|
|
||||||
role: 'user',
|
|
||||||
app_id: this.options?.appId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate token with Mana Core Auth service
|
|
||||||
*/
|
|
||||||
private async validateToken(token: string): Promise<any> {
|
|
||||||
const authUrl =
|
|
||||||
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
|
|
||||||
process.env.MANA_CORE_AUTH_URL ||
|
|
||||||
'http://localhost:3001';
|
|
||||||
|
|
||||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text().catch(() => 'Unknown error');
|
|
||||||
if (this.options?.debug) {
|
|
||||||
console.error('[AuthGuard] Token validation failed:', response.status, errorText);
|
|
||||||
}
|
|
||||||
throw new UnauthorizedException('Invalid token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await response.json()) as TokenValidationResponse;
|
|
||||||
|
|
||||||
if (!result.valid || !result.payload) {
|
|
||||||
throw new UnauthorizedException(result.error || 'Invalid token');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub: result.payload.sub,
|
|
||||||
email: result.payload.email,
|
|
||||||
role: result.payload.role,
|
|
||||||
app_id: result.payload.app_id || this.options?.appId,
|
|
||||||
sessionId: result.payload.sessionId || result.payload.sid,
|
|
||||||
iat: result.payload.iat,
|
|
||||||
exp: result.payload.exp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractTokenFromHeader(request: any): string | undefined {
|
|
||||||
const authHeader = request.headers.authorization;
|
|
||||||
if (!authHeader) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [type, token] = authHeader.split(' ');
|
|
||||||
return type === 'Bearer' ? token : undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { AuthGuard } from './auth.guard';
|
|
||||||
export { OptionalAuthGuard } from './optional-auth.guard';
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
import { Injectable, CanActivate, ExecutionContext, Inject, Optional } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { MANA_CORE_OPTIONS } from '../mana-core.module';
|
|
||||||
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
|
|
||||||
|
|
||||||
interface TokenValidationResponse {
|
|
||||||
valid: boolean;
|
|
||||||
payload?: {
|
|
||||||
sub: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
sessionId?: string;
|
|
||||||
sid?: string;
|
|
||||||
app_id?: string;
|
|
||||||
iat?: number;
|
|
||||||
exp?: number;
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional auth guard - allows unauthenticated requests but still validates and extracts user info if token is present
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class OptionalAuthGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
@Optional()
|
|
||||||
@Inject(MANA_CORE_OPTIONS)
|
|
||||||
private readonly options?: ManaCoreModuleOptions,
|
|
||||||
@Optional()
|
|
||||||
private readonly configService?: ConfigService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const token = this.extractTokenFromHeader(request);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
// No token - allow request but user will be undefined
|
|
||||||
request.user = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userData = await this.validateToken(token);
|
|
||||||
|
|
||||||
if (userData) {
|
|
||||||
request.user = userData;
|
|
||||||
request.accessToken = token;
|
|
||||||
|
|
||||||
if (this.options?.debug) {
|
|
||||||
console.log('[OptionalAuthGuard] User authenticated:', userData.sub);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
request.user = null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (this.options?.debug) {
|
|
||||||
console.error('[OptionalAuthGuard] Token validation failed:', error);
|
|
||||||
}
|
|
||||||
// For optional auth, we allow the request to proceed even if token validation fails
|
|
||||||
request.user = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate token with Mana Core Auth service
|
|
||||||
*/
|
|
||||||
private async validateToken(token: string): Promise<any | null> {
|
|
||||||
const authUrl =
|
|
||||||
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
|
|
||||||
process.env.MANA_CORE_AUTH_URL ||
|
|
||||||
'http://localhost:3001';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await response.json()) as TokenValidationResponse;
|
|
||||||
|
|
||||||
if (!result.valid || !result.payload) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub: result.payload.sub,
|
|
||||||
email: result.payload.email,
|
|
||||||
role: result.payload.role,
|
|
||||||
app_id: result.payload.app_id || this.options?.appId,
|
|
||||||
sessionId: result.payload.sessionId || result.payload.sid,
|
|
||||||
iat: result.payload.iat,
|
|
||||||
exp: result.payload.exp,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractTokenFromHeader(request: any): string | undefined {
|
|
||||||
const authHeader = request.headers.authorization;
|
|
||||||
if (!authHeader) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [type, token] = authHeader.split(' ');
|
|
||||||
return type === 'Bearer' ? token : undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
// Module
|
|
||||||
export { ManaCoreModule, MANA_CORE_OPTIONS } from './mana-core.module';
|
|
||||||
|
|
||||||
// Interfaces
|
|
||||||
export {
|
|
||||||
ManaCoreModuleOptions,
|
|
||||||
ManaCoreModuleAsyncOptions,
|
|
||||||
ManaCoreOptionsFactory,
|
|
||||||
} from './interfaces/mana-core-options.interface';
|
|
||||||
|
|
||||||
// Guards
|
|
||||||
export { AuthGuard } from './guards/auth.guard';
|
|
||||||
export { OptionalAuthGuard } from './guards/optional-auth.guard';
|
|
||||||
|
|
||||||
// Decorators
|
|
||||||
export { CurrentUser, JwtPayload } from './decorators/current-user.decorator';
|
|
||||||
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator';
|
|
||||||
export {
|
|
||||||
UseCredits,
|
|
||||||
CreditOperationConfig,
|
|
||||||
CREDIT_OPERATION_KEY,
|
|
||||||
} from './decorators/use-credits.decorator';
|
|
||||||
|
|
||||||
// Interceptors
|
|
||||||
export { CreditInterceptor } from './interceptors/credit.interceptor';
|
|
||||||
|
|
||||||
// Services
|
|
||||||
export {
|
|
||||||
CreditClientService,
|
|
||||||
CreditValidationResult,
|
|
||||||
CreditBalance,
|
|
||||||
} from './services/credit-client.service';
|
|
||||||
|
|
||||||
// Exceptions
|
|
||||||
export {
|
|
||||||
InsufficientCreditsException,
|
|
||||||
InsufficientCreditsDetails,
|
|
||||||
} from './exceptions/insufficient-credits.exception';
|
|
||||||
|
|
||||||
// Re-export credit operations for convenience
|
|
||||||
export {
|
|
||||||
CreditOperationType,
|
|
||||||
CREDIT_COSTS,
|
|
||||||
CreditCategory,
|
|
||||||
getCreditCost,
|
|
||||||
getOperationMetadata,
|
|
||||||
getOperationsForApp,
|
|
||||||
formatCreditCost,
|
|
||||||
getPricingTable,
|
|
||||||
isFreeOperation,
|
|
||||||
isMicroCreditOperation,
|
|
||||||
isAiOperation,
|
|
||||||
} from '@manacore/credit-operations';
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
import {
|
|
||||||
Injectable,
|
|
||||||
NestInterceptor,
|
|
||||||
ExecutionContext,
|
|
||||||
CallHandler,
|
|
||||||
Logger,
|
|
||||||
Inject,
|
|
||||||
Optional,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { Observable, tap, catchError, throwError } from 'rxjs';
|
|
||||||
import { CreditClientService } from '../services/credit-client.service';
|
|
||||||
import {
|
|
||||||
InsufficientCreditsException,
|
|
||||||
InsufficientCreditsDetails,
|
|
||||||
} from '../exceptions/insufficient-credits.exception';
|
|
||||||
import { CREDIT_OPERATION_KEY, CreditOperationConfig } from '../decorators/use-credits.decorator';
|
|
||||||
import { CREDIT_COSTS, getOperationMetadata } from '@manacore/credit-operations';
|
|
||||||
import { MANA_CORE_OPTIONS } from '../mana-core.module';
|
|
||||||
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interceptor that handles credit validation and consumption for decorated endpoints.
|
|
||||||
*
|
|
||||||
* This interceptor:
|
|
||||||
* 1. Checks if the user has sufficient credits before executing the handler
|
|
||||||
* 2. Consumes credits after successful execution (or before, depending on config)
|
|
||||||
* 3. Throws InsufficientCreditsException if the user doesn't have enough credits
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class CreditInterceptor implements NestInterceptor {
|
|
||||||
private readonly logger = new Logger(CreditInterceptor.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly reflector: Reflector,
|
|
||||||
private readonly creditClient: CreditClientService,
|
|
||||||
@Optional()
|
|
||||||
@Inject(MANA_CORE_OPTIONS)
|
|
||||||
private readonly options?: ManaCoreModuleOptions
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
|
||||||
const config = this.reflector.get<CreditOperationConfig>(
|
|
||||||
CREDIT_OPERATION_KEY,
|
|
||||||
context.getHandler()
|
|
||||||
);
|
|
||||||
|
|
||||||
// If no config, just proceed (shouldn't happen if decorator is used correctly)
|
|
||||||
if (!config) {
|
|
||||||
return next.handle();
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const user = request.user;
|
|
||||||
|
|
||||||
// Check if user is authenticated
|
|
||||||
if (!user?.sub) {
|
|
||||||
this.logger.warn('No authenticated user found for credit operation');
|
|
||||||
return next.handle();
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = user.sub;
|
|
||||||
const operationName = config.operation;
|
|
||||||
|
|
||||||
// Calculate cost
|
|
||||||
const cost = this.calculateCost(config, request);
|
|
||||||
const consumeMode = config.consumeMode || 'after';
|
|
||||||
|
|
||||||
// Skip in development if configured
|
|
||||||
if (config.skipInDev && this.isDevelopment()) {
|
|
||||||
this.logger.debug(`Skipping credit check in development for ${operationName}`);
|
|
||||||
return next.handle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate credits before execution
|
|
||||||
const validation = await this.creditClient.validateCredits(userId, operationName, cost);
|
|
||||||
|
|
||||||
if (!validation.hasCredits) {
|
|
||||||
const details: InsufficientCreditsDetails = {
|
|
||||||
requiredCredits: cost,
|
|
||||||
availableCredits: validation.availableCredits,
|
|
||||||
creditType: 'user',
|
|
||||||
operation: operationName,
|
|
||||||
};
|
|
||||||
throw new InsufficientCreditsException(details);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If consume mode is 'before', consume now
|
|
||||||
if (consumeMode === 'before') {
|
|
||||||
const description = this.generateDescription(config, request);
|
|
||||||
const consumed = await this.creditClient.consumeCredits(
|
|
||||||
userId,
|
|
||||||
operationName,
|
|
||||||
cost,
|
|
||||||
description,
|
|
||||||
this.buildMetadata(config, request)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!consumed) {
|
|
||||||
this.logger.error(`Failed to consume credits for ${operationName}`);
|
|
||||||
// Still allow the operation to proceed - fail open
|
|
||||||
}
|
|
||||||
|
|
||||||
return next.handle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If consume mode is 'after', consume on success
|
|
||||||
return next.handle().pipe(
|
|
||||||
tap(async () => {
|
|
||||||
const description = this.generateDescription(config, request);
|
|
||||||
const consumed = await this.creditClient.consumeCredits(
|
|
||||||
userId,
|
|
||||||
operationName,
|
|
||||||
cost,
|
|
||||||
description,
|
|
||||||
this.buildMetadata(config, request)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!consumed) {
|
|
||||||
this.logger.error(`Failed to consume credits after success for ${operationName}`);
|
|
||||||
} else if (this.options?.debug) {
|
|
||||||
this.logger.log(`Consumed ${cost} credits for ${operationName} (user: ${userId})`);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
// Don't consume credits if the operation failed
|
|
||||||
this.logger.debug(`Operation ${operationName} failed, credits not consumed`);
|
|
||||||
return throwError(() => error);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the credit cost for the operation.
|
|
||||||
*/
|
|
||||||
private calculateCost(config: CreditOperationConfig, request: any): number {
|
|
||||||
// Dynamic cost takes priority
|
|
||||||
if (config.dynamicCost) {
|
|
||||||
return config.dynamicCost(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom cost override
|
|
||||||
if (config.customCost !== undefined) {
|
|
||||||
return config.customCost;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default cost from CREDIT_COSTS
|
|
||||||
return CREDIT_COSTS[config.operation] || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a description for the credit transaction.
|
|
||||||
*/
|
|
||||||
private generateDescription(config: CreditOperationConfig, request: any): string {
|
|
||||||
// Custom description function
|
|
||||||
if (config.descriptionFn) {
|
|
||||||
return config.descriptionFn(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default description from operation metadata
|
|
||||||
const metadata = getOperationMetadata(config.operation);
|
|
||||||
return metadata?.name || config.operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build metadata for the credit transaction.
|
|
||||||
*/
|
|
||||||
private buildMetadata(config: CreditOperationConfig, request: any): Record<string, any> {
|
|
||||||
const metadata: Record<string, any> = {
|
|
||||||
operation: config.operation,
|
|
||||||
path: request.path,
|
|
||||||
method: request.method,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add app info from operation metadata
|
|
||||||
const opMeta = getOperationMetadata(config.operation);
|
|
||||||
if (opMeta) {
|
|
||||||
metadata.app = opMeta.app;
|
|
||||||
metadata.category = opMeta.category;
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if running in development mode.
|
|
||||||
*/
|
|
||||||
private isDevelopment(): boolean {
|
|
||||||
return (
|
|
||||||
this.options?.debug ||
|
|
||||||
process.env.NODE_ENV === 'development' ||
|
|
||||||
process.env.NODE_ENV === 'dev'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { CreditInterceptor } from './credit.interceptor';
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { type ModuleMetadata } from '@nestjs/common';
|
|
||||||
import type { Type } from '@nestjs/common';
|
|
||||||
|
|
||||||
export interface ManaCoreModuleOptions {
|
|
||||||
/**
|
|
||||||
* @deprecated No longer used - auth URL is read from MANA_CORE_AUTH_URL env variable
|
|
||||||
*/
|
|
||||||
manaServiceUrl?: string;
|
|
||||||
appId: string;
|
|
||||||
serviceKey?: string;
|
|
||||||
signupRedirectUrl?: string;
|
|
||||||
debug?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ManaCoreOptionsFactory {
|
|
||||||
createManaCoreOptions(): Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ManaCoreModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
|
||||||
useExisting?: Type<ManaCoreOptionsFactory>;
|
|
||||||
useClass?: Type<ManaCoreOptionsFactory>;
|
|
||||||
useFactory?: (...args: any[]) => Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
|
|
||||||
inject?: any[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { DynamicModule, Module, Global, Provider } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ManaCoreModuleOptions,
|
|
||||||
ManaCoreModuleAsyncOptions,
|
|
||||||
ManaCoreOptionsFactory,
|
|
||||||
} from './interfaces/mana-core-options.interface';
|
|
||||||
import { AuthGuard } from './guards/auth.guard';
|
|
||||||
import { CreditClientService } from './services/credit-client.service';
|
|
||||||
|
|
||||||
export const MANA_CORE_OPTIONS = 'MANA_CORE_OPTIONS';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({})
|
|
||||||
export class ManaCoreModule {
|
|
||||||
static forRoot(options: ManaCoreModuleOptions): DynamicModule {
|
|
||||||
return {
|
|
||||||
module: ManaCoreModule,
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: MANA_CORE_OPTIONS,
|
|
||||||
useValue: options,
|
|
||||||
},
|
|
||||||
AuthGuard,
|
|
||||||
CreditClientService,
|
|
||||||
],
|
|
||||||
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static forRootAsync(options: ManaCoreModuleAsyncOptions): DynamicModule {
|
|
||||||
const asyncProviders = this.createAsyncProviders(options);
|
|
||||||
|
|
||||||
return {
|
|
||||||
module: ManaCoreModule,
|
|
||||||
imports: options.imports || [],
|
|
||||||
providers: [...asyncProviders, AuthGuard, CreditClientService],
|
|
||||||
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static createAsyncProviders(options: ManaCoreModuleAsyncOptions): Provider[] {
|
|
||||||
if (options.useFactory) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
provide: MANA_CORE_OPTIONS,
|
|
||||||
useFactory: options.useFactory,
|
|
||||||
inject: options.inject || [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const useClass = options.useClass;
|
|
||||||
const useExisting = options.useExisting;
|
|
||||||
|
|
||||||
if (useClass) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
provide: MANA_CORE_OPTIONS,
|
|
||||||
useFactory: async (optionsFactory: ManaCoreOptionsFactory) =>
|
|
||||||
await optionsFactory.createManaCoreOptions(),
|
|
||||||
inject: [useClass],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: useClass,
|
|
||||||
useClass,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useExisting) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
provide: MANA_CORE_OPTIONS,
|
|
||||||
useFactory: async (optionsFactory: ManaCoreOptionsFactory) =>
|
|
||||||
await optionsFactory.createManaCoreOptions(),
|
|
||||||
inject: [useExisting],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
import { Injectable, Inject, Optional, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { MANA_CORE_OPTIONS } from '../mana-core.module';
|
|
||||||
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
|
|
||||||
|
|
||||||
export interface CreditValidationResult {
|
|
||||||
hasCredits: boolean;
|
|
||||||
availableCredits: number;
|
|
||||||
requiredCredits?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreditBalance {
|
|
||||||
balance: number;
|
|
||||||
totalEarned: number;
|
|
||||||
totalSpent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CreditClientService {
|
|
||||||
private readonly logger = new Logger(CreditClientService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Optional()
|
|
||||||
@Inject(MANA_CORE_OPTIONS)
|
|
||||||
private readonly options?: ManaCoreModuleOptions,
|
|
||||||
@Optional()
|
|
||||||
private readonly configService?: ConfigService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private getAuthUrl(): string {
|
|
||||||
return (
|
|
||||||
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
|
|
||||||
process.env.MANA_CORE_AUTH_URL ||
|
|
||||||
'http://localhost:3001'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the credits service URL. Uses MANA_CREDITS_URL if available,
|
|
||||||
* falls back to MANA_CORE_AUTH_URL for backward compatibility.
|
|
||||||
*/
|
|
||||||
private getCreditsUrl(): string {
|
|
||||||
return (
|
|
||||||
this.configService?.get<string>('MANA_CREDITS_URL') ||
|
|
||||||
process.env.MANA_CREDITS_URL ||
|
|
||||||
this.getAuthUrl()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getServiceKey(): string {
|
|
||||||
return (
|
|
||||||
this.options?.serviceKey ||
|
|
||||||
this.configService?.get<string>('MANA_CORE_SERVICE_KEY') ||
|
|
||||||
process.env.MANA_CORE_SERVICE_KEY ||
|
|
||||||
''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAppId(): string {
|
|
||||||
return (
|
|
||||||
this.options?.appId || this.configService?.get<string>('APP_ID') || process.env.APP_ID || ''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async validateCredits(
|
|
||||||
userId: string,
|
|
||||||
operation: string,
|
|
||||||
requiredAmount: number
|
|
||||||
): Promise<CreditValidationResult> {
|
|
||||||
try {
|
|
||||||
const balance = await this.getBalance(userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasCredits: balance.balance >= requiredAmount,
|
|
||||||
availableCredits: balance.balance,
|
|
||||||
requiredCredits: requiredAmount,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to validate credits for user ${userId}:`, error);
|
|
||||||
// In case of error, we allow the operation to proceed
|
|
||||||
// The actual credit deduction will fail if there are no credits
|
|
||||||
return {
|
|
||||||
hasCredits: true,
|
|
||||||
availableCredits: 0,
|
|
||||||
requiredCredits: requiredAmount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBalance(userId: string): Promise<CreditBalance> {
|
|
||||||
const creditsUrl = this.getCreditsUrl();
|
|
||||||
const serviceKey = this.getServiceKey();
|
|
||||||
|
|
||||||
if (!serviceKey) {
|
|
||||||
this.logger.warn('Service key not configured, returning default balance');
|
|
||||||
return {
|
|
||||||
balance: 1000,
|
|
||||||
totalEarned: 0,
|
|
||||||
totalSpent: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${creditsUrl}/api/v1/internal/credits/balance/${userId}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Service-Key': serviceKey,
|
|
||||||
'X-App-Id': this.getAppId(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
this.logger.warn(`Credit balance request failed: ${response.status}`);
|
|
||||||
return this.getDefaultBalance();
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
balance = 0,
|
|
||||||
totalEarned = 0,
|
|
||||||
totalSpent = 0,
|
|
||||||
} = (await response.json()) as CreditBalance;
|
|
||||||
return {
|
|
||||||
balance,
|
|
||||||
totalEarned,
|
|
||||||
totalSpent,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get balance for user ${userId}:`, error);
|
|
||||||
return this.getDefaultBalance();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async consumeCredits(
|
|
||||||
userId: string,
|
|
||||||
operation: string,
|
|
||||||
amount: number,
|
|
||||||
description: string,
|
|
||||||
metadata?: Record<string, any>,
|
|
||||||
creditSource?: { type: 'personal' } | { type: 'guild'; guildId: string }
|
|
||||||
): Promise<boolean> {
|
|
||||||
const creditsUrl = this.getCreditsUrl();
|
|
||||||
const serviceKey = this.getServiceKey();
|
|
||||||
|
|
||||||
if (!serviceKey) {
|
|
||||||
this.logger.warn('Service key not configured, skipping credit consumption');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${creditsUrl}/api/v1/internal/credits/use`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Service-Key': serviceKey,
|
|
||||||
'X-App-Id': this.getAppId(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
userId,
|
|
||||||
amount,
|
|
||||||
appId: this.getAppId(),
|
|
||||||
description,
|
|
||||||
metadata: {
|
|
||||||
operation,
|
|
||||||
...metadata,
|
|
||||||
},
|
|
||||||
...(creditSource && { creditSource }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text().catch(() => 'Unknown error');
|
|
||||||
this.logger.error(`Failed to consume credits: ${response.status} ${errorText}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.options?.debug) {
|
|
||||||
this.logger.log(`Consumed ${amount} credits for user ${userId}: ${description}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to consume credits for user ${userId}:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async refundCredits(
|
|
||||||
userId: string,
|
|
||||||
amount: number,
|
|
||||||
description: string,
|
|
||||||
metadata?: Record<string, any>
|
|
||||||
): Promise<boolean> {
|
|
||||||
const creditsUrl = this.getCreditsUrl();
|
|
||||||
const serviceKey = this.getServiceKey();
|
|
||||||
|
|
||||||
if (!serviceKey) {
|
|
||||||
this.logger.warn('Service key not configured, skipping credit refund');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${creditsUrl}/api/v1/internal/credits/refund`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Service-Key': serviceKey,
|
|
||||||
'X-App-Id': this.getAppId(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
userId,
|
|
||||||
amount,
|
|
||||||
appId: this.getAppId(),
|
|
||||||
description,
|
|
||||||
metadata,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text().catch(() => 'Unknown error');
|
|
||||||
this.logger.error(`Failed to refund credits: ${response.status} ${errorText}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.options?.debug) {
|
|
||||||
this.logger.log(`Refunded ${amount} credits for user ${userId}: ${description}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to refund credits for user ${userId}:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDefaultBalance(): CreditBalance {
|
|
||||||
return {
|
|
||||||
balance: 1000,
|
|
||||||
totalEarned: 0,
|
|
||||||
totalSpent: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2021",
|
|
||||||
"module": "commonjs",
|
|
||||||
"lib": ["ES2021"],
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"emitDecoratorMetadata": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
|
|
@ -76,7 +76,7 @@ const playgroundSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" f
|
||||||
const citycornersSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#ccGrad)"/><path d="M512 200C408.3 200 324 284.3 324 388C324 536 512 800 512 800C512 800 700 536 700 388C700 284.3 615.7 200 512 200ZM512 468C467.8 468 432 432.2 432 388C432 343.8 467.8 308 512 308C556.2 308 592 343.8 592 388C592 432.2 556.2 468 512 468Z" fill="white"/><circle cx="512" cy="388" r="60" fill="#2563eb" fill-opacity="0.4"/><defs><linearGradient id="ccGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#2563eb"/><stop offset="1" stop-color="#1d4ed8"/></linearGradient></defs></svg>`;
|
const citycornersSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#ccGrad)"/><path d="M512 200C408.3 200 324 284.3 324 388C324 536 512 800 512 800C512 800 700 536 700 388C700 284.3 615.7 200 512 200ZM512 468C467.8 468 432 432.2 432 388C432 343.8 467.8 308 512 308C556.2 308 592 343.8 592 388C592 432.2 556.2 468 512 468Z" fill="white"/><circle cx="512" cy="388" r="60" fill="#2563eb" fill-opacity="0.4"/><defs><linearGradient id="ccGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#2563eb"/><stop offset="1" stop-color="#1d4ed8"/></linearGradient></defs></svg>`;
|
||||||
|
|
||||||
// Taktik icon (clock with play button, amber gradient)
|
// Taktik icon (clock with play button, amber gradient)
|
||||||
const taktikSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#taktikGrad)"/><circle cx="512" cy="480" r="220" stroke="white" stroke-width="40"/><path d="M512 340V480L600 560" stroke="white" stroke-width="36" stroke-linecap="round" stroke-linejoin="round"/><circle cx="512" cy="480" r="20" fill="white"/><path d="M480 700L560 740L480 780Z" fill="white" fill-opacity="0.6"/><defs><linearGradient id="taktikGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#f59e0b"/><stop offset="1" stop-color="#d97706"/></linearGradient></defs></svg>`;
|
const timesSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#timesGrad)"/><circle cx="512" cy="480" r="220" stroke="white" stroke-width="40"/><path d="M512 340V480L600 560" stroke="white" stroke-width="36" stroke-linecap="round" stroke-linejoin="round"/><circle cx="512" cy="480" r="20" fill="white"/><path d="M480 700L560 740L480 780Z" fill="white" fill-opacity="0.6"/><defs><linearGradient id="timesGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#f59e0b"/><stop offset="1" stop-color="#d97706"/></linearGradient></defs></svg>`;
|
||||||
|
|
||||||
// Calc icon (calculator with pink gradient)
|
// Calc icon (calculator with pink gradient)
|
||||||
const calcSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#calcGrad)"/><rect x="320" y="260" width="384" height="504" rx="32" fill="white"/><rect x="360" y="300" width="304" height="100" rx="16" fill="#ec4899" fill-opacity="0.2"/><rect x="380" y="330" width="200" height="16" rx="4" fill="#ec4899" fill-opacity="0.5"/><rect x="380" y="358" width="120" height="24" rx="4" fill="#ec4899" fill-opacity="0.7"/><rect x="360" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="576" width="64" height="120" rx="12" fill="#ec4899"/><rect x="360" y="644" width="144" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="644" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><defs><linearGradient id="calcGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#ec4899"/><stop offset="1" stop-color="#db2777"/></linearGradient></defs></svg>`;
|
const calcSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#calcGrad)"/><rect x="320" y="260" width="384" height="504" rx="32" fill="white"/><rect x="360" y="300" width="304" height="100" rx="16" fill="#ec4899" fill-opacity="0.2"/><rect x="380" y="330" width="200" height="16" rx="4" fill="#ec4899" fill-opacity="0.5"/><rect x="380" y="358" width="120" height="24" rx="4" fill="#ec4899" fill-opacity="0.7"/><rect x="360" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="440" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="508" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.3"/><rect x="360" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="440" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="576" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="600" y="576" width="64" height="120" rx="12" fill="#ec4899"/><rect x="360" y="644" width="144" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><rect x="520" y="644" width="64" height="52" rx="12" fill="#ec4899" fill-opacity="0.15"/><defs><linearGradient id="calcGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#ec4899"/><stop offset="1" stop-color="#db2777"/></linearGradient></defs></svg>`;
|
||||||
|
|
@ -112,7 +112,7 @@ export const APP_ICONS = {
|
||||||
playground: svgToDataUrl(playgroundSvg),
|
playground: svgToDataUrl(playgroundSvg),
|
||||||
context: svgToDataUrl(contextSvg),
|
context: svgToDataUrl(contextSvg),
|
||||||
citycorners: svgToDataUrl(citycornersSvg),
|
citycorners: svgToDataUrl(citycornersSvg),
|
||||||
taktik: svgToDataUrl(taktikSvg),
|
times: svgToDataUrl(timesSvg),
|
||||||
calc: svgToDataUrl(calcSvg),
|
calc: svgToDataUrl(calcSvg),
|
||||||
uload: svgToDataUrl(
|
uload: svgToDataUrl(
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ug" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#818cf8"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ug)"/><path d="M35 45a10 10 0 0 1 10-10h0a10 10 0 0 1 0 20h0M65 55a10 10 0 0 1-10 10h0a10 10 0 0 1 0-20h0M42 58l16-16" stroke="white" stroke-width="5" stroke-linecap="round" fill="none"/></svg>`
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ug" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#818cf8"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ug)"/><path d="M35 45a10 10 0 0 1 10-10h0a10 10 0 0 1 0 20h0M65 55a10 10 0 0 1-10 10h0a10 10 0 0 1 0-20h0M42 58l16-16" stroke="white" stroke-width="5" stroke-linecap="round" fill="none"/></svg>`
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,7 @@ export const MANA_APPS: ManaApp[] = [
|
||||||
status: 'development',
|
status: 'development',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'taktik',
|
id: 'times',
|
||||||
name: 'Taktik',
|
name: 'Taktik',
|
||||||
description: {
|
description: {
|
||||||
de: 'Zeiterfassung & Timetracking',
|
de: 'Zeiterfassung & Timetracking',
|
||||||
|
|
@ -351,7 +351,7 @@ export const MANA_APPS: ManaApp[] = [
|
||||||
de: 'Professionelle Zeiterfassung mit Timer, Projekten, Kunden, Reports und Gilden-Integration.',
|
de: 'Professionelle Zeiterfassung mit Timer, Projekten, Kunden, Reports und Gilden-Integration.',
|
||||||
en: 'Professional time tracking with timer, projects, clients, reports, and guild integration.',
|
en: 'Professional time tracking with timer, projects, clients, reports, and guild integration.',
|
||||||
},
|
},
|
||||||
icon: APP_ICONS.taktik,
|
icon: APP_ICONS.times,
|
||||||
color: '#f59e0b',
|
color: '#f59e0b',
|
||||||
comingSoon: false,
|
comingSoon: false,
|
||||||
status: 'development',
|
status: 'development',
|
||||||
|
|
@ -527,7 +527,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
|
||||||
playground: { dev: 'http://localhost:5190', prod: 'https://playground.mana.how' },
|
playground: { dev: 'http://localhost:5190', prod: 'https://playground.mana.how' },
|
||||||
context: { dev: 'http://localhost:5192', prod: 'https://context.mana.how' },
|
context: { dev: 'http://localhost:5192', prod: 'https://context.mana.how' },
|
||||||
citycorners: { dev: 'http://localhost:5196', prod: 'https://citycorners.mana.how' },
|
citycorners: { dev: 'http://localhost:5196', prod: 'https://citycorners.mana.how' },
|
||||||
taktik: { dev: 'http://localhost:5197', prod: 'https://taktik.mana.how' },
|
times: { dev: 'http://localhost:5197', prod: 'https://times.mana.how' },
|
||||||
uload: { dev: 'http://localhost:5173', prod: 'https://ulo.ad' },
|
uload: { dev: 'http://localhost:5173', prod: 'https://ulo.ad' },
|
||||||
reader: { dev: 'exp://localhost:8081', prod: 'https://reader.mana.how' },
|
reader: { dev: 'exp://localhost:8081', prod: 'https://reader.mana.how' },
|
||||||
news: { dev: 'http://localhost:5174', prod: 'https://news.mana.how' },
|
news: { dev: 'http://localhost:5174', prod: 'https://news.mana.how' },
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,7 @@ export function createBetterAuth(databaseUrl: string) {
|
||||||
'https://questions.mana.how',
|
'https://questions.mana.how',
|
||||||
'https://skilltree.mana.how',
|
'https://skilltree.mana.how',
|
||||||
'https://storage.mana.how',
|
'https://storage.mana.how',
|
||||||
'https://taktik.mana.how',
|
'https://times.mana.how',
|
||||||
'https://todo.mana.how',
|
'https://todo.mana.how',
|
||||||
'https://traces.mana.how',
|
'https://traces.mana.how',
|
||||||
'https://zitare.mana.how',
|
'https://zitare.mana.how',
|
||||||
|
|
|
||||||
|
|
@ -192,4 +192,4 @@ services/mana-sync/
|
||||||
|
|
||||||
## Connected Apps (19)
|
## Connected Apps (19)
|
||||||
|
|
||||||
Todo, Calendar, Clock, Contacts, Chat, Questions, Mukke, Context, Photos, ManaDeck, Picture, Presi, Storage, Zitare, SkillTree, CityCorners, NutriPhi, Planta, Inventar, uLoad, Taktik, Calc
|
Todo, Calendar, Clock, Contacts, Chat, Questions, Mukke, Context, Photos, ManaDeck, Picture, Presi, Storage, Zitare, SkillTree, CityCorners, NutriPhi, Planta, Inventar, uLoad, Times, Calc
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue